diff --git a/package.json b/package.json index 9a30e42e..3e7ea425 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "resend", - "version": "6.18.1", + "version": "6.19.0-preview-headless-dashboard.4", "description": "Node.js library for the Resend API", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/src/broadcasts/broadcasts.spec.ts b/src/broadcasts/broadcasts.spec.ts index cf19bbed..a1f47084 100644 --- a/src/broadcasts/broadcasts.spec.ts +++ b/src/broadcasts/broadcasts.spec.ts @@ -7,6 +7,8 @@ import type { CreateBroadcastResponseSuccess, } from './interfaces/create-broadcast-options.interface'; import type { GetBroadcastResponseSuccess } from './interfaces/get-broadcast.interface'; +import type { GetBroadcastMetricsResponseSuccess } from './interfaces/get-broadcast-metrics.interface'; +import type { ListBroadcastRecipientsResponseSuccess } from './interfaces/list-broadcast-recipients.interface'; import type { ListBroadcastsResponseSuccess } from './interfaces/list-broadcasts.interface'; import type { RemoveBroadcastResponseSuccess } from './interfaces/remove-broadcast.interface'; import type { UpdateBroadcastResponseSuccess } from './interfaces/update-broadcast.interface'; @@ -594,6 +596,358 @@ describe('Broadcasts', () => { }); }); + describe('metrics', () => { + describe('when broadcast not found', () => { + it('returns error', async () => { + const response: ErrorResponse = { + name: 'not_found', + statusCode: 404, + message: 'Broadcast not found', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 404, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.broadcasts.metrics( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + ); + + await expect(result).resolves.toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Broadcast not found", + "name": "not_found", + "statusCode": 404, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); + + it('retrieves broadcast metrics', async () => { + const response: GetBroadcastMetricsResponseSuccess = { + object: 'broadcast_metrics', + broadcast_id: '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + status: 'sent', + created_at: '2026-12-01 19:32:22.980+00', + scheduled_at: '2026-12-02 19:32:22.980+00', + sent_at: '2026-12-02 19:32:22.980+00', + total: 1000, + sent: 995, + remaining: 0, + delivered: { total: 945, percentage: 94.5 }, + opened: { total: 500, percentage: 50 }, + clicked: { total: 100, percentage: 10 }, + unsubscribed: { total: 20, percentage: 2 }, + bounced: { total: 50, percentage: 5 }, + complained: { total: 10, percentage: 1 }, + suppressed: { total: 5, percentage: 0.5 }, + clicked_links: [ + { + url: 'https://resend.com/pricing', + clicks: 90, + unique_clicks: 65, + percentage: 6.5, + }, + { + url: 'https://resend.com/docs', + clicks: 45, + unique_clicks: 35, + percentage: 3.5, + }, + ], + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await expect( + resend.broadcasts.metrics('559ac32e-9ef5-46fb-82a1-b76b840c0f7b'), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "bounced": { + "percentage": 5, + "total": 50, + }, + "broadcast_id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + "clicked": { + "percentage": 10, + "total": 100, + }, + "clicked_links": [ + { + "clicks": 90, + "percentage": 6.5, + "unique_clicks": 65, + "url": "https://resend.com/pricing", + }, + { + "clicks": 45, + "percentage": 3.5, + "unique_clicks": 35, + "url": "https://resend.com/docs", + }, + ], + "complained": { + "percentage": 1, + "total": 10, + }, + "created_at": "2026-12-01 19:32:22.980+00", + "delivered": { + "percentage": 94.5, + "total": 945, + }, + "object": "broadcast_metrics", + "opened": { + "percentage": 50, + "total": 500, + }, + "remaining": 0, + "scheduled_at": "2026-12-02 19:32:22.980+00", + "sent": 995, + "sent_at": "2026-12-02 19:32:22.980+00", + "status": "sent", + "suppressed": { + "percentage": 0.5, + "total": 5, + }, + "total": 1000, + "unsubscribed": { + "percentage": 2, + "total": 20, + }, + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); + + describe('recipients', () => { + describe('when broadcast not found', () => { + it('returns error', async () => { + const response: ErrorResponse = { + name: 'not_found', + statusCode: 404, + message: 'Broadcast not found', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 404, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.broadcasts.recipients( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + { type: 'clicked' }, + ); + + await expect(result).resolves.toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Broadcast not found", + "name": "not_found", + "statusCode": 404, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); + + it('lists broadcast recipients filtered by type', async () => { + const response: ListBroadcastRecipientsResponseSuccess<'clicked'> = { + object: 'list', + has_more: true, + data: [ + { + id: 'b2Zmc2V0OjA', + contact_id: 'e169aa45-1ecf-4183-9955-b1499d5701d3', + email: 'carter@example.com', + count: 3, + clicked_links: [ + { url: 'https://resend.com/pricing', clicks: 2 }, + { url: 'https://resend.com/docs', clicks: 1 }, + ], + }, + { + id: 'b2Zmc2V0OjE', + contact_id: null, + email: 'dana@example.com', + count: 1, + clicked_links: [{ url: 'https://resend.com/pricing', clicks: 1 }], + }, + ], + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.broadcasts.recipients( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + { type: 'clicked', limit: 20 }, + ); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/recipients?limit=20&type=clicked', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('passes email and bounceType filters', async () => { + const response: ListBroadcastRecipientsResponseSuccess<'bounced'> = { + object: 'list', + has_more: false, + data: [ + { + id: 'b2Zmc2V0OjA', + contact_id: null, + email: 'bounced@example.com', + bounce_type: 'permanent', + }, + ], + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.broadcasts.recipients( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + { + type: 'bounced', + email: 'bounced@example.com', + bounceType: 'permanent', + }, + ); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/recipients?type=bounced&email=bounced%40example.com&bounce_type=permanent', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('passes after cursor for pagination', async () => { + const response: ListBroadcastRecipientsResponseSuccess = { + object: 'list', + has_more: false, + data: [], + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await resend.broadcasts.recipients( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + { type: 'opened', limit: 10, after: 'cursor-value' }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/recipients?limit=10&after=cursor-value&type=opened', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('passes before cursor for pagination', async () => { + const response: ListBroadcastRecipientsResponseSuccess = { + object: 'list', + has_more: false, + data: [], + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await resend.broadcasts.recipients( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + { type: 'opened', limit: 10, before: 'cursor-value' }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/recipients?limit=10&before=cursor-value&type=opened', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + describe('remove', () => { it('removes a broadcast', async () => { const id = 'b01e0de9-7c27-4a53-bf38-2e3f98389a65'; diff --git a/src/broadcasts/broadcasts.ts b/src/broadcasts/broadcasts.ts index 6487eabe..73b4af9e 100644 --- a/src/broadcasts/broadcasts.ts +++ b/src/broadcasts/broadcasts.ts @@ -1,4 +1,7 @@ -import { buildPaginationUrl } from '../common/utils/build-pagination-query'; +import { + buildPaginationQuery, + buildPaginationUrl, +} from '../common/utils/build-pagination-query'; import { render } from '../render'; import type { Resend } from '../resend'; import type { @@ -9,6 +12,16 @@ import type { GetBroadcastResponse, GetBroadcastResponseSuccess, } from './interfaces/get-broadcast.interface'; +import type { + GetBroadcastMetricsResponse, + GetBroadcastMetricsResponseSuccess, +} from './interfaces/get-broadcast-metrics.interface'; +import type { + BroadcastRecipientEventType, + ListBroadcastRecipientsOptions, + ListBroadcastRecipientsResponse, + ListBroadcastRecipientsResponseSuccess, +} from './interfaces/list-broadcast-recipients.interface'; import type { ListBroadcastsOptions, ListBroadcastsResponse, @@ -88,6 +101,27 @@ export class Broadcasts { return data; } + async metrics(id: string): Promise { + const data = await this.resend.get( + `/broadcasts/${id}/metrics`, + ); + return data; + } + + async recipients( + id: string, + options: ListBroadcastRecipientsOptions, + ): Promise> { + const queryString = buildRecipientsQuery( + options as ListBroadcastRecipientsOptions, + ); + const url = `/broadcasts/${id}/recipients?${queryString}`; + + const data = + await this.resend.get>(url); + return data; + } + async remove(id: string): Promise { const data = await this.resend.delete( `/broadcasts/${id}`, @@ -119,3 +153,20 @@ export class Broadcasts { return data; } } + +function buildRecipientsQuery(options: ListBroadcastRecipientsOptions) { + const { type, email, bounceType, ...pagination } = options; + const searchParams = new URLSearchParams(buildPaginationQuery(pagination)); + + searchParams.set('type', type); + + if (email !== undefined) { + searchParams.set('email', email); + } + + if (bounceType !== undefined) { + searchParams.set('bounce_type', bounceType); + } + + return searchParams.toString(); +} diff --git a/src/broadcasts/interfaces/get-broadcast-metrics.interface.ts b/src/broadcasts/interfaces/get-broadcast-metrics.interface.ts new file mode 100644 index 00000000..b46b9a40 --- /dev/null +++ b/src/broadcasts/interfaces/get-broadcast-metrics.interface.ts @@ -0,0 +1,37 @@ +import type { Response } from '../../interfaces'; +import type { Broadcast } from './broadcast'; + +export interface BroadcastMetricCounter { + total: number; + percentage: number; +} + +export interface BroadcastMetricsClickedLink { + url: string; + clicks: number; + unique_clicks: number; + percentage: number; +} + +export interface GetBroadcastMetricsResponseSuccess { + object: 'broadcast_metrics'; + broadcast_id: string; + status: Broadcast['status']; + created_at: string; + scheduled_at: string | null; + sent_at: string | null; + total: number; + sent: number; + remaining: number; + delivered: BroadcastMetricCounter; + opened: BroadcastMetricCounter; + clicked: BroadcastMetricCounter; + unsubscribed: BroadcastMetricCounter; + bounced: BroadcastMetricCounter; + complained: BroadcastMetricCounter; + suppressed: BroadcastMetricCounter; + clicked_links: BroadcastMetricsClickedLink[]; +} + +export type GetBroadcastMetricsResponse = + Response; diff --git a/src/broadcasts/interfaces/index.ts b/src/broadcasts/interfaces/index.ts index c83f1c02..d782196d 100644 --- a/src/broadcasts/interfaces/index.ts +++ b/src/broadcasts/interfaces/index.ts @@ -1,6 +1,8 @@ export * from './broadcast'; export * from './create-broadcast-options.interface'; export * from './get-broadcast.interface'; +export * from './get-broadcast-metrics.interface'; +export * from './list-broadcast-recipients.interface'; export * from './list-broadcasts.interface'; export * from './remove-broadcast.interface'; export * from './send-broadcast-options.interface'; diff --git a/src/broadcasts/interfaces/list-broadcast-recipients.interface.ts b/src/broadcasts/interfaces/list-broadcast-recipients.interface.ts new file mode 100644 index 00000000..424cf035 --- /dev/null +++ b/src/broadcasts/interfaces/list-broadcast-recipients.interface.ts @@ -0,0 +1,75 @@ +import type { + PaginatedData, + PaginationOptions, +} from '../../common/interfaces/pagination-options.interface'; +import type { Response } from '../../interfaces'; + +export type BroadcastRecipientEventType = + | 'sent' + | 'delivered' + | 'opened' + | 'clicked' + | 'bounced' + | 'complained' + | 'unsubscribed' + | 'suppressed'; + +export type BroadcastRecipientBounceType = + | 'permanent' + | 'transient' + | 'undetermined'; + +export type ListBroadcastRecipientsOptions< + T extends BroadcastRecipientEventType = BroadcastRecipientEventType, +> = PaginationOptions & { + type: T; + email?: string; + bounceType?: T extends 'bounced' ? BroadcastRecipientBounceType : never; +}; + +export interface BroadcastRecipientClickedLink { + url: string; + clicks: number; +} + +type BroadcastRecipientBase = { + id: string; + contact_id: string | null; + email: string; +}; + +type BroadcastRecipientLoose = BroadcastRecipientBase & { + count?: number; + clicked_links?: BroadcastRecipientClickedLink[]; + bounce_type?: BroadcastRecipientBounceType; +}; + +// True for a union of 2+ members (including the full default union), false +// for a single literal. https://github.com/microsoft/TypeScript/issues/27024 +type IsUnion = T extends B ? ([B] extends [T] ? false : true) : never; + +type BroadcastRecipientFieldsByType = { + sent: unknown; + delivered: unknown; + opened: { count: number }; + clicked: { count: number; clicked_links: BroadcastRecipientClickedLink[] }; + bounced: { bounce_type: BroadcastRecipientBounceType }; + complained: unknown; + unsubscribed: unknown; + suppressed: unknown; +}; + +export type BroadcastRecipient< + T extends BroadcastRecipientEventType = BroadcastRecipientEventType, +> = + IsUnion extends true + ? BroadcastRecipientLoose + : BroadcastRecipientBase & BroadcastRecipientFieldsByType[T]; + +export type ListBroadcastRecipientsResponseSuccess< + T extends BroadcastRecipientEventType = BroadcastRecipientEventType, +> = PaginatedData[]>; + +export type ListBroadcastRecipientsResponse< + T extends BroadcastRecipientEventType = BroadcastRecipientEventType, +> = Response>; diff --git a/src/emails/emails.spec.ts b/src/emails/emails.spec.ts index ada9aee7..0cab333b 100644 --- a/src/emails/emails.spec.ts +++ b/src/emails/emails.spec.ts @@ -7,6 +7,7 @@ import type { CreateEmailResponseSuccess, } from './interfaces/create-email-options.interface'; import type { GetEmailResponseSuccess } from './interfaces/get-email-options.interface'; +import type { GetEmailsMetricsResponseSuccess } from './interfaces/get-metrics.interface'; import type { ListEmailsResponseSuccess } from './interfaces/list-emails-options.interface'; const fetchMocker = createFetchMock(vi); @@ -872,4 +873,388 @@ describe('Emails', () => { }); }); }); + + describe('metrics', () => { + it('calls endpoint with no options and returns the response', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: [], + granularity: 'daily', + sort_by: 'sent', + sort_order: 'desc', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics(); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics', + ); + }); + + it('calls endpoint passing date range, metrics, dimensions and filters', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'domain'], + granularity: 'daily', + sort_by: 'date', + sort_order: 'asc', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + data: [ + { + period: '2026-07-01', + domain_id: 'd91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + domain_name: 'example.com', + sent: 172, + delivered: 169, + open_rate: 49.7, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + startDate: '2026-07-01', + endDate: '2026-07-08', + timezone: 'America/New_York', + granularity: 'daily', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'domain'], + filter: { domainId: ['d91cd9bd-1176-4f47-2a4b-fce2d5399cbf'] }, + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?start_date=2026-07-01&end_date=2026-07-08&timezone=America%2FNew_York&granularity=daily&metrics=sent%2Cdelivered%2Copen_rate&dimensions=period%2Cdomain&domain_id=d91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + ); + }); + + it('calls endpoint passing the email dimension and email_id', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'email'], + granularity: 'daily', + sort_by: 'date', + sort_order: 'asc', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + data: [ + { + period: '2026-07-01', + email_id: '4dd369bc-aa82-4ff3-97de-514ae3000ee0', + sent: 172, + delivered: 169, + open_rate: 49.7, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + startDate: '2026-07-01', + endDate: '2026-07-08', + timezone: 'America/New_York', + granularity: 'daily', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'email'], + filter: { emailId: ['4dd369bc-aa82-4ff3-97de-514ae3000ee0'] }, + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?start_date=2026-07-01&end_date=2026-07-08&timezone=America%2FNew_York&granularity=daily&metrics=sent%2Cdelivered%2Copen_rate&dimensions=period%2Cemail&email_id=4dd369bc-aa82-4ff3-97de-514ae3000ee0', + ); + }); + + it('calls endpoint passing sortBy=date and sortOrder for a period breakdown', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent'], + dimensions: ['period'], + granularity: 'daily', + sort_by: 'date', + sort_order: 'desc', + totals: { + sent: 1204, + }, + data: [ + { period: '2026-07-08', sent: 200 }, + { period: '2026-07-01', sent: 150 }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + startDate: '2026-07-01', + endDate: '2026-07-08', + metrics: ['sent'], + dimensions: ['period'], + sortBy: 'date', + sortOrder: 'desc', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?start_date=2026-07-01&end_date=2026-07-08&metrics=sent&dimensions=period&sort_by=date&sort_order=desc', + ); + }); + + it('calls endpoint passing sortBy=date for a period+domain breakdown', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent'], + dimensions: ['period', 'domain'], + granularity: 'daily', + sort_by: 'date', + sort_order: 'asc', + totals: { + sent: 1204, + }, + data: [ + { + period: '2026-07-01', + domain_id: 'd91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + domain_name: 'example.com', + sent: 172, + }, + { + period: '2026-07-08', + domain_id: 'd91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + domain_name: 'example.com', + sent: 200, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + metrics: ['sent'], + dimensions: ['period', 'domain'], + sortBy: 'date', + sortOrder: 'asc', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?metrics=sent&dimensions=period%2Cdomain&sort_by=date&sort_order=asc', + ); + }); + + it('calls endpoint passing a metric sortBy for a domain breakdown', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent'], + dimensions: ['domain'], + granularity: 'daily', + sort_by: 'sent', + sort_order: 'asc', + totals: { + sent: 1204, + }, + data: [ + { + domain_id: 'd91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + domain_name: 'example.com', + sent: 172, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + dimensions: ['domain'], + sortBy: 'sent', + sortOrder: 'asc', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?dimensions=domain&sort_by=sent&sort_order=asc', + ); + }); + + it('calls endpoint passing an engagement metric sortBy for a domain breakdown', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['unique_opened'], + dimensions: ['domain'], + granularity: 'daily', + sort_by: 'unique_opened', + sort_order: 'desc', + totals: { + unique_opened: 512, + }, + data: [ + { + domain_id: 'd91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + domain_name: 'example.com', + unique_opened: 128, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + dimensions: ['domain'], + metrics: ['unique_opened'], + sortBy: 'unique_opened', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?metrics=unique_opened&dimensions=domain&sort_by=unique_opened', + ); + }); + + it('calls endpoint passing a rate metric sortBy for an email breakdown', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['open_rate'], + dimensions: ['email'], + granularity: 'daily', + sort_by: 'open_rate', + sort_order: 'desc', + totals: { + open_rate: 42.5, + }, + data: [ + { + email_id: '3c9a1e0a-2b7d-4e0a-9e0a-2b7d4e0a9e0a', + open_rate: 63.4, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + dimensions: ['email'], + metrics: ['open_rate'], + sortBy: 'open_rate', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?metrics=open_rate&dimensions=email&sort_by=open_rate', + ); + }); + + it('returns error when request fails', async () => { + const response: ErrorResponse = { + name: 'validation_error', + message: 'Invalid `start_date`.', + statusCode: 422, + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + }, + }); + + const result = await resend.emails.metrics({ startDate: 'not-a-date' }); + + expect(result).toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Invalid \`start_date\`.", + "name": "validation_error", + "statusCode": 422, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); }); diff --git a/src/emails/emails.ts b/src/emails/emails.ts index 06640262..1dd64121 100644 --- a/src/emails/emails.ts +++ b/src/emails/emails.ts @@ -17,6 +17,11 @@ import type { GetEmailResponse, GetEmailResponseSuccess, } from './interfaces/get-email-options.interface'; +import type { + GetEmailsMetricsOptions, + GetEmailsMetricsResponse, + GetEmailsMetricsResponseSuccess, +} from './interfaces/get-metrics.interface'; import type { ListEmailsOptions, ListEmailsResponse, @@ -96,4 +101,40 @@ export class Emails { ); return data; } + + async metrics( + options: GetEmailsMetricsOptions = {}, + ): Promise { + const queryString = buildMetricsQuery(options); + const url = queryString + ? `/emails/metrics?${queryString}` + : '/emails/metrics'; + + const data = await this.resend.get(url); + return data; + } +} + +function buildMetricsQuery(options: GetEmailsMetricsOptions) { + const params: Record = { + start_date: options.startDate, + end_date: options.endDate, + timezone: options.timezone, + granularity: options.granularity, + metrics: options.metrics?.join(','), + dimensions: options.dimensions?.join(','), + domain_id: options.filter?.domainId?.join(','), + email_id: options.filter?.emailId?.join(','), + sort_by: options.sortBy, + sort_order: options.sortOrder, + }; + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + searchParams.set(key, value); + } + } + + return searchParams.toString(); } diff --git a/src/emails/interfaces/get-metrics.interface.ts b/src/emails/interfaces/get-metrics.interface.ts new file mode 100644 index 00000000..7ad9491d --- /dev/null +++ b/src/emails/interfaces/get-metrics.interface.ts @@ -0,0 +1,126 @@ +import type { Response } from '../../interfaces'; + +export type EmailMetric = + | 'received' + | 'delivered' + | 'complained' + | 'suppressed' + | 'bounced' + | 'bounced_transient' + | 'bounced_permanent' + | 'bounced_undetermined' + | 'opened' + | 'clicked' + | 'unsubscribed' + | 'delivery_delayed' + | 'failed' + | 'sent' + | 'unique_opened' + | 'unique_clicked' + | 'delivery_rate' + | 'open_rate' + | 'click_rate' + | 'bounce_rate' + | 'complaint_rate' + | 'unsubscribe_rate'; + +export type EmailMetricsDimension = 'period' | 'domain' | 'email'; + +export type EmailMetricsGranularity = 'hourly' | 'daily' | 'weekly' | 'monthly'; + +export type EmailMetricsSortBy = 'date' | EmailMetric; + +export type EmailMetricsSortOrder = 'asc' | 'desc'; + +export type GetEmailsMetricsOptions = { + /** + * The start of the date range, as an ISO 8601 date or datetime. + * Defaults to 6 days before `endDate`. + * + * @link https://resend.com/docs/api-reference/emails/get-metrics#query-parameters + */ + startDate?: string; + + /** + * The end of the date range, as an ISO 8601 date or datetime. + * Defaults to now. + * + * @link https://resend.com/docs/api-reference/emails/get-metrics#query-parameters + */ + endDate?: string; + + /** + * The IANA timezone used to bucket periods when `period` is in `dimensions`. + * Defaults to `UTC`. + */ + timezone?: string; + + /** + * The bucket size used when `period` is in `dimensions`. + * Defaults to `daily`. + */ + granularity?: EmailMetricsGranularity; + + /** + * The metrics to include in the response. Defaults to all metrics. + */ + metrics?: EmailMetric[]; + + /** + * The dimensions to break the response down by. Defaults to `[]`, which + * returns a single `totals` row for the whole range, with no `data`. + */ + dimensions?: EmailMetricsDimension[]; + + filter?: { + /** + * Restrict the response to these sending domain IDs. + */ + domainId?: string[]; + + /** + * Restrict the response to these email IDs. Cannot be combined with the + * `domain` dimension. + */ + emailId?: string[]; + }; + + /** + * What to sort `data` by. Defaults to `date` when `dimensions` includes + * `period`, or `sent` otherwise. + * + * @link https://resend.com/docs/api-reference/emails/get-metrics#query-parameters + */ + sortBy?: EmailMetricsSortBy; + + /** + * The sort direction for `sortBy`. Defaults to `asc` when `dimensions` + * includes `period`, `desc` otherwise. + */ + sortOrder?: EmailMetricsSortOrder; +}; + +export type EmailMetricsTotals = Partial>; + +export type EmailMetricsDataRow = EmailMetricsTotals & { + period?: string; + domain_id?: string; + domain_name?: string; + email_id?: string; +}; + +export interface GetEmailsMetricsResponseSuccess { + object: 'metrics'; + start_date: string; + end_date: string; + metrics: EmailMetric[]; + dimensions: EmailMetricsDimension[]; + granularity: EmailMetricsGranularity; + sort_by: EmailMetricsSortBy; + sort_order: EmailMetricsSortOrder; + totals: EmailMetricsTotals; + data?: EmailMetricsDataRow[]; +} + +export type GetEmailsMetricsResponse = + Response; diff --git a/src/emails/interfaces/index.ts b/src/emails/interfaces/index.ts index fa471001..d725324e 100644 --- a/src/emails/interfaces/index.ts +++ b/src/emails/interfaces/index.ts @@ -1,5 +1,6 @@ export * from './cancel-email-options.interface'; export * from './create-email-options.interface'; export * from './get-email-options.interface'; +export * from './get-metrics.interface'; export * from './list-emails-options.interface'; export * from './update-email-options.interface'; diff --git a/src/index.ts b/src/index.ts index 65701147..6d2cb289 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,4 +24,5 @@ export * from './suppressions/batch/interfaces'; export * from './suppressions/interfaces'; export * from './templates/interfaces'; export * from './topics/interfaces'; +export * from './usage/interfaces'; export * from './webhooks/interfaces'; diff --git a/src/resend.ts b/src/resend.ts index fe4f5e0e..f5da0f8f 100644 --- a/src/resend.ts +++ b/src/resend.ts @@ -23,6 +23,7 @@ import { Segments } from './segments/segments'; import { Suppressions } from './suppressions/suppressions'; import { Templates } from './templates/templates'; import { Topics } from './topics/topics'; +import { Usage } from './usage/usage'; import { Webhooks } from './webhooks/webhooks'; const defaultBaseUrl = 'https://api.resend.com'; @@ -69,6 +70,7 @@ export class Resend { readonly suppressions = new Suppressions(this); readonly templates = new Templates(this); readonly topics = new Topics(this); + readonly usage = new Usage(this); readonly webhooks = new Webhooks(this); constructor( diff --git a/src/segments/interfaces/get-metrics.interface.ts b/src/segments/interfaces/get-metrics.interface.ts new file mode 100644 index 00000000..7dc75173 --- /dev/null +++ b/src/segments/interfaces/get-metrics.interface.ts @@ -0,0 +1,67 @@ +import type { Response } from '../../interfaces'; + +export type SegmentMetric = 'all_contacts' | 'subscribers' | 'unsubscribers'; + +export type SegmentMetricsDimension = 'segment'; + +export type SegmentMetricsSortBy = 'date' | SegmentMetric; + +export type SegmentMetricsSortOrder = 'asc' | 'desc'; + +export type GetSegmentsMetricsOptions = { + /** + * The metrics to include in the response. Defaults to all metrics. + * + * @link https://resend.com/docs/api-reference/segments/get-segment-metrics#query-parameters + */ + metrics?: SegmentMetric[]; + + /** + * The dimensions to break `data` down by. Defaults to `[]`, which returns + * only `totals` with no `data`. + */ + dimensions?: SegmentMetricsDimension[]; + + filter?: { + /** + * Restrict `totals` (and `data`, when requested) to these segment IDs, + * without double-counting contacts that belong to more than one. + */ + segmentId?: string[]; + }; + + /** + * What to sort the segment breakdown by: `date` (each segment's creation + * date), or a metric key to sort by that value instead. Only applies to + * `data` (when `dimensions` includes `segment`) — `totals` ignores sort + * params. Defaults to `date`. + * + * @link https://resend.com/docs/api-reference/segments/get-segment-metrics#query-parameters + */ + sortBy?: SegmentMetricsSortBy; + + /** + * The sort direction for `sortBy`. Defaults to `desc`. + */ + sortOrder?: SegmentMetricsSortOrder; +}; + +export type SegmentMetricsTotals = Partial>; + +export type SegmentMetricsDataRow = SegmentMetricsTotals & { + segment_id: string; + segment_name: string; +}; + +export interface GetSegmentsMetricsResponseSuccess { + object: 'segments_metrics'; + metrics: SegmentMetric[]; + dimensions: SegmentMetricsDimension[]; + sort_by: SegmentMetricsSortBy; + sort_order: SegmentMetricsSortOrder; + totals: SegmentMetricsTotals; + data?: SegmentMetricsDataRow[]; +} + +export type GetSegmentsMetricsResponse = + Response; diff --git a/src/segments/interfaces/index.ts b/src/segments/interfaces/index.ts index c572db5d..c62a18ad 100644 --- a/src/segments/interfaces/index.ts +++ b/src/segments/interfaces/index.ts @@ -1,4 +1,5 @@ export * from './create-segment-options.interface'; +export * from './get-metrics.interface'; export * from './get-segment.interface'; export * from './list-segments.interface'; export * from './remove-segment.interface'; diff --git a/src/segments/segments.spec.ts b/src/segments/segments.spec.ts index 3414c9ba..7f77700f 100644 --- a/src/segments/segments.spec.ts +++ b/src/segments/segments.spec.ts @@ -6,6 +6,7 @@ import type { CreateSegmentOptions, CreateSegmentResponseSuccess, } from './interfaces/create-segment-options.interface'; +import type { GetSegmentsMetricsResponseSuccess } from './interfaces/get-metrics.interface'; import type { GetSegmentResponseSuccess } from './interfaces/get-segment.interface'; import type { ListSegmentsResponseSuccess } from './interfaces/list-segments.interface'; import type { RemoveSegmentResponseSuccess } from './interfaces/remove-segment.interface'; @@ -212,6 +213,254 @@ describe('Segments', () => { }); }); + describe('metrics', () => { + it('calls endpoint with no options and returns the response', async () => { + const response: GetSegmentsMetricsResponseSuccess = { + object: 'segments_metrics', + metrics: ['all_contacts', 'subscribers', 'unsubscribers'], + dimensions: [], + sort_by: 'date', + sort_order: 'desc', + totals: { + all_contacts: 12450, + subscribers: 11800, + unsubscribers: 650, + }, + }; + + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.segments.metrics(); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/segments/metrics', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('omits query params when passed as empty arrays', async () => { + const response: GetSegmentsMetricsResponseSuccess = { + object: 'segments_metrics', + metrics: ['all_contacts', 'subscribers', 'unsubscribers'], + dimensions: [], + sort_by: 'date', + sort_order: 'desc', + totals: { + all_contacts: 12450, + subscribers: 11800, + unsubscribers: 650, + }, + }; + + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await resend.segments.metrics({ + metrics: [], + dimensions: [], + filter: { segmentId: [] }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/segments/metrics', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('calls endpoint passing metrics, dimensions and segment_id', async () => { + const response: GetSegmentsMetricsResponseSuccess = { + object: 'segments_metrics', + metrics: ['all_contacts'], + dimensions: ['segment'], + sort_by: 'date', + sort_order: 'desc', + totals: { + all_contacts: 4300, + }, + data: [ + { + segment_id: '78261eea-8f8b-4381-83c6-79fa7120f1cf', + segment_name: 'Registered Users', + all_contacts: 4300, + }, + ], + }; + + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.segments.metrics({ + metrics: ['all_contacts'], + dimensions: ['segment'], + filter: { segmentId: ['78261eea-8f8b-4381-83c6-79fa7120f1cf'] }, + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/segments/metrics?metrics=all_contacts&dimensions=segment&segment_id=78261eea-8f8b-4381-83c6-79fa7120f1cf', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('calls endpoint passing sortBy and sortOrder', async () => { + const response: GetSegmentsMetricsResponseSuccess = { + object: 'segments_metrics', + metrics: ['all_contacts'], + dimensions: ['segment'], + sort_by: 'date', + sort_order: 'asc', + totals: { + all_contacts: 4300, + }, + data: [ + { + segment_id: '78261eea-8f8b-4381-83c6-79fa7120f1cf', + segment_name: 'Registered Users', + all_contacts: 4300, + }, + ], + }; + + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.segments.metrics({ + dimensions: ['segment'], + sortBy: 'date', + sortOrder: 'asc', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/segments/metrics?dimensions=segment&sort_by=date&sort_order=asc', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('calls endpoint passing a metric sortBy', async () => { + const response: GetSegmentsMetricsResponseSuccess = { + object: 'segments_metrics', + metrics: ['subscribers'], + dimensions: ['segment'], + sort_by: 'subscribers', + sort_order: 'desc', + totals: { + subscribers: 4300, + }, + data: [ + { + segment_id: '78261eea-8f8b-4381-83c6-79fa7120f1cf', + segment_name: 'Registered Users', + subscribers: 4300, + }, + ], + }; + + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.segments.metrics({ + dimensions: ['segment'], + metrics: ['subscribers'], + sortBy: 'subscribers', + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/segments/metrics?metrics=subscribers&dimensions=segment&sort_by=subscribers', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('returns error when request fails', async () => { + const response: ErrorResponse = { + name: 'validation_error', + message: + 'Invalid `metrics` value. Allowed: all_contacts, subscribers, unsubscribers.', + statusCode: 422, + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = resend.segments.metrics(); + + await expect(result).resolves.toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Invalid \`metrics\` value. Allowed: all_contacts, subscribers, unsubscribers.", + "name": "validation_error", + "statusCode": 422, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); + describe('get', () => { describe('when audience not found', () => { it('returns error', async () => { diff --git a/src/segments/segments.ts b/src/segments/segments.ts index 59144bbb..44de75b3 100644 --- a/src/segments/segments.ts +++ b/src/segments/segments.ts @@ -6,6 +6,11 @@ import type { CreateSegmentResponse, CreateSegmentResponseSuccess, } from './interfaces/create-segment-options.interface'; +import type { + GetSegmentsMetricsOptions, + GetSegmentsMetricsResponse, + GetSegmentsMetricsResponseSuccess, +} from './interfaces/get-metrics.interface'; import type { GetSegmentResponse, GetSegmentResponseSuccess, @@ -55,4 +60,35 @@ export class Segments { ); return data; } + + async metrics( + options: GetSegmentsMetricsOptions = {}, + ): Promise { + const queryString = buildMetricsQuery(options); + const url = queryString + ? `/segments/metrics?${queryString}` + : '/segments/metrics'; + + const data = await this.resend.get(url); + return data; + } +} + +function buildMetricsQuery(options: GetSegmentsMetricsOptions) { + const params: Record = { + metrics: options.metrics?.join(','), + dimensions: options.dimensions?.join(','), + segment_id: options.filter?.segmentId?.join(','), + sort_by: options.sortBy, + sort_order: options.sortOrder, + }; + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + searchParams.set(key, value); + } + } + + return searchParams.toString(); } diff --git a/src/usage/interfaces/get-usage.interface.ts b/src/usage/interfaces/get-usage.interface.ts new file mode 100644 index 00000000..9e4868c1 --- /dev/null +++ b/src/usage/interfaces/get-usage.interface.ts @@ -0,0 +1,54 @@ +import type { Response } from '../../interfaces'; + +export type GetUsageResponseSuccess = { + object: 'usage'; + generated_at: string; + emails: { + daily: { + used: number; + limit: number | null; + sent: number; + received: number; + resets_at: string; + }; + monthly: { + used: number; + limit: number; + sent: number; + received: number; + resets_at: string; + }; + }; + contacts: { + used: number; + limit: number; + }; + segments: { + used: number; + limit: number | null; + }; + broadcasts: { + used: number; + limit: null; + }; + ai_credits: { + used: number; + limit: number; + next_increase_at: string | null; + }; + automation_runs: { + used: number; + limit: number; + resets_at: string; + }; + domains: { + used: number; + limit: number | null; + }; + rate_limit: { + limit: number; + duration: string; + }; +}; + +export type GetUsageResponse = Response; diff --git a/src/usage/interfaces/index.ts b/src/usage/interfaces/index.ts new file mode 100644 index 00000000..9e5f1789 --- /dev/null +++ b/src/usage/interfaces/index.ts @@ -0,0 +1 @@ +export * from './get-usage.interface'; diff --git a/src/usage/usage.spec.ts b/src/usage/usage.spec.ts new file mode 100644 index 00000000..a45836cb --- /dev/null +++ b/src/usage/usage.spec.ts @@ -0,0 +1,71 @@ +import createFetchMock from 'vitest-fetch-mock'; +import { Resend } from '../resend'; +import { mockSuccessResponse } from '../test-utils/mock-fetch'; +import type { GetUsageResponseSuccess } from './interfaces'; + +const fetchMocker = createFetchMock(vi); +fetchMocker.enableMocks(); + +describe('Usage', () => { + afterEach(() => fetchMock.resetMocks()); + afterAll(() => fetchMocker.disableMocks()); + + describe('get', () => { + it('gets usage', async () => { + const response: GetUsageResponseSuccess = { + object: 'usage', + generated_at: '2024-11-01T18:10:00.000Z', + emails: { + daily: { + used: 10, + limit: 100, + sent: 8, + received: 2, + resets_at: '2024-11-02T00:00:00.000Z', + }, + monthly: { + used: 100, + limit: 3000, + sent: 80, + received: 20, + resets_at: '2024-12-01T00:00:00.000Z', + }, + }, + contacts: { used: 10, limit: 3000 }, + segments: { used: 1, limit: 3 }, + broadcasts: { used: 2, limit: null }, + ai_credits: { used: 5, limit: 100, next_increase_at: null }, + automation_runs: { + used: 3, + limit: 10000, + resets_at: '2024-12-01T00:00:00.000Z', + }, + domains: { used: 1, limit: 1 }, + rate_limit: { limit: 1000, duration: '1000ms' }, + }; + + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.usage.get(); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/usage', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); +}); diff --git a/src/usage/usage.ts b/src/usage/usage.ts new file mode 100644 index 00000000..76644d63 --- /dev/null +++ b/src/usage/usage.ts @@ -0,0 +1,11 @@ +import type { Resend } from '../resend'; +import type { GetUsageResponse, GetUsageResponseSuccess } from './interfaces'; + +export class Usage { + constructor(private readonly resend: Resend) {} + + async get(): Promise { + const data = await this.resend.get('/usage'); + return data; + } +} diff --git a/src/webhooks/events/attempts/attempts.ts b/src/webhooks/events/attempts/attempts.ts new file mode 100644 index 00000000..a7df05d7 --- /dev/null +++ b/src/webhooks/events/attempts/attempts.ts @@ -0,0 +1,26 @@ +import { buildPaginationUrl } from '../../../common/utils/build-pagination-query'; +import type { Resend } from '../../../resend'; +import type { + ListWebhookEventAttemptsOptions, + ListWebhookEventAttemptsResponse, + ListWebhookEventAttemptsResponseSuccess, +} from '../../interfaces/list-webhook-event-attempts.interface'; + +export class Attempts { + constructor(private readonly resend: Resend) {} + + async list( + options: ListWebhookEventAttemptsOptions, + ): Promise { + const { webhookId, eventId } = options; + + const url = buildPaginationUrl( + `/webhooks/${webhookId}/events/${eventId}/attempts`, + options, + ); + + const data = + await this.resend.get(url); + return data; + } +} diff --git a/src/webhooks/events/events.ts b/src/webhooks/events/events.ts new file mode 100644 index 00000000..abea149e --- /dev/null +++ b/src/webhooks/events/events.ts @@ -0,0 +1,41 @@ +import { buildPaginationUrl } from '../../common/utils/build-pagination-query'; +import type { Resend } from '../../resend'; +import type { + GetWebhookEventOptions, + GetWebhookEventResponse, + GetWebhookEventResponseSuccess, +} from '../interfaces/get-webhook-event.interface'; +import type { + ListWebhookEventsOptions, + ListWebhookEventsResponse, + ListWebhookEventsResponseSuccess, +} from '../interfaces/list-webhook-events.interface'; +import { Attempts } from './attempts/attempts'; + +export class Events { + readonly attempts: Attempts; + + constructor(private readonly resend: Resend) { + this.attempts = new Attempts(resend); + } + + async list( + options: ListWebhookEventsOptions, + ): Promise { + const { webhookId } = options; + + const url = buildPaginationUrl(`/webhooks/${webhookId}/events`, options); + + const data = await this.resend.get(url); + return data; + } + + async get(options: GetWebhookEventOptions): Promise { + const { webhookId, eventId } = options; + + const data = await this.resend.get( + `/webhooks/${webhookId}/events/${eventId}`, + ); + return data; + } +} diff --git a/src/webhooks/interfaces/get-webhook-event.interface.ts b/src/webhooks/interfaces/get-webhook-event.interface.ts new file mode 100644 index 00000000..b05e68c2 --- /dev/null +++ b/src/webhooks/interfaces/get-webhook-event.interface.ts @@ -0,0 +1,23 @@ +import type { Response } from '../../interfaces'; +import type { WebhookEventLogStatus } from './list-webhook-events.interface'; +import type { + WebhookEvent, + WebhookEventPayload, +} from './webhook-event.interface'; + +export interface GetWebhookEventOptions { + webhookId: string; + eventId: string; +} + +export interface GetWebhookEventResponseSuccess { + object: 'webhook_event'; + id: string; + type: WebhookEvent; + created_at: string; + status: WebhookEventLogStatus; + next_attempt_at: string | null; + payload: WebhookEventPayload; +} + +export type GetWebhookEventResponse = Response; diff --git a/src/webhooks/interfaces/index.ts b/src/webhooks/interfaces/index.ts index bce40798..7e3a4d31 100644 --- a/src/webhooks/interfaces/index.ts +++ b/src/webhooks/interfaces/index.ts @@ -8,6 +8,24 @@ export type { GetWebhookResponse, GetWebhookResponseSuccess, } from './get-webhook.interface'; +export type { + GetWebhookEventOptions, + GetWebhookEventResponse, + GetWebhookEventResponseSuccess, +} from './get-webhook-event.interface'; +export type { + ListWebhookEventAttemptsOptions, + ListWebhookEventAttemptsResponse, + ListWebhookEventAttemptsResponseSuccess, + WebhookEventAttempt, +} from './list-webhook-event-attempts.interface'; +export type { + ListWebhookEventsOptions, + ListWebhookEventsResponse, + ListWebhookEventsResponseSuccess, + WebhookEventLog, + WebhookEventLogStatus, +} from './list-webhook-events.interface'; export type { ListWebhooksOptions, ListWebhooksResponse, diff --git a/src/webhooks/interfaces/list-webhook-event-attempts.interface.ts b/src/webhooks/interfaces/list-webhook-event-attempts.interface.ts new file mode 100644 index 00000000..1e745b5d --- /dev/null +++ b/src/webhooks/interfaces/list-webhook-event-attempts.interface.ts @@ -0,0 +1,24 @@ +import type { Response } from '../../interfaces'; + +export interface WebhookEventAttempt { + id: string; + http_status_code: number; + response: string; + sent_at: string; +} + +export type ListWebhookEventAttemptsOptions = { + webhookId: string; + eventId: string; + limit?: number; + after?: string; +}; + +export type ListWebhookEventAttemptsResponseSuccess = { + object: 'list'; + has_more: boolean; + data: WebhookEventAttempt[]; +}; + +export type ListWebhookEventAttemptsResponse = + Response; diff --git a/src/webhooks/interfaces/list-webhook-events.interface.ts b/src/webhooks/interfaces/list-webhook-events.interface.ts new file mode 100644 index 00000000..80f9c31d --- /dev/null +++ b/src/webhooks/interfaces/list-webhook-events.interface.ts @@ -0,0 +1,29 @@ +import type { Response } from '../../interfaces'; + +export type WebhookEventLogStatus = + | 'success' + | 'pending' + | 'failed' + | 'attempting'; + +export interface WebhookEventLog { + id: string; + type: string; + created_at: string; + status: WebhookEventLogStatus; +} + +export type ListWebhookEventsOptions = { + webhookId: string; + limit?: number; + after?: string; +}; + +export type ListWebhookEventsResponseSuccess = { + object: 'list'; + has_more: boolean; + data: WebhookEventLog[]; +}; + +export type ListWebhookEventsResponse = + Response; diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 78a37322..cf871150 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -9,6 +9,9 @@ import type { CreateWebhookResponseSuccess, } from './interfaces/create-webhook-options.interface'; import type { GetWebhookResponseSuccess } from './interfaces/get-webhook.interface'; +import type { GetWebhookEventResponseSuccess } from './interfaces/get-webhook-event.interface'; +import type { ListWebhookEventAttemptsResponseSuccess } from './interfaces/list-webhook-event-attempts.interface'; +import type { ListWebhookEventsResponseSuccess } from './interfaces/list-webhook-events.interface'; import type { ListWebhooksResponseSuccess } from './interfaces/list-webhooks.interface'; import type { RemoveWebhookResponseSuccess } from './interfaces/remove-webhook.interface'; import type { @@ -222,6 +225,232 @@ describe('Webhooks', () => { }); }); + describe('events.list', () => { + const webhookId = '430eed87-632a-4ea6-90db-0aace67ec228'; + const response: ListWebhookEventsResponseSuccess = { + has_more: false, + object: 'list', + data: [ + { + id: 'msg_1srOrx2ZWZBpBUvZwXKQmoEYga2', + type: 'email.sent', + created_at: '2026-08-22T15:28:00.000Z', + status: 'success', + }, + ], + }; + + describe('when no pagination options are provided', () => { + it('lists events', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.webhooks.events.list({ webhookId }); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.resend.com/webhooks/${webhookId}/events`, + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + + describe('when pagination options are provided', () => { + it('passes limit and after params', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await resend.webhooks.events.list({ + webhookId, + limit: 10, + after: 'msg_1srOrx2ZWZBpBUvZwXKQmoEYga2', + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.resend.com/webhooks/${webhookId}/events?limit=10&after=msg_1srOrx2ZWZBpBUvZwXKQmoEYga2`, + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + }); + + describe('events.get', () => { + const webhookId = '430eed87-632a-4ea6-90db-0aace67ec228'; + const eventId = 'msg_1srOrx2ZWZBpBUvZwXKQmoEYga2'; + + it('gets an event', async () => { + const response: GetWebhookEventResponseSuccess = { + object: 'webhook_event', + id: eventId, + type: 'email.sent', + created_at: '2026-08-22T15:28:00.000Z', + status: 'success', + next_attempt_at: null, + payload: { + type: 'email.sent', + created_at: '2026-08-22T15:28:00.000Z', + data: { + created_at: '2026-08-22T15:28:00.000Z', + email_id: 'abc', + message_id: '', + from: 'bu@resend.com', + to: ['zeno@resend.com'], + subject: 'Hello World', + }, + }, + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.webhooks.events.get({ webhookId, eventId }); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.resend.com/webhooks/${webhookId}/events/${eventId}`, + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + describe('when event not found', () => { + it('returns error', async () => { + const response: ErrorResponse = { + name: 'not_found', + message: 'Webhook event not found', + statusCode: 404, + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 404, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.webhooks.events.get({ webhookId, eventId }); + + await expect(result).resolves.toEqual({ + data: null, + error: { + message: 'Webhook event not found', + name: 'not_found', + statusCode: 404, + }, + headers: { + 'content-type': 'application/json', + }, + }); + }); + }); + }); + + describe('events.attempts.list', () => { + const webhookId = '430eed87-632a-4ea6-90db-0aace67ec228'; + const eventId = 'msg_1srOrx2ZWZBpBUvZwXKQmoEYga2'; + const response: ListWebhookEventAttemptsResponseSuccess = { + has_more: false, + object: 'list', + data: [ + { + id: 'atmpt_2ZbUCwvGmIT4mLIN6d3Yz0Ainbd', + http_status_code: 200, + response: '{"ok":true}', + sent_at: '2026-08-22T15:28:05.000Z', + }, + ], + }; + + describe('when no pagination options are provided', () => { + it('lists attempts', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.webhooks.events.attempts.list({ + webhookId, + eventId, + }); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.resend.com/webhooks/${webhookId}/events/${eventId}/attempts`, + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + + describe('when pagination options are provided', () => { + it('passes limit and after params', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await resend.webhooks.events.attempts.list({ + webhookId, + eventId, + limit: 10, + after: 'atmpt_2ZbUCwvGmIT4mLIN6d3Yz0Ainbd', + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.resend.com/webhooks/${webhookId}/events/${eventId}/attempts?limit=10&after=atmpt_2ZbUCwvGmIT4mLIN6d3Yz0Ainbd`, + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + }); + describe('update', () => { const webhookId = '430eed87-632a-4ea6-90db-0aace67ec228'; diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 22105103..0c33babe 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -1,6 +1,7 @@ import { Webhook } from 'standardwebhooks'; import { buildPaginationUrl } from '../common/utils/build-pagination-query'; import type { Resend } from '../resend'; +import { Events } from './events/events'; import type { CreateWebhookOptions, CreateWebhookRequestOptions, @@ -40,7 +41,11 @@ interface VerifyWebhookOptions { } export class Webhooks { - constructor(private readonly resend: Resend) {} + readonly events: Events; + + constructor(private readonly resend: Resend) { + this.events = new Events(resend); + } async create( payload: CreateWebhookOptions,