Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5d3a14b
feat: broadcast metrics and recipients (#1037)
dielduarte Aug 3, 2026
aa064c0
feat: add usage resource (#1039)
gabrielmfern Aug 3, 2026
604df91
chore: bump version to 6.19.0-preview-headless-dashboard.0 (#1044)
dielduarte Aug 3, 2026
fa025d2
feat: segments.metrics() for account-level segment metrics (#1045)
dielduarte Aug 5, 2026
89c6540
feat: emails.metrics() for account-level email metrics (#1042)
dielduarte Aug 5, 2026
5895f21
chore: bump version to 6.19.0-preview-headless-dashboard.1 (#1046)
dielduarte Aug 5, 2026
610e63d
feat: add monthly granularity to emails.metrics() (#1050)
dielduarte Aug 7, 2026
b367514
chore: bump version to 6.19.0-preview-headless-dashboard.2 (#1054)
dielduarte Aug 7, 2026
0717857
fix(emails): flatten metrics filter query params (#1052)
dielduarte Aug 7, 2026
5dfd051
fix(segments): flatten metrics filter query params (#1053)
dielduarte Aug 7, 2026
466939d
chore: bump version to 6.19.0-preview-headless-dashboard.3 (#1055)
dielduarte Aug 7, 2026
7bdb18e
feat(webhooks): event endpoints (#1051)
gabrielmfern Aug 7, 2026
3e683d5
chore: bump to 6.19.0-preview-headless-dashboard.4 (#1058)
gabrielmfern Aug 7, 2026
33273b2
feat: add broadcasts.cancel() method (#1059)
dielduarte Aug 10, 2026
0e2d30f
chore: bump to 6.19.0-preview-headless-dashboard.5 (#1063)
dielduarte Aug 10, 2026
c13f9c8
feat: broadcasts.metrics() for account-level broadcast metrics (#1067)
dielduarte Aug 13, 2026
7375bda
chore: bump to 6.19.0-preview-headless-dashboard.6 (#1068)
dielduarte Aug 13, 2026
689bdce
feat: add clicked-links support (#1069)
dielduarte Aug 13, 2026
2e6a55a
chore: bump to 6.19.0-preview-headless-dashboard.7 (#1070)
dielduarte Aug 13, 2026
bab22ab
Merge branch 'canary' into preview-headless-dashboard
gabrielmfern Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
506 changes: 506 additions & 0 deletions src/broadcasts/broadcasts.spec.ts

Large diffs are not rendered by default.

97 changes: 96 additions & 1 deletion src/broadcasts/broadcasts.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,6 +16,22 @@ import type {
GetBroadcastResponse,
GetBroadcastResponseSuccess,
} from './interfaces/get-broadcast.interface';
import type {
GetBroadcastsMetricsOptions,
GetBroadcastsMetricsResponse,
GetBroadcastsMetricsResponseSuccess,
} from './interfaces/get-metrics.interface';
import type {
ListBroadcastClickedLinksOptions,
ListBroadcastClickedLinksResponse,
ListBroadcastClickedLinksResponseSuccess,
} from './interfaces/list-broadcast-clicked-links.interface';
import type {
BroadcastRecipientEventType,
ListBroadcastRecipientsOptions,
ListBroadcastRecipientsResponse,
ListBroadcastRecipientsResponseSuccess,
} from './interfaces/list-broadcast-recipients.interface';
import type {
ListBroadcastsOptions,
ListBroadcastsResponse,
Expand Down Expand Up @@ -92,6 +111,44 @@ export class Broadcasts {
return data;
}

async metrics(
options: GetBroadcastsMetricsOptions = {},
): Promise<GetBroadcastsMetricsResponse> {
const queryString = buildMetricsQuery(options);
const url = queryString
? `/broadcasts/metrics?${queryString}`
: '/broadcasts/metrics';

const data =
await this.resend.get<GetBroadcastsMetricsResponseSuccess>(url);
return data;
}

async recipients<T extends BroadcastRecipientEventType>(
id: string,
options: ListBroadcastRecipientsOptions<T>,
): Promise<ListBroadcastRecipientsResponse<T>> {
const queryString = buildRecipientsQuery(
options as ListBroadcastRecipientsOptions,
);
const url = `/broadcasts/${id}/recipients?${queryString}`;

const data =
await this.resend.get<ListBroadcastRecipientsResponseSuccess<T>>(url);
return data;
}

async clickedLinks(
id: string,
options: ListBroadcastClickedLinksOptions = {},
): Promise<ListBroadcastClickedLinksResponse> {
const url = buildPaginationUrl(`/broadcasts/${id}/clicked-links`, options);

const data =
await this.resend.get<ListBroadcastClickedLinksResponseSuccess>(url);
return data;
}

async remove(id: string): Promise<RemoveBroadcastResponse> {
const data = await this.resend.delete<RemoveBroadcastResponseSuccess>(
`/broadcasts/${id}`,
Expand Down Expand Up @@ -130,3 +187,41 @@ export class Broadcasts {
return data;
}
}

function buildMetricsQuery(options: GetBroadcastsMetricsOptions) {
const params: Record<string, string | undefined> = {
start_date: options.startDate,
end_date: options.endDate,
timezone: options.timezone,
granularity: options.granularity,
metrics: options.metrics?.join(','),
dimensions: options.dimensions?.join(','),
broadcast_id: options.broadcastId?.join(','),
};

const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') {
searchParams.set(key, value);
}
}

return searchParams.toString();
}

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();
}
101 changes: 101 additions & 0 deletions src/broadcasts/interfaces/get-metrics.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Response } from '../../interfaces';

export type BroadcastMetric =
| '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 BroadcastMetricsDimension = 'period' | 'broadcast';

export type BroadcastMetricsGranularity =
| 'hourly'
| 'daily'
| 'weekly'
| 'monthly';

export type GetBroadcastsMetricsOptions = {
/**
* 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/broadcasts/get-broadcasts-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/broadcasts/get-broadcasts-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?: BroadcastMetricsGranularity;

/**
* The metrics to include in the response. Defaults to all metrics.
*/
metrics?: BroadcastMetric[];

/**
* The dimensions to break the response down by. Defaults to `[]`, which
* returns a single `totals` row for the whole range, with no `data`.
*/
dimensions?: BroadcastMetricsDimension[];

/**
* Restrict the response to these broadcast IDs (up to 100). When set,
* the date range is ignored unless `period` is also in `dimensions`.
*/
broadcastId?: string[];
};

export type BroadcastMetricsTotals = Partial<Record<BroadcastMetric, number>>;

export type BroadcastMetricsDataRow = BroadcastMetricsTotals & {
period?: string;
id?: string;
name?: string;
};

export interface GetBroadcastsMetricsResponseSuccess {
object: 'metrics';
start_date: string;
end_date: string;
metrics: BroadcastMetric[];
dimensions: BroadcastMetricsDimension[];
granularity: BroadcastMetricsGranularity;
totals: BroadcastMetricsTotals;
data?: BroadcastMetricsDataRow[];
}

export type GetBroadcastsMetricsResponse =
Response<GetBroadcastsMetricsResponseSuccess>;
3 changes: 3 additions & 0 deletions src/broadcasts/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ export * from './broadcast';
export * from './cancel-broadcast.interface';
export * from './create-broadcast-options.interface';
export * from './get-broadcast.interface';
export * from './get-metrics.interface';
export * from './list-broadcast-clicked-links.interface';
export * from './list-broadcast-recipients.interface';
export * from './list-broadcasts.interface';
export * from './remove-broadcast.interface';
export * from './send-broadcast-options.interface';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {
PaginatedData,
PaginationOptions,
} from '../../common/interfaces/pagination-options.interface';
import type { Response } from '../../interfaces';

export type ListBroadcastClickedLinksOptions = PaginationOptions;

export type BroadcastClickedLink = {
/**
* An opaque cursor for this row, used only for pagination. It does not
* identify any entity in Resend.
*/
id: string;
url: string;
clicks: number;
unique_clicks: number;
};

export type ListBroadcastClickedLinksResponseSuccess = PaginatedData<
BroadcastClickedLink[]
>;

export type ListBroadcastClickedLinksResponse =
Response<ListBroadcastClickedLinksResponseSuccess>;
Original file line number Diff line number Diff line change
@@ -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, B = T> = 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<T> extends true
? BroadcastRecipientLoose
: BroadcastRecipientBase & BroadcastRecipientFieldsByType[T];

export type ListBroadcastRecipientsResponseSuccess<
T extends BroadcastRecipientEventType = BroadcastRecipientEventType,
> = PaginatedData<BroadcastRecipient<T>[]>;

export type ListBroadcastRecipientsResponse<
T extends BroadcastRecipientEventType = BroadcastRecipientEventType,
> = Response<ListBroadcastRecipientsResponseSuccess<T>>;
Loading
Loading