Node SDK
@send-broadcast/sdk is the official Node and TypeScript client for the Broadcast
API. It covers every API endpoint and works against any Broadcast instance.
Node 18+, using native fetch. Ships both ESM and CommonJS builds with
TypeScript declarations, and has no runtime dependencies.
Installation
npm install @send-broadcast/sdk
Getting Your API Token
- Log in to your Broadcast dashboard
- Go to Settings → API Keys
- Click New API Key
- Name it and select only the permissions your integration needs
- Copy the token: it is shown once
See API Authentication for the full permission list.
Quick Start
import { Broadcast } from '@send-broadcast/sdk'; const client = new Broadcast({ apiToken: process.env.BROADCAST_API_TOKEN, host: 'https://mail.example.com', }); await client.subscribers.create({ email: 'ada@example.com', first_name: 'Ada' }); await client.transactionals.create({ to: 'ada@example.com', subject: 'Welcome', body: '<p>Glad you are here.</p>', });
CommonJS works too:
const { Broadcast } = require('@send-broadcast/sdk');
Warning
host is required. There is no default. Broadcast is self-hosted first, so
every instance lives at its own domain. A built-in guess would be wrong for
almost everyone, so the client asks you to be explicit.
You can also set BROADCAST_HOST and BROADCAST_API_TOKEN in the environment.
These are the same names the Agents CLI uses in
~/.config/broadcast/config.
Configuration
new Broadcast({ apiToken: '...', host: 'https://mail.example.com', timeout: 30_000, // read timeout, milliseconds openTimeout: 10_000, retryAttempts: 3, retryDelay: 1_000, // base backoff, multiplied by attempt number maxRetryDelay: 30_000, // ceiling on a server-supplied Retry-After warningsMode: 'log', // 'log' | 'raise' | 'ignore' logger: console, debug: false, broadcastChannelId: 42, // admin/system tokens only });
Durations are milliseconds here; the Ruby and PHP clients use seconds. The behaviour on the wire is identical.
Timeouts, rate-limited requests, and server errors are retried with backoff. A validation error (422) is never retried: it is deterministic, so a retry only adds latency.
Responses
The API sends more than a body: warnings, rate-limit headers, and an
idempotency-replay marker. Those are read through meta():
import { meta } from '@send-broadcast/sdk'; const result = await client.subscribers.create({ email: 'ada@example.com' }); result.id // the body, exactly as the API sent it meta(result).status // 201 meta(result).warnings // parsed warnings, if any meta(result).rateLimit?.remaining meta(result).idempotentReplay // true if the API replayed a stored response
Why not result.status? Because Broadcast returns a body field called
status on every broadcast, and another called warnings. Attaching metadata
to the same object would shadow real data. The body comes back untouched, so
Object.keys(), spread, and JSON.stringify() all see exactly what the API
sent.
This is the one place the Node client deliberately differs from the Ruby and PHP
clients, which get two separate namespaces from Hash subclassing and
ArrayAccess respectively. JavaScript has no equivalent.
Warnings
A 2xx response can carry warnings: the API accepted your request but ignored
part of it, such as an unrecognised parameter or a filter it could not parse. A
mistyped created_after silently widens a result set rather than failing.
for (const warning of meta(result).warnings) { console.warn(String(warning)); }
Set warningsMode: 'raise' to turn them into exceptions instead. Note the write
has already happened by then: nothing is rolled back. See
API Response Warnings.
Rate Limits
Every response carries the current limit state, and 429s are retried
automatically, honouring the server’s Retry-After (capped at maxRetryDelay).
const result = await client.subscribers.list(); meta(result).rateLimit?.limit // 120 meta(result).rateLimit?.remaining // 118 meta(result).rateLimit?.reset // Date
If the retries are exhausted you get a RateLimitError, which carries
.retryAfter so you can requeue the job sensibly.
Common Tasks
Subscribers
await client.subscribers.list({ page: 1, is_active: true, tags: ['vip'] }); await client.subscribers.find('ada@example.com'); await client.subscribers.create({ email: 'ada@example.com', tags: ['vip'] }); await client.subscribers.update('ada@example.com', { first_name: 'Ada' }); await client.subscribers.addTags('ada@example.com', ['beta']); await client.subscribers.unsubscribe('ada@example.com'); await client.subscribers.redact('ada@example.com'); // irreversible
See Subscribers API for every filter and field.
Transactional email
await client.transactionals.create({ to: 'ada@example.com', subject: 'Your receipt', body: '<p>Thanks for your order.</p>', idempotencyKey: `receipt-${order.id}`, });
Passing an idempotencyKey makes a retry safe: the server stores the response
for 24 hours and replays it rather than sending a second email. Check
meta(result).idempotentReplay to tell a replay from a fresh send.
Full details in Transactional Email API.
Broadcasts
const broadcast = await client.broadcasts.create({ subject: 'Weekly update', body: '<p>Hello</p>', }); await client.broadcasts.schedule(broadcast.id, { scheduled_send_at: '2026-08-01T09:00:00Z', scheduled_timezone: 'UTC', });
Warning
client.broadcasts.send(id) sends immediately to the whole audience. There is
no unsend. Call client.status() first: if readiness.broadcasts is false,
the channel has no usable email server or sender identity.
Sequences, segments, templates, opt-in forms
await client.sequences.get(id, { includeSteps: true }); await client.sequences.addSubscriber(id, { email: 'ada@example.com' }); await client.segments.create({ name: 'VIPs' }); await client.templates.create({ label: 'Welcome', subject: 'Hi' }); await client.optInForms.analytics(id, { startDate: new Date('2026-01-01') });
Reading a segment recounts its members server-side, so segments.get is not free.
Topics
Subscriber Topics use the subscriber permissions.
const topic = await client.topics.create({ name: 'Webinars', custom_data_key: 'sub_webinars' }); await client.broadcasts.create({ subject: 'Webinar', body: '...', topic_id: topic.id }); // Change only the keys you send; null deletes a key await client.subscribers.update('jane@example.com', { custom_data: { sub_webinars: false } }, { customDataMode: 'merge' });
Email servers
Credential redaction guard. The API returns credentials bullet-masked
(••••••••). A naive fetch-modify-save would write those bullets back and
destroy a working SMTP password. update() strips any credential field whose
value matches the redaction pattern and warns:
const server = await client.emailServers.get(id); server.smtp_password; // '••••••••' await client.emailServers.update(id, { name: 'Renamed', smtp_password: server.smtp_password }); // -> sends only { name: 'Renamed' }, warns about the dropped field
Autopilot
await client.autopilots.create({ name: 'Weekly', ai_model: 'openai/gpt-4o' }); await client.autopilots.activate(id); await client.autopilots.triggerRun(id); // 202: async, poll runs() await client.autopilots.runs(id, { limit: 10 });
activate requires an active source, an API key, and a model. Sources and tone
samples have no API endpoints: they live in the web UI, so an autopilot
created entirely over the API cannot be activated until a source is added there.
Suppressions
await client.suppressions.check('ada@example.com'); // will this address receive mail? await client.suppressions.list({ page: 1, email: 'example.com' }); await client.suppressions.add('bounced@example.com'); await client.suppressions.remove('bounced@example.com'); await client.suppressions.bulkAdd(['a@example.com', 'b@example.com']); // up to 10,000 await client.suppressions.bulkRemove(['a@example.com']);
check reads across both the channel’s list and the installation-wide global
list, so it answers the question integrations actually ask: will this address
receive mail? Adding an already-suppressed address is a success (200 rather
than 201), so there is no need to check first.
The global list is a separate resource and needs an admin (system) token:
await client.globalSuppressions.list(); await client.globalSuppressions.add('spamtrap@example.com'); await client.globalSuppressions.remove('spamtrap@example.com'); await client.globalSuppressions.bulkAdd([...]); await client.globalSuppressions.bulkRemove([...]);
See Suppressions API for the full semantics.
Channel settings and brand kit
await client.channelSettings.get(); // general, sender, unsubscribe_page, confirmation_page, opt_in_forms await client.channelSettings.update({ unsubscribe_page: { legal_notice_url: 'https://example.com/impressum' } }); await client.channelDesign.get(); // the brand kit await client.channelDesign.update({ colors: { accent: '#ff5500' }, typography: { font: 'georgia' } });
Updates are partial: send only what changes, and the response is the whole object after the change. Read meta(result).warnings for keys the server ignored. Changing either one needs the Channel Settings write permission. The brand kit switches (match_brand_kit in unsubscribe_page, confirmation_page and opt_in_forms) are channel settings, not part of the brand kit update. The brand kit logo is the logo_url of an image in the channel’s file library. Reading and changing channel settings, and changing the brand kit, need Broadcast 2.46.0 or later and the next release of this SDK after 0.5.0. See Channel Settings API and Brand Kit API.
Users
Create and manage the people who sign in to Broadcast, and what they can reach. This
needs an admin (system) token with the Users permission, and no channel: a channel
token gets AuthorizationError.
await client.users.list({ q: 'ada', status: 'active' }); const user = await client.users.create({ email: 'ada@example.com', first_name: 'Ada', last_name: 'Lovelace', send_password_reset: true, // or password: '...' }); await client.users.get(user.id); // includes system and channel permissions await client.users.update(user.id, { last_name: 'King' }); await client.users.deactivate(user.id); await client.users.activate(user.id); // also clears a lockout await client.users.delete(user.id);
Channel access. A write replaces the user’s whole record on that channel. Pass
exactly one of permissions, role or presetId:
await client.users.channelPermissions(user.id); await client.users.setChannelPermissions(user.id, channelId, { role: 'Editor' }); await client.users.setChannelPermissions(user.id, channelId, { permissions: { subscribers_read: true } }); await client.users.bulkChannelPermissions(user.id, { broadcastChannelIds: [3, 4], presetId: 12 }); await client.users.removeChannelPermissions(user.id, channelId); await client.users.systemPermissions(user.id); await client.users.updateSystemPermissions(user.id, { user_management: true });
What to expect:
- Sudo users are read-only. Changing one, or their permissions, raises
AuthorizationError, and sudo access can never be granted through the API. - Nothing is silently ignored. A request the API will not apply (such as
sudo,active, or permissions inside the user payload) raisesValidationErrorand changes nothing. Usedeactivateandactivatefor sign-in access. - A bulk apply that applies to no channel raises
ValidationError; otherwise check itsfailedlist. - Passing none, or more than one, of the permission options raises
TypeErrorbefore any request is sent.
See Users API for every field and rule, and Users & Permissions for what the roles and permissions mean.
Channel Scoping
Admin (system) tokens can address any channel:
await client.withChannel(123, async () => { await client.emailServers.list(); });
The previous scope is restored afterwards, including when the block throws. The
override lives on the client instance, so concurrent calls on the same
instance will see each other’s scope: use one client per channel, or pass
broadcast_channel_id explicitly, when running channels in parallel.
Webhooks
import { Webhook } from '@send-broadcast/sdk'; const valid = Webhook.verify( rawBody, // the raw bytes, not a re-serialised object req.headers['x-broadcast-signature'], req.headers['x-broadcast-timestamp'], process.env.BROADCAST_WEBHOOK_SECRET, ); if (!valid) return res.status(401).end();
HMAC-SHA256 over timestamp.payload, v1,<base64> header format, 5-minute
timestamp tolerance, constant-time comparison. verify returns false for
every rejection rather than distinguishing them.
Pass the raw request body. Re-serialising a parsed object changes the bytes and verification will fail.
EVENT_TYPES lists all 32 event names. This is the only place the SDK reaches
for node:crypto, so everything else runs unchanged on edge runtimes.
See Webhook Endpoints API for creating and managing endpoints.
Errors
import { ValidationError, AuthenticationError, AuthorizationError, RateLimitError, APIError } from '@send-broadcast/sdk'; try { await client.subscribers.create({ email: 'invalid' }); } catch (error) { if (error instanceof ValidationError) { // 422: bad input, do not retry } else if (error instanceof AuthenticationError) { // 401: bad token, or it lacks this permission } else if (error instanceof AuthorizationError) { // 403: needs an admin token, or the user is sudo } else if (error instanceof RateLimitError) { // 429 await new Promise((r) => setTimeout(r, (error.retryAfter ?? 5) * 1000)); } else if (error instanceof APIError) { // 5xx and other transport failures } }
ValidationError and TimeoutError are siblings of APIError, not children,
so catching APIError for transport problems will not silently swallow a
validation error.
Export & Migration
Read-only export endpoints. Admin tokens only, and every call needs a
broadcast_channel_id.
const client = new Broadcast({ ..., broadcastChannelId: 42 }); await client.migration.manifest(); // sizes the export for await (const sub of client.migration.eachRecord('subscribers')) { // auto-pages; advances by the limit the server actually applied } for await (const row of client.migration.eachRecord('unsubscribedEmails')) { // the channel's own suppression list } const bytes = await client.migration.downloadFileAsset(id); // Uint8Array
The channel’s own suppression list is a separate collection from suppressions, which is the global list, so export both. It needs Broadcast 2.40.0 or later and version 0.3.0 or later of this SDK.
On a demo instance this entire API returns 403 for every request, valid
token or not: deliberately, so a public demo cannot be used as a token oracle.
It surfaces as AuthorizationError.
Source and Support
The client is open source and its README documents every method:
CI runs lint, typecheck and the test suite on Node 22 and 24, and additionally builds the package and smoke-tests both the ESM and CommonJS entry points on Node 18, 20, 22 and 24.