Skip to content

Commit a673763

Browse files
authored
feat: add webhook update endpoint (#712)
1 parent 36e3f45 commit a673763

File tree

9 files changed

+305
-6
lines changed

9 files changed

+305
-6
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "resend",
3-
"version": "6.3.0-canary.3",
3+
"version": "6.3.0-canary.4",
44
"description": "Node.js library for the Resend API",
55
"main": "./dist/index.js",
66
"module": "./dist/index.mjs",

src/webhooks/interfaces/create-webhook-options.interface.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { PostOptions } from '../../common/interfaces';
22
import type { ErrorResponse } from '../../interfaces';
3+
import type { WebhookEvent } from './webhook-event.interface';
34

45
export interface CreateWebhookOptions {
56
endpoint: string;
6-
events: string[];
7+
events: WebhookEvent[];
78
}
89

910
export interface CreateWebhookRequestOptions extends PostOptions {}

src/webhooks/interfaces/get-webhook.interface.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { ErrorResponse } from '../../interfaces';
2+
import type { WebhookEvent } from './webhook-event.interface';
23

34
export interface GetWebhookResponseSuccess {
45
object: 'webhook';
56
id: string;
67
created_at: string;
7-
status: string;
8+
status: 'enabled' | 'disabled';
89
endpoint: string;
9-
events: string[] | null;
10+
events: WebhookEvent[] | null;
1011
signing_secret: string;
1112
}
1213

src/webhooks/interfaces/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ export type {
1818
RemoveWebhookResponse,
1919
RemoveWebhookResponseSuccess,
2020
} from './remove-webhook.interface';
21+
export type {
22+
UpdateWebhookOptions,
23+
UpdateWebhookResponse,
24+
UpdateWebhookResponseSuccess,
25+
} from './update-webhook.interface';
26+
export type { WebhookEvent } from './webhook-event.interface';

src/webhooks/interfaces/list-webhooks.interface.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { PaginationOptions } from '../../common/interfaces';
22
import type { ErrorResponse } from '../../interfaces';
3+
import type { WebhookEvent } from './webhook-event.interface';
34

45
export type ListWebhooksOptions = PaginationOptions;
56

67
export interface Webhook {
78
id: string;
89
endpoint: string;
910
created_at: string;
10-
status: string;
11-
events: string[] | null;
11+
status: 'enabled' | 'disabled';
12+
events: WebhookEvent[] | null;
1213
}
1314

1415
export type ListWebhooksResponseSuccess = {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ErrorResponse } from '../../interfaces';
2+
import type { WebhookEvent } from './webhook-event.interface';
3+
4+
export interface UpdateWebhookOptions {
5+
endpoint?: string;
6+
events?: WebhookEvent[];
7+
status?: 'enabled' | 'disabled';
8+
}
9+
10+
export interface UpdateWebhookResponseSuccess {
11+
object: 'webhook';
12+
id: string;
13+
}
14+
15+
export type UpdateWebhookResponse =
16+
| {
17+
data: UpdateWebhookResponseSuccess;
18+
error: null;
19+
}
20+
| {
21+
data: null;
22+
error: ErrorResponse;
23+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type WebhookEvent =
2+
| 'email.sent'
3+
| 'email.delivered'
4+
| 'email.delivery_delayed'
5+
| 'email.complained'
6+
| 'email.bounced'
7+
| 'email.opened'
8+
| 'email.clicked'
9+
| 'email.received'
10+
| 'email.failed'
11+
| 'contact.created'
12+
| 'contact.updated'
13+
| 'contact.deleted'
14+
| 'domain.created'
15+
| 'domain.updated'
16+
| 'domain.deleted';

src/webhooks/webhooks.spec.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {
1111
import type { GetWebhookResponseSuccess } from './interfaces/get-webhook.interface';
1212
import type { ListWebhooksResponseSuccess } from './interfaces/list-webhooks.interface';
1313
import type { RemoveWebhookResponseSuccess } from './interfaces/remove-webhook.interface';
14+
import type {
15+
UpdateWebhookOptions,
16+
UpdateWebhookResponseSuccess,
17+
} from './interfaces/update-webhook.interface';
1418

1519
const mocks = vi.hoisted(() => {
1620
const verify = vi.fn();
@@ -223,6 +227,237 @@ describe('Webhooks', () => {
223227
});
224228
});
225229

230+
describe('update', () => {
231+
const webhookId = '430eed87-632a-4ea6-90db-0aace67ec228';
232+
233+
it('updates all webhook fields', async () => {
234+
const payload: UpdateWebhookOptions = {
235+
endpoint: 'https://new.com/webhook',
236+
events: ['email.sent', 'email.delivered', 'email.bounced'],
237+
status: 'disabled',
238+
};
239+
const response: UpdateWebhookResponseSuccess = {
240+
object: 'webhook',
241+
id: webhookId,
242+
};
243+
244+
fetchMock.mockOnce(JSON.stringify(response), {
245+
status: 200,
246+
headers: {
247+
'content-type': 'application/json',
248+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
249+
},
250+
});
251+
252+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
253+
254+
await expect(
255+
resend.webhooks.update(webhookId, payload),
256+
).resolves.toMatchInlineSnapshot(`
257+
{
258+
"data": {
259+
"id": "430eed87-632a-4ea6-90db-0aace67ec228",
260+
"object": "webhook",
261+
},
262+
"error": null,
263+
}
264+
`);
265+
});
266+
267+
it('updates only endpoint field', async () => {
268+
const payload: UpdateWebhookOptions = {
269+
endpoint: 'https://new.com/webhook',
270+
};
271+
const response: UpdateWebhookResponseSuccess = {
272+
object: 'webhook',
273+
id: webhookId,
274+
};
275+
276+
fetchMock.mockOnce(JSON.stringify(response), {
277+
status: 200,
278+
headers: {
279+
'content-type': 'application/json',
280+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
281+
},
282+
});
283+
284+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
285+
286+
await expect(
287+
resend.webhooks.update(webhookId, payload),
288+
).resolves.toMatchInlineSnapshot(`
289+
{
290+
"data": {
291+
"id": "430eed87-632a-4ea6-90db-0aace67ec228",
292+
"object": "webhook",
293+
},
294+
"error": null,
295+
}
296+
`);
297+
});
298+
299+
it('updates only events field', async () => {
300+
const payload: UpdateWebhookOptions = {
301+
events: ['email.sent', 'email.delivered'],
302+
};
303+
const response: UpdateWebhookResponseSuccess = {
304+
object: 'webhook',
305+
id: webhookId,
306+
};
307+
308+
fetchMock.mockOnce(JSON.stringify(response), {
309+
status: 200,
310+
headers: {
311+
'content-type': 'application/json',
312+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
313+
},
314+
});
315+
316+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
317+
318+
await expect(
319+
resend.webhooks.update(webhookId, payload),
320+
).resolves.toMatchInlineSnapshot(`
321+
{
322+
"data": {
323+
"id": "430eed87-632a-4ea6-90db-0aace67ec228",
324+
"object": "webhook",
325+
},
326+
"error": null,
327+
}
328+
`);
329+
});
330+
331+
it('updates only status field to disabled', async () => {
332+
const payload: UpdateWebhookOptions = {
333+
status: 'disabled',
334+
};
335+
const response: UpdateWebhookResponseSuccess = {
336+
object: 'webhook',
337+
id: webhookId,
338+
};
339+
340+
fetchMock.mockOnce(JSON.stringify(response), {
341+
status: 200,
342+
headers: {
343+
'content-type': 'application/json',
344+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
345+
},
346+
});
347+
348+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
349+
350+
await expect(
351+
resend.webhooks.update(webhookId, payload),
352+
).resolves.toMatchInlineSnapshot(`
353+
{
354+
"data": {
355+
"id": "430eed87-632a-4ea6-90db-0aace67ec228",
356+
"object": "webhook",
357+
},
358+
"error": null,
359+
}
360+
`);
361+
});
362+
363+
it('updates only status field to enabled', async () => {
364+
const payload: UpdateWebhookOptions = {
365+
status: 'enabled',
366+
};
367+
const response: UpdateWebhookResponseSuccess = {
368+
object: 'webhook',
369+
id: webhookId,
370+
};
371+
372+
fetchMock.mockOnce(JSON.stringify(response), {
373+
status: 200,
374+
headers: {
375+
'content-type': 'application/json',
376+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
377+
},
378+
});
379+
380+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
381+
382+
await expect(
383+
resend.webhooks.update(webhookId, payload),
384+
).resolves.toMatchInlineSnapshot(`
385+
{
386+
"data": {
387+
"id": "430eed87-632a-4ea6-90db-0aace67ec228",
388+
"object": "webhook",
389+
},
390+
"error": null,
391+
}
392+
`);
393+
});
394+
395+
it('handles empty payload', async () => {
396+
const payload: UpdateWebhookOptions = {};
397+
const response: UpdateWebhookResponseSuccess = {
398+
object: 'webhook',
399+
id: webhookId,
400+
};
401+
402+
fetchMock.mockOnce(JSON.stringify(response), {
403+
status: 200,
404+
headers: {
405+
'content-type': 'application/json',
406+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
407+
},
408+
});
409+
410+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
411+
412+
await expect(
413+
resend.webhooks.update(webhookId, payload),
414+
).resolves.toMatchInlineSnapshot(`
415+
{
416+
"data": {
417+
"id": "430eed87-632a-4ea6-90db-0aace67ec228",
418+
"object": "webhook",
419+
},
420+
"error": null,
421+
}
422+
`);
423+
});
424+
425+
describe('when webhook not found', () => {
426+
it('returns error', async () => {
427+
const response: ErrorResponse = {
428+
name: 'not_found',
429+
message: 'Failed to update webhook endpoint',
430+
statusCode: 404,
431+
};
432+
433+
fetchMock.mockOnce(JSON.stringify(response), {
434+
status: 404,
435+
headers: {
436+
'content-type': 'application/json',
437+
Authorization: 'Bearer re_924b3rjh2387fbewf823',
438+
},
439+
});
440+
441+
const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
442+
443+
const result = resend.webhooks.update(webhookId, {
444+
endpoint: 'https://new.com/webhook',
445+
});
446+
447+
await expect(result).resolves.toMatchInlineSnapshot(`
448+
{
449+
"data": null,
450+
"error": {
451+
"message": "Failed to update webhook endpoint",
452+
"name": "not_found",
453+
"statusCode": 404,
454+
},
455+
}
456+
`);
457+
});
458+
});
459+
});
460+
226461
describe('remove', () => {
227462
it('removes a webhook', async () => {
228463
const id = '430eed87-632a-4ea6-90db-0aace67ec228';

src/webhooks/webhooks.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import type {
2020
RemoveWebhookResponse,
2121
RemoveWebhookResponseSuccess,
2222
} from './interfaces/remove-webhook.interface';
23+
import type {
24+
UpdateWebhookOptions,
25+
UpdateWebhookResponse,
26+
UpdateWebhookResponseSuccess,
27+
} from './interfaces/update-webhook.interface';
2328

2429
interface Headers {
2530
id: string;
@@ -64,6 +69,17 @@ export class Webhooks {
6469
return data;
6570
}
6671

72+
async update(
73+
id: string,
74+
payload: UpdateWebhookOptions,
75+
): Promise<UpdateWebhookResponse> {
76+
const data = await this.resend.patch<UpdateWebhookResponseSuccess>(
77+
`/webhooks/${id}`,
78+
payload,
79+
);
80+
return data;
81+
}
82+
6783
async remove(id: string): Promise<RemoveWebhookResponse> {
6884
const data = await this.resend.delete<RemoveWebhookResponseSuccess>(
6985
`/webhooks/${id}`,

0 commit comments

Comments
 (0)