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

  1. Log in to your Broadcast dashboard
  2. Go to Settings → API Keys
  3. Click New API Key
  4. Name it and select only the permissions your integration needs
  5. 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.

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.

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, 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 AuthorizationError) { // 403 — token lacks permission
  } 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
}

const bytes = await client.migration.downloadFileAsset(id);  // Uint8Array

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.

Prefer another language? See the Ruby SDK or the PHP SDK.

Was this page helpful?

Thanks for your feedback!

Thanks for letting us know. We'll work on improving this page.