Official Node/TypeScript client for Broadcast, the self-hosted email marketing platform.
Works with any Broadcast instance — self-hosted or SaaS. Covers 104/104 API operations, verified against the API's generated OpenAPI document.
📖 Node SDK documentation · API reference · All docs
Also available: Ruby · PHP · Python
npm install @send-broadcast/sdkNode 18+ (uses native fetch). Ships ESM and CJS builds with TypeScript types
and no runtime dependencies.
- Log in to your Broadcast dashboard
- Go to Settings > API Keys
- Click New API Key
- Name it, select the permissions you need (see Permissions below), and save
- Copy the token
import { Broadcast } from '@send-broadcast/sdk';
const client = new Broadcast({
apiToken: process.env.BROADCAST_API_TOKEN,
host: 'https://mail.example.com', // required — see below
});
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');There is no default. Broadcast is self-hosted-first, so every instance lives at
its own domain and any built-in guess would be wrong for nearly everyone. Set it
explicitly, or via the BROADCAST_HOST environment variable.
BROADCAST_HOST and BROADCAST_API_TOKEN are the same names the Broadcast CLI
uses in ~/.config/broadcast/config, so a machine set up for the CLI needs no
extra configuration.
new Broadcast({
apiToken: '...', // or BROADCAST_API_TOKEN
host: 'https://...', // or BROADCAST_HOST — required
timeout: 30_000, // read timeout, ms
openTimeout: 10_000, // connect timeout, ms
retryAttempts: 3,
retryDelay: 1_000, // base backoff, ms, multiplied by attempt
maxRetryDelay: 30_000, // ceiling on a server-sent Retry-After
warningsMode: 'log', // 'log' | 'raise' | 'ignore'
logger: console,
debug: false,
broadcastChannelId: 42, // admin/system tokens
});Durations are milliseconds here (the Ruby gem uses seconds). Wire behaviour is identical.
The API sends more than a body: warnings, rate-limit headers, and an
idempotency-replay marker. Those live behind meta() rather than on the object
itself:
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 Warning[]
meta(result).rateLimit?.remaining
meta(result).idempotentReplay // true if the API replayed a stored responseWhy not result.status? Because Broadcast returns a body field called
status (on broadcasts) and another called warnings. Hanging metadata off the
same object would shadow real data. The body you get back is untouched —
Object.keys(), spread, and JSON.stringify() all see exactly what the API
sent. This is the one place this SDK deliberately diverges from the Ruby gem's
ergonomics, and it is because JavaScript has no equivalent of Response < Hash.
A 2xx response can carry warnings: the API accepted your request but ignored part of it — an unrecognised parameter, a value it overrode. A mistyped filter silently widens a result set unless you look.
// 'log' (default) — warn through `logger`, return normally
// 'raise' — throw WarningError. NOTE: the write already happened.
// 'ignore' — say nothing; read them off meta(result)
const client = new Broadcast({ ..., warningsMode: 'raise' });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
// Back off before you get throttled
if ((meta(result).rateLimit?.remaining ?? Infinity) < 10) {
await new Promise((r) => setTimeout(r, 1_000));
}If the retries are exhausted you get a RateLimitError, which carries
.retryAfter so you can requeue the job sensibly.
BroadcastError
├── ConfigurationError
├── APIError
│ ├── AuthenticationError 401
│ ├── AuthorizationError 403
│ ├── NotFoundError 404
│ ├── ConflictError 409 idempotency replay still in flight
│ └── RateLimitError 429 carries .retryAfter
├── ValidationError 422
├── TimeoutError
├── DeliveryError
└── WarningError carries .warnings and .response
ValidationError and TimeoutError are siblings of APIError, not children —
matching the Ruby gem. Catching APIError gets you transport and status
failures and leaves validation to be handled deliberately.
Timeouts, 429s, and 5xx are retried with backoff. A 422 is not: it is deterministic, so retrying is pure latency.
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.removeTags('ada@example.com', ['beta']);
await client.subscribers.activate('ada@example.com');
await client.subscribers.deactivate('ada@example.com');
await client.subscribers.unsubscribe('ada@example.com');
await client.subscribers.resubscribe('ada@example.com');
await client.subscribers.redact('ada@example.com'); // irreversibleDouble opt-in on create:
await client.subscribers.create({
email: 'ada@example.com',
double_opt_in: { reply_to: 'hello@example.com', include_unsubscribe_link: true },
});created_after / created_before that fail to parse are ignored by the
server rather than rejected — they come back as a parameter_ignored warning,
so a bad timestamp silently widens your result set. Check meta(result).warnings.
await client.broadcasts.list();
await client.broadcasts.get(id);
await client.broadcasts.create({ subject: 'Weekly', body: '<p>Hi</p>' });
await client.broadcasts.update(id, { subject: 'Edited' });
await client.broadcasts.delete(id);
await client.broadcasts.send(id); // no undo
await client.broadcasts.schedule(id, { scheduled_send_at: '2026-08-01T09:00:00Z', scheduled_timezone: 'UTC' });
await client.broadcasts.cancelSchedule(id);
await client.broadcasts.statistics(id);
await client.broadcasts.statisticsTimeline(id);
await client.broadcasts.statisticsLinks(id);await client.sequences.list();
await client.sequences.get(id, { includeSteps: true });
await client.sequences.create({ name: 'Onboarding' });
await client.sequences.addSubscriber(id, { email: 'ada@example.com' });
await client.sequences.removeSubscriber(id, 'ada@example.com');
await client.sequences.listSubscribers(id, { page: 1 });
await client.sequences.listSteps(id);
await client.sequences.createStep(id, { subject: 'Day 1' });
await client.sequences.updateStep(id, stepId, { subject: 'Day 2' });
await client.sequences.moveStep(id, stepId, underId);
await client.sequences.deleteStep(id, stepId);Steps hang off sequences rather than a top-level resource, matching the
nested routes.
await client.segments.create({ name: 'VIPs', rules: [...] });
await client.templates.create({ label: 'Welcome', subject: 'Hi', body: '...' });
await client.optInForms.create({ label: 'Footer form' });
// How often the same visitor sees a popup: one of TRIGGER_FREQUENCIES, or ValidationError (422).
await client.optInForms.update(id, { trigger_settings: { frequency: 'once_per_day' } });
// Every N days (Broadcast 2.49 and later): frequency_cookie_days is a whole number, 1 or more; blank means 30.
await client.optInForms.update(id, { trigger_settings: { frequency: 'once_per_days', frequency_cookie_days: 7 } });
await client.optInForms.analytics(id, { startDate: new Date('2026-01-01') });
await client.optInForms.createVariant(id, { name: 'B', weight: 50 });
await client.optInForms.duplicate(id, { label: 'Copy' });Reading a segment recounts its members server-side, so segments.get is not free.
A topic is one kind of email subscribers opt in to or out of (webinars, offers).
Its value lives in a top-level custom_data key (true, false, or no value)
or in a tag. unset_receives decides, at send time, whether a subscriber with no
value receives it; Broadcast never writes a default. Topics use the subscriber
permissions.
const topic = await client.topics.create({ name: 'Webinars', custom_data_key: 'sub_webinars' });
await client.topics.create({ name: 'News', storage: 'tag', tag_name: 'news' });
await client.topics.list();
await client.topics.update(topic.id, { unset_receives: false });
await client.topics.delete(topic.id); // 422 while a broadcast or sequence uses it
// Send to a topic: (segments) AND topic
await client.broadcasts.create({ subject: 'Webinar', body: '...', segment_ids: [2], topic_id: topic.id });
// Send to everyone except a segment (also on sequences and autopilots)
await client.broadcasts.create({ subject: 'Offer', body: '...', excluded_segment_ids: [competitors.id] });
// Migrate someone who already unsubscribed, in one request
await client.subscribers.create({ email: 'opted-out@example.com', unsubscribed_at: '2025-03-14T09:30:00Z' });
// Change only the keys you send (null deletes a key); the default replaces custom_data
await client.subscribers.update('jane@example.com', { custom_data: { sub_webinars: false } }, { customDataMode: 'merge' });The subscriber JSON has topics: each topic's stored value (true, false, or
null). Topic changes fire subscriber.preferences_updated as well as
subscriber.updated.
const kit = await client.channelDesign.get();
kit.colors.accent; // '#2563eb'
kit.typography.font_stack; // CSS font stack for the chosen font
kit.brand.logo_url; // public URL, or nullReturns the token channel's brand kit with defaults filled in, so every key
is present even when no kit is configured. There is no channel parameter: it
always reads the token's own channel. Reading requires templates_read (or,
from Broadcast 2.46.0, channel_settings_read).
From Broadcast 2.46.0 you can change it. This needs channel_settings_write:
const kit = await client.channelDesign.update({
colors: { accent: '#ff5500' },
typography: { font: 'georgia' },
brand: { logo_url: 'https://your-broadcast.example/files/eyJ...-logo.png' },
});
meta(kit).warnings; // e.g. a key the server did not recognizeSend only what changes, in the shape get() returns. An empty string or
null puts a value back to its default, and social_links replaces the list.
font_stack and width are read-only. The logo must be the logo_url of an
image in the channel's file library; the server never fetches a URL. A value
the Brand Kit page would refuse is a ValidationError (422), and nothing is
saved.
const settings = await client.channelSettings.get();
settings.unsubscribe_page.legal_notice_url; // 'https://example.com/impressum'
const updated = await client.channelSettings.update({
unsubscribe_page: {
privacy_policy_url: 'https://example.com/privacy',
legal_notice_url: 'https://example.com/impressum',
},
});
meta(updated).warnings;Broadcast 2.46.0 or later. The settings are grouped like the Settings pages:
general, sender, unsubscribe_page (with the legal links),
confirmation_page and opt_in_forms (match_brand_kit: whether new opt-in
forms start from the brand kit). Updates are partial: nested text and colours merge, an
empty string or null removes a value, and languages replaces the list.
Reading needs channel_settings_read, changing needs channel_settings_write;
both are off on every token until you tick them.
The match_brand_kit switches (unsubscribe_page, confirmation_page
and opt_in_forms) decide where the brand kit is used. They are set
here, not with the brand kit update.
const file = await client.fileAssets.upload('./logo.png'); // a path (Node)
await client.fileAssets.upload(bytes, { filename: 'logo.png' }); // Uint8Array, ArrayBuffer or Blob
await client.fileAssets.uploadBase64({ filename: 'logo.png', data: base64 });
await client.channelDesign.update({ brand: { logo_url: file.url } });
const { data, total } = await client.fileAssets.list({ filter: 'logo', limit: 20, offset: 0 });
await client.fileAssets.get(file.id);
await client.fileAssets.delete(file.id);Broadcast 2.48.0 or later. The channel's file library: PNG, JPEG or GIF
images under 10 MB, each with a public url that never expires. A file is
{ id, filename, content_type, byte_size, url, created_at }.
upload() sends multipart/form-data. The content type follows the file
extension unless you pass contentType. Bytes and a Blob need filename.
uploadBase64() sends a JSON body, for callers that already hold base64.
Both take channelRestricted: true (link the file to this channel only; the
default is every channel) and idempotencyKey. Another file type, or a file
that is too large, is a ValidationError (422).
delete() refuses a file that is a channel's brand kit logo with a
ValidationError (422). Choose another logo first. Reading needs
file_assets_read; upload and delete need file_assets_write.
await client.emailServers.list();
await client.emailServers.create({ name: 'SES', ... });
await client.emailServers.testConnection(id);
await client.emailServers.copyToChannel(id, targetChannelId); // admin tokenCredential 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 fieldPass the real credential to rotate it, or omit the field.
Requires an admin API token (a channel token gets 403). The token also
needs users_read for GETs and users_write for everything else. Sudo users
are read-only through this API — update/deactivate/activate/delete/
permission writes on them return 403 — and sudo access can never be granted
through it.
await client.users.list({ q: 'ada', status: 'active' });
await client.users.create({ email: 'ada@example.com', first_name: 'Ada', last_name: 'Lovelace', send_password_reset: true });
await client.users.update(id, { first_name: 'Grace' });
await client.users.deactivate(id);
await client.users.activate(id); // also clears lockout
await client.users.delete(id);Channel permissions. PUT replaces the whole channel record — pass exactly
one of permissions, role, or presetId; the SDK throws a TypeError if
you pass zero or more than one:
await client.users.channelPermissions(id);
await client.users.setChannelPermissions(id, channelId, { role: 'Editor' });
await client.users.setChannelPermissions(id, channelId, { permissions: { subscribers_read: true } });
await client.users.removeChannelPermissions(id, channelId);
await client.users.bulkChannelPermissions(id, {
broadcastChannelIds: [1, 2, 3],
presetId: 12,
});System permissions:
await client.users.systemPermissions(id);
await client.users.updateSystemPermissions(id, { user_management: true });AI-generated newsletters. Requires autopilot_read / autopilot_write.
await client.autopilots.create({ name: 'Weekly', ai_model: 'openai/gpt-4o', ... });
await client.autopilots.activate(id);
await client.autopilots.pause(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 are configured in the web UI, so an
autopilot created entirely over the API cannot be activated until a source is
added there.
openrouter_api_key is write-only and carries the same redaction guard as email
servers.
await client.transactionals.create({
to: 'ada@example.com',
subject: 'Receipt',
body: '<p>Thanks</p>',
idempotencyKey: `receipt-${order.id}`,
});
await client.transactionals.get(id);Idempotency. The server stores the response for 24 hours keyed on (token, key) and replays it rather than sending a second email. The key is part of a fingerprint over method + path + body:
- same key, same payload, still running →
ConflictError(409) - same key, different payload →
ValidationError(422)
That 422 means "this key was already used for something else", not that the email was invalid. Do not retry it with the same key.
Check meta(result).idempotentReplay to tell a replay from a fresh send.
This is the only endpoint that accepts Idempotency-Key.
await client.whoami(); // token identity and permissions
await client.status(); // channel readiness — check before a send
await client.prime(); // full capability manifest
await client.skill(); // plain-text agent skill manifest (a string)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
await client.migration.subscribers({ limit: 250 });
for await (const sub of client.migration.eachRecord('subscribers')) {
// auto-pages; advances by the limit the server actually applied
}
// Suppressed addresses live in two lists; export both:
// suppressions = global suppression list, unsubscribedEmails = the channel's own
for await (const row of client.migration.eachRecord('unsubscribedEmails')) {
// { id, email, broadcast_channel_id, created_at, updated_at }
}
const bytes = await client.migration.downloadFileAsset(id); // Uint8ArrayOn 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 here as AuthorizationError.
Admin/system tokens can address any channel:
await client.withChannel(123, async () => {
await client.emailServers.list(); // scoped to channel 123
});The override lives on the client instance and is restored afterwards, including
when the block throws. Concurrent calls on the same client instance will see
each other's scope — use one client per channel, or pass
broadcast_channel_id explicitly, when running channels in parallel.
import { Webhook, EVENT_TYPES } 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.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 — a handler should answer 401
identically in all cases.
Pass the raw request body. Re-serialising a parsed object changes the bytes and verification will fail.
EVENT_TYPES lists all 37 event names; an unknown event type is dropped
silently when creating an endpoint. SUBSCRIBER_EVENTS includes
subscribers.purged and subscribers.purge_failed: a purge of the whole
list sends one of these instead of a subscriber.deleted per subscriber.
Each token can be scoped to specific resources. Use the minimum permissions your integration requires.
| Resource | Read permission | Write permission |
|---|---|---|
| Transactional Emails | transactionals_read -- get delivery status |
transactionals_write -- send emails |
| Subscribers | subscribers_read -- list, find |
subscribers_write -- create, update, tag, deactivate, unsubscribe, redact |
| Sequences | sequences_read -- list, get, list steps |
sequences_write -- create, update, delete, manage steps, enroll subscribers |
| Broadcasts | broadcasts_read -- list, get, statistics |
broadcasts_write -- create, update, delete, send, schedule |
| Segments | segments_read -- list, get |
segments_write -- create, update, delete |
| Templates | templates_read -- list, get, channel design |
templates_write -- create, update, delete |
| Opt-In Forms | opt_in_forms_read -- list, get, analytics |
opt_in_forms_write -- create, update, delete, create_variant, duplicate |
| Email Servers | email_servers_read -- list, get |
email_servers_write -- create, update, delete, test_connection, copy_to_channel (admin) |
| Webhook Endpoints | webhook_endpoints_read -- list, get, deliveries |
webhook_endpoints_write -- create, update, delete, test |
| Autopilot | autopilot_read -- list, get, runs |
autopilot_write -- create, update, delete, activate, pause, deactivate, trigger_run |
| File Assets | file_assets_read -- list, get |
file_assets_write -- upload, uploadBase64, delete |
| Channel Settings | channel_settings_read -- channel settings, brand kit |
channel_settings_write -- update channel settings and the brand kit |
| Users (admin token only) | users_read -- list, get, channel/system permissions |
users_write -- create, update, deactivate, activate, delete, set/remove/bulk channel permissions, update system permissions |
- Check the token: it must be an API key from Settings > API Keys, not a password or a session cookie.
- Check the host: pointing at the wrong instance produces a valid-looking 401, because the token is unknown there.
The token is valid but lacks the permission for that call. Check the table above, then re-issue the key with the resource enabled — permissions are fixed at creation.
On a demo instance, the entire migration API returns 403 for every request, valid token or not.
If you reused an idempotencyKey with a different payload, the API rejects
it: the key is fingerprinted over method, path and body. It means "this key was
already used for something else", not that the email was invalid. Use a new key.
Call client.status(). If readiness.transactionals is false, the channel has
no usable email server or sender identity — the API accepts the request and the
send stalls. On a demo instance, sends are always accepted and never delivered.
Your host is wrong — usually http instead of https, or a bare apex that
redirects to www. The client refuses to follow redirects on writes, and never
across hosts, because every request carries your API token. Set host to the
final URL.
Node 18+ provides fetch natively. On an older runtime, or a bundler that
strips it, pass one explicitly: new Broadcast({ ..., fetch: myFetch }).
npm install
npm run lint # eslint + typescript-eslint
npm run typecheck
npm test # mocked HTTP, no network
npm run check # all three
npm run build
# against a real instance
BROADCAST_LIVE_TEST=1 BROADCAST_HOST=http://localhost:3000 \
BROADCAST_API_TOKEN=... npm run test:liveThe unit suite runs TypeScript directly through --experimental-strip-types,
which needs Node 22.6+. That is a constraint on the sources, not the package:
CI additionally builds and smoke-tests the published output on Node 18, 20, 22
and 24, which is what proves the engines floor.
- Node SDK guide — the same material as this README, on the docs site
- API reference — endpoints, parameters, and permissions
- API response warnings — why a 2xx can still tell you something went wrong
- Webhook endpoints — signature format and event types
- Agents CLI — the same credentials, from a terminal
| Language | Package | Repository |
|---|---|---|
| Node / TypeScript | @send-broadcast/sdk | this repository |
| Ruby | broadcast-ruby | broadcast-ruby |
| PHP | broadcast/broadcast-php | broadcast-php |
| Python | broadcast-python | broadcast-python |
All four cover the same 104 operations and behave the same way on the wire — the transport contract (warnings, idempotency, rate-limit handling, redirect safety, credential redaction) is identical across languages.
MIT