# Node SDK

> Install and use @send-broadcast/sdk, the official Node and TypeScript client for the Broadcast API: subscribers, broadcasts, sequences, transactional email, and webhooks.

Source: https://sendbroadcast.net/docs/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

```bash
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](https://sendbroadcast.net/docs/api-authentication) for the full permission list.

## Quick Start

```ts
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:

```js
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](https://sendbroadcast.net/docs/agents-cli) uses in
`~/.config/broadcast/config`.

## Configuration

```ts
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()`:

```ts
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.

```ts
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](https://sendbroadcast.net/docs/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`).

```ts
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

```ts
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](https://sendbroadcast.net/docs/api-subscribers) for every filter and field.

### Transactional email

```ts
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](https://sendbroadcast.net/docs/api-transactional-email).

### Broadcasts

```ts
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

```ts
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](https://sendbroadcast.net/docs/subscriber-topics) use the subscriber permissions.

```ts
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:

```ts
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

```ts
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

```ts
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:

```ts
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](https://sendbroadcast.net/docs/api-suppressions) for the full semantics.

### Channel settings and brand kit

```ts
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](https://sendbroadcast.net/docs/api-channel-settings) and [Brand Kit API](https://sendbroadcast.net/docs/api-channel-design).

### 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`.

```ts
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`:

```ts
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) raises `ValidationError` and changes
  nothing. Use `deactivate` and `activate` for sign-in access.
- A bulk apply that applies to no channel raises `ValidationError`; otherwise check its
  `failed` list.
- Passing none, or more than one, of the permission options raises `TypeError`
  before any request is sent.

See [Users API](https://sendbroadcast.net/docs/api-users) for every field and rule, and
[Users & Permissions](https://sendbroadcast.net/docs/users-and-permissions) for what the roles and permissions mean.

## Channel Scoping

Admin (system) tokens can address any channel:

```ts
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

```ts
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,` 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](https://sendbroadcast.net/docs/api-webhook-endpoints) for creating and managing endpoints. ## Errors
```ts
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`.
```ts
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: - **Source:** [github.com/send-broadcast/broadcast-node](https://github.com/send-broadcast/broadcast-node) 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](https://sendbroadcast.net/docs/ruby-sdk) or the [PHP SDK](https://sendbroadcast.net/docs/php-sdk).
