@notifykit/sdk 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ const webhookJob = await client.sendWebhook({
40
40
 
41
41
  Emails are queued immediately (HTTP 202 Accepted) and delivered asynchronously. Use [getJob](#tracking-jobs) to confirm delivery.
42
42
 
43
- > **Note:** On the **Free plan**, emails send from NotifyKit's shared SendGrid account (`noreply@notifykit.dev`). On **Indie/Startup plans**, connect your own SendGrid API key in the dashboard before sending emails.
43
+ > **Note:** On the **Free plan**, emails send from NotifyKit's shared SendGrid account (`noreply@notifykit.dev`). On **Indie/Startup plans**, connect your own SendGrid, Resend, or Postmark API key in the dashboard before sending emails.
44
44
 
45
45
  ### Basic Email
46
46
 
@@ -59,7 +59,7 @@ await client.sendEmail({
59
59
  to: "user@example.com",
60
60
  subject: "Welcome",
61
61
  body: "<h1>Hello</h1>",
62
- from: "hello@em.yourapp.com", // Must be a verified domain
62
+ from: "hello@em.yourapp.com", // Must be a verified sending domain
63
63
  });
64
64
  ```
65
65
 
@@ -87,16 +87,18 @@ await client.sendEmail({
87
87
  provider: "SENDGRID",
88
88
  });
89
89
 
90
- // Force SendGrid first, then Resend if SendGrid fails. No other providers tried.
90
+ // Force Postmark first, then Resend if Postmark fails. No other providers tried.
91
91
  await client.sendEmail({
92
92
  to: "user@example.com",
93
93
  subject: "Receipt",
94
94
  body: "<h1>Thanks</h1>",
95
- provider: "SENDGRID",
95
+ provider: "POSTMARK",
96
96
  fallback: "RESEND",
97
97
  });
98
98
  ```
99
99
 
100
+ Valid provider values: `"SENDGRID"` | `"RESEND"` | `"POSTMARK"`
101
+
100
102
  **Validation:**
101
103
 
102
104
  | Case | Outcome |
@@ -104,6 +106,7 @@ await client.sendEmail({
104
106
  | `fallback` set without `provider` | `400 Bad Request` |
105
107
  | `provider` equals `fallback` | `400 Bad Request` |
106
108
  | Requested `provider` or `fallback` not configured for your account | `400 Bad Request` |
109
+ | Either provider used on Free plan | `400 Bad Request` |
107
110
 
108
111
  Forced routing is a contract: NotifyKit does **not** retry through providers you didn't authorize. The routing fields persist with the job, so manual or automatic retries replay the same attempt set.
109
112
 
@@ -130,7 +133,7 @@ await client.sendEmail({
130
133
 
131
134
  ## Sending Webhooks
132
135
 
133
- Webhooks are queued immediately (HTTP 202 Accepted) and delivered asynchronously with automatic retries on failure.
136
+ Webhooks are queued immediately (HTTP 202 Accepted) and delivered asynchronously with automatic retries on failure. Payloads are capped at **10kb**.
134
137
 
135
138
  ### Basic Webhook
136
139
 
@@ -152,7 +155,6 @@ await client.sendWebhook({
152
155
  method: "POST",
153
156
  payload: { orderId: "12345" },
154
157
  headers: {
155
- "X-Webhook-Secret": process.env.WEBHOOK_SECRET!,
156
158
  "X-Event-Type": "order.created",
157
159
  },
158
160
  idempotencyKey: "order-12345-webhook",
@@ -167,6 +169,50 @@ await client.sendWebhook({
167
169
 
168
170
  ---
169
171
 
172
+ ## Webhook Signing
173
+
174
+ When a webhook signing secret is configured in your dashboard, NotifyKit signs every outgoing webhook delivery with HMAC-SHA256. Your receiving endpoint can verify this signature to confirm the request is genuine and hasn't been replayed.
175
+
176
+ **Headers sent with each delivery:**
177
+
178
+ | Header | Value |
179
+ | ----------------------- | ------------------------------ |
180
+ | `X-Webhook-Timestamp` | Unix timestamp (seconds) |
181
+ | `X-Webhook-Signature` | `t=<timestamp>,v1=<hex>` |
182
+
183
+ ### Verifying Signatures
184
+
185
+ ```typescript
186
+ import { verifyWebhookSignature } from "@notifykit/sdk";
187
+
188
+ app.post("/webhooks/notifykit", (req, res) => {
189
+ const valid = verifyWebhookSignature({
190
+ payload: req.rawBody, // raw string — NOT parsed JSON
191
+ timestamp: req.headers["x-webhook-timestamp"],
192
+ signature: req.headers["x-webhook-signature"],
193
+ secret: process.env.NOTIFYKIT_WEBHOOK_SECRET!,
194
+ tolerance: 300, // optional, default 300s (5 min)
195
+ });
196
+
197
+ if (!valid) return res.status(401).send("Invalid signature");
198
+
199
+ const event = req.body;
200
+ // handle event...
201
+ res.sendStatus(200);
202
+ });
203
+ ```
204
+
205
+ > **Important:** Always use the **raw body string** (`req.rawBody`), not the parsed JSON object. Re-serializing a parsed object can produce a different byte sequence and will cause verification to fail.
206
+
207
+ The `tolerance` option rejects requests older than N seconds, protecting against replay attacks. Set it to `0` to disable the time check entirely.
208
+
209
+ **`verifyWebhookSignature` returns `false` (never throws) when:**
210
+ - The signature header is missing or malformed
211
+ - The timestamp is outside the tolerance window
212
+ - The HMAC digest does not match
213
+
214
+ ---
215
+
170
216
  ## Tracking Jobs
171
217
 
172
218
  Every notification returns a job ID you can use to track delivery status.
@@ -209,7 +255,7 @@ for (const log of status.deliveryLogs) {
209
255
  }
210
256
  ```
211
257
 
212
- For successful sends, the last entry's `usedProvider` is the provider that delivered. For failures, it's the last provider attempted. Webhook jobs return an empty array.
258
+ For successful sends, the last entry's `usedProvider` is the provider that delivered. For failures, it's the last provider attempted. Webhook jobs and Free plan jobs return `null` for `usedProvider`.
213
259
 
214
260
  ### List Jobs with Filters
215
261
 
@@ -217,8 +263,8 @@ For successful sends, the last entry's `usedProvider` is the provider that deliv
217
263
  const result = await client.listJobs({
218
264
  page: 1,
219
265
  limit: 20,
220
- type: "email", // Filter by type: 'email' or 'webhook'
221
- status: "failed", // Filter by status
266
+ type: "email", // 'email' | 'webhook'
267
+ status: "failed", // 'pending' | 'processing' | 'completed' | 'failed'
222
268
  });
223
269
 
224
270
  console.log(`Total: ${result.pagination.total} jobs`);
@@ -266,27 +312,49 @@ try {
266
312
 
267
313
  if (error.isStatus(400)) console.error("Bad request:", error.message);
268
314
  if (error.isStatus(401)) console.error("Invalid API key");
269
- if (error.isStatus(403))
270
- console.error("Quota or permission error:", error.message);
315
+ if (error.isStatus(403)) console.error("Quota or permission error:", error.message);
271
316
  if (error.isStatus(409)) console.error("Duplicate idempotency key");
272
- if (error.isStatus(429)) console.error("Rate limit exceeded");
317
+ if (error.isStatus(429)) {
318
+ console.error("Rate limit exceeded");
319
+ if (error.retryAfter) console.log(`Retry after ${error.retryAfter}s`);
320
+ }
273
321
  }
274
322
  }
275
323
  ```
276
324
 
325
+ ### Automatic Retries & Timeouts
326
+
327
+ The SDK retries transient failures automatically:
328
+
329
+ - Retries up to **2 times** (3 attempts total) on **5xx**, **429**, and network errors, with backoff (~200ms → ~500ms).
330
+ - On **429**, it waits for the server's `retryAfter` (capped at 10s) before retrying.
331
+ - **Other 4xx errors** (e.g. 400, 401, 409) fail immediately — they are never retried.
332
+ - Non-idempotent requests (`sendEmail`, `sendWebhook`, `retryJob`) are **not** retried on a timeout or dropped connection, to avoid duplicate sends.
333
+ - Every request times out after **10 seconds**.
334
+
335
+ > This is distinct from NotifyKit's server-side **webhook delivery** retries (see [Sending Webhooks](#sending-webhooks)), which control how delivery to your endpoint is re-attempted.
336
+
277
337
  ---
278
338
 
279
339
  ## API Reference
280
340
 
281
- | Method | Description | Returns |
282
- | ---------------------- | ------------------------------- | ------------------------------- |
283
- | `sendEmail(options)` | Send an email notification | `Promise<JobResponse>` |
284
- | `sendWebhook(options)` | Send a webhook notification | `Promise<JobResponse>` |
285
- | `getJob(jobId)` | Get job status and details | `Promise<JobStatus>` |
286
- | `listJobs(options?)` | List jobs with optional filters | `Promise<{ data, pagination }>` |
287
- | `retryJob(jobId)` | Retry a failed job | `Promise<RetryJobResponse>` |
288
- | `ping()` | Test API connection | `Promise<string>` |
289
- | `getApiInfo()` | Get API version info | `Promise<ApiInfo>` |
341
+ ### `NotifyKitClient` methods
342
+
343
+ | Method | Description | Returns |
344
+ | ------------------------------- | ------------------------------- | ------------------------------- |
345
+ | `sendEmail(options)` | Send an email notification | `Promise<JobResponse>` |
346
+ | `sendWebhook(options)` | Send a webhook notification | `Promise<JobResponse>` |
347
+ | `getJob(jobId)` | Get job status and delivery logs | `Promise<JobStatus>` |
348
+ | `listJobs(options?)` | List jobs with optional filters | `Promise<{ data, pagination }>` |
349
+ | `retryJob(jobId)` | Retry a failed job | `Promise<RetryJobResponse>` |
350
+ | `ping()` | Test API connection | `Promise<string>` |
351
+ | `getApiInfo()` | Get API version info | `Promise<ApiInfo>` |
352
+
353
+ ### Standalone utilities
354
+
355
+ | Function | Description |
356
+ | ------------------------------- | -------------------------------------------------- |
357
+ | `verifyWebhookSignature(options)` | Verify HMAC-SHA256 signature on incoming webhooks |
290
358
 
291
359
  ### TypeScript Types
292
360
 
@@ -296,7 +364,13 @@ import type {
296
364
  SendEmailOptions,
297
365
  SendWebhookOptions,
298
366
  JobResponse,
367
+ JobStatus,
368
+ JobSummary,
369
+ DeliveryLog,
370
+ RetryJobResponse,
299
371
  ApiInfo,
372
+ EmailProvider,
373
+ VerifyWebhookSignatureOptions,
300
374
  } from "@notifykit/sdk";
301
375
  ```
302
376
 
@@ -304,11 +378,11 @@ import type {
304
378
 
305
379
  ## Plans
306
380
 
307
- | Plan | Price | Webhooks/month | Emails/month |
308
- | ------- | ------ | -------------- | --------------------------------- |
309
- | Free | $0 | 100 (shared) | 100 (shared with webhooks) |
310
- | Indie | $9/mo | 4,000 | Unlimited (via your SendGrid key) |
311
- | Startup | $30/mo | 15,000 | Unlimited (via your SendGrid key) |
381
+ | Plan | Price | Webhooks/month | Emails/month | Rate limit |
382
+ | ------- | ------- | -------------- | -------------------------- | ------------ |
383
+ | Free | $0 | 100 (shared) | 100 (shared with webhooks) | 5 req/min |
384
+ | Indie | $5/mo | 4,000 | Unlimited (via your key) | 50 req/min |
385
+ | Startup | $10/mo | 15,000 | Unlimited (via your key) | 200 req/min |
312
386
 
313
387
  ---
314
388
 
package/dist/client.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { NotifyKitConfig, SendEmailOptions, SendWebhookOptions, JobResponse, JobStatus, JobSummary, ApiInfo, PaginationMeta, RetryJobResponse } from './types';
2
2
  export declare class NotifyKitClient {
3
- private client;
3
+ private readonly baseUrl;
4
+ private readonly headers;
4
5
  constructor(config: NotifyKitConfig);
6
+ private request;
5
7
  /** Test API connection */
6
8
  ping(): Promise<string>;
7
9
  /** Get API information */
package/dist/client.js CHANGED
@@ -1,94 +1,145 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.NotifyKitClient = void 0;
7
- const axios_1 = __importDefault(require("axios"));
8
4
  const errors_1 = require("./errors");
5
+ /** Abort a request that hasn't responded within this window. */
6
+ const TIMEOUT_MS = 10000;
7
+ /** Backoff delays between retries; length also caps the retry count (2 retries / 3 attempts). */
8
+ const RETRY_DELAYS_MS = [200, 500];
9
+ /** Upper bound for a server-provided Retry-After so a caller can't be stalled indefinitely. */
10
+ const MAX_RETRY_AFTER_MS = 10000;
11
+ /**
12
+ * Methods we can safely re-send after a transport failure (timeout / dropped connection).
13
+ * A timed-out POST may already have been processed by the server, so retrying it could
14
+ * duplicate a notification — only idempotent methods are retried on transport errors.
15
+ */
16
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD']);
17
+ function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+ /** Transient server states worth retrying regardless of HTTP method. */
21
+ function isRetryableStatus(status) {
22
+ return status >= 500 || status === 429;
23
+ }
24
+ function describeTransportError(err) {
25
+ if (err instanceof Error && err.name === 'TimeoutError')
26
+ return 'Request timed out';
27
+ if (err instanceof Error)
28
+ return err.message;
29
+ return 'Network error occurred';
30
+ }
31
+ /** Build a NotifyKitError from a non-OK (or success:false) response body. */
32
+ function toError(status, statusText, data) {
33
+ let message = statusText || 'Request failed';
34
+ let errors;
35
+ let retryAfter;
36
+ if (data) {
37
+ const detail = data.error ?? data.message;
38
+ if (Array.isArray(detail)) {
39
+ errors = detail;
40
+ message = data.error ? detail.join(', ') : 'Validation failed';
41
+ }
42
+ else if (detail != null) {
43
+ message = String(detail);
44
+ }
45
+ if (status === 429 && typeof data.retryAfter === 'number') {
46
+ retryAfter = data.retryAfter;
47
+ }
48
+ }
49
+ return new errors_1.NotifyKitError(message, status, data, errors, retryAfter);
50
+ }
9
51
  class NotifyKitClient {
10
52
  constructor(config) {
11
53
  if (!config.apiKey) {
12
54
  throw new Error('API key is required');
13
55
  }
14
- this.client = axios_1.default.create({
15
- baseURL: config.baseUrl || 'https://api.notifykit.dev',
16
- headers: {
17
- 'X-API-Key': config.apiKey,
18
- 'Content-Type': 'application/json',
19
- },
20
- });
21
- this.client.interceptors.response.use((response) => {
22
- const apiResponse = response.data;
23
- if (apiResponse && apiResponse.success === false) {
24
- throw new errors_1.NotifyKitError(apiResponse.error || apiResponse.message || 'Request failed', response.status, apiResponse);
56
+ this.baseUrl = (config.baseUrl ?? 'https://api.notifykit.dev').replace(/\/$/, '');
57
+ this.headers = {
58
+ 'X-API-Key': config.apiKey,
59
+ 'Content-Type': 'application/json',
60
+ };
61
+ }
62
+ async request(method, path, body) {
63
+ const retryTransport = IDEMPOTENT_METHODS.has(method);
64
+ for (let attempt = 0;; attempt++) {
65
+ const isLastAttempt = attempt >= RETRY_DELAYS_MS.length;
66
+ // --- Send ---
67
+ let res;
68
+ try {
69
+ res = await fetch(`${this.baseUrl}${path}`, {
70
+ method,
71
+ headers: this.headers,
72
+ body: body !== undefined ? JSON.stringify(body) : undefined,
73
+ signal: AbortSignal.timeout(TIMEOUT_MS),
74
+ });
25
75
  }
26
- return apiResponse?.data ?? apiResponse;
27
- }, (error) => {
28
- if (error.response) {
29
- const data = error.response.data;
30
- const statusCode = error.response.status;
31
- let message = error.message;
32
- let errors = null;
33
- if (data?.error) {
34
- if (Array.isArray(data.error)) {
35
- errors = data.error;
36
- message = data.error.join(', ');
37
- }
38
- else {
39
- message = data.error;
40
- }
76
+ catch (err) {
77
+ // Transport failure: retry only idempotent requests, and only while attempts remain.
78
+ if (retryTransport && !isLastAttempt) {
79
+ await sleep(RETRY_DELAYS_MS[attempt]);
80
+ continue;
41
81
  }
42
- else if (data?.message) {
43
- if (Array.isArray(data.message)) {
44
- errors = data.message;
45
- message = 'Validation failed';
46
- }
47
- else {
48
- message = data.message;
49
- }
50
- }
51
- const retryAfter = statusCode === 429 && typeof data?.retryAfter === 'number' ? data.retryAfter : undefined;
52
- throw new errors_1.NotifyKitError(message, statusCode, data, errors, retryAfter);
82
+ throw new errors_1.NotifyKitError(describeTransportError(err));
83
+ }
84
+ const data = await res.json().catch(() => null);
85
+ // --- Success ---
86
+ if (res.ok && data?.success !== false) {
87
+ return (data?.data ?? data);
53
88
  }
54
- throw new errors_1.NotifyKitError(error.message || 'Network error occurred');
55
- });
89
+ // --- Failure ---
90
+ const error = toError(res.status, res.statusText, data);
91
+ // Retry transient server states (5xx / 429) for any method; a 4xx fails immediately.
92
+ if (isRetryableStatus(res.status) && !isLastAttempt) {
93
+ const delay = error.retryAfter != null
94
+ ? Math.min(error.retryAfter * 1000, MAX_RETRY_AFTER_MS)
95
+ : RETRY_DELAYS_MS[attempt];
96
+ await sleep(delay);
97
+ continue;
98
+ }
99
+ throw error;
100
+ }
56
101
  }
57
102
  // ================================
58
103
  // APP
59
104
  // ================================
60
105
  /** Test API connection */
61
106
  async ping() {
62
- return await this.client.get('/api/v1/ping');
107
+ return this.request('GET', '/api/v1/ping');
63
108
  }
64
109
  /** Get API information */
65
110
  async getApiInfo() {
66
- return await this.client.get('/api/v1/info');
111
+ return this.request('GET', '/api/v1/info');
67
112
  }
68
113
  // ================================
69
114
  // NOTIFICATIONS
70
115
  // ================================
71
116
  /** Send an email notification */
72
117
  async sendEmail(options) {
73
- return await this.client.post('/api/v1/notifications/email', options);
118
+ return this.request('POST', '/api/v1/notifications/email', options);
74
119
  }
75
120
  /** Send a webhook notification */
76
121
  async sendWebhook(options) {
77
- return await this.client.post('/api/v1/notifications/webhook', options);
122
+ return this.request('POST', '/api/v1/notifications/webhook', options);
78
123
  }
79
124
  /** Get job status by ID */
80
125
  async getJob(jobId) {
81
- return await this.client.get(`/api/v1/notifications/jobs/${jobId}`);
126
+ return this.request('GET', `/api/v1/notifications/jobs/${jobId}`);
82
127
  }
83
128
  /** List jobs with optional filters */
84
129
  async listJobs(options) {
85
- return await this.client.get('/api/v1/notifications/jobs', {
86
- params: options,
87
- });
130
+ let path = '/api/v1/notifications/jobs';
131
+ if (options) {
132
+ const params = new URLSearchParams(Object.entries(options)
133
+ .filter(([, v]) => v !== undefined)
134
+ .map(([k, v]) => [k, String(v)]));
135
+ if (params.size > 0)
136
+ path += `?${params}`;
137
+ }
138
+ return this.request('GET', path);
88
139
  }
89
140
  /** Retry a failed job */
90
141
  async retryJob(jobId) {
91
- return await this.client.post(`/api/v1/notifications/jobs/${jobId}/retry`);
142
+ return this.request('POST', `/api/v1/notifications/jobs/${jobId}/retry`);
92
143
  }
93
144
  }
94
145
  exports.NotifyKitClient = NotifyKitClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notifykit/sdk",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Official NotifyKit SDK for Node.js",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,7 +14,7 @@
14
14
  "prepublishOnly": "npm run build"
15
15
  },
16
16
  "engines": {
17
- "node": ">=14.0.0"
17
+ "node": ">=18.0.0"
18
18
  },
19
19
  "keywords": [
20
20
  "notifykit",
@@ -33,9 +33,7 @@
33
33
  "url": "https://github.com/brayzonn/notifykit-sdk/issues"
34
34
  },
35
35
  "homepage": "https://github.com/brayzonn/notifykit-sdk#readme",
36
- "dependencies": {
37
- "axios": "^1.6.0"
38
- },
36
+ "dependencies": {},
39
37
  "devDependencies": {
40
38
  "@types/node": "^20.0.0",
41
39
  "typescript": "^5.0.0"