Bulk audience send
Load a list of contacts into an audience, send one personalized template to all
of them in a single call, and follow the blast to completion. One
POST /api/v1/audiences/{id}/send reaches up to 10,000 recipients. Sendoka
renders the template once per contact, drops suppressed addresses and schedules
one message per recipient. It answers straight away with a job_id you can
poll or cancel.
1. Create the audience (once)
curl -X POST https://www.sendoka.com/api/v1/audiences \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "newsletter-2026-04", "name": "April newsletter"}'
slug (lowercase letters, digits and dashes, unique per organization) and
name are both required. Response 201:
{ "id": "aud_...", "slug": "newsletter-2026-04" }
A slug already in use is 409 AUDIENCE_SLUG_TAKEN. Scope: write:audiences.
2. Add contacts, 1,000 per call
const API = "https://www.sendoka.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.SENDOKA_API_KEY}`,
"Content-Type": "application/json",
};
const all = await readCsv("./subscribers.csv"); // [{ email, name, company }, ...]
for (let i = 0; i < all.length; i += 1000) {
const res = await fetch(`${API}/audiences/${audienceId}/contacts`, {
method: "POST",
headers,
body: JSON.stringify({
contacts: all.slice(i, i + 1000).map((c) => ({
email: c.email,
name: c.name,
// String values only. Every key becomes a template variable.
metadata: { company: c.company },
})),
}),
});
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error?.message}`);
}
Each contact needs an email or an E.164 phone. name (up to 200
characters) and metadata (a string-to-string map) are optional. Response
201:
{
"audience_id": "aud_...",
"submitted": 1000,
"contacts_created": 988,
"contacts_matched": 12,
"members": 1000
}
- Contacts are upserted by email or phone, so you can re-run a sync
without creating duplicates. A matched contact keeps the
nameandmetadatait already has. The call links it to the audience and does not update those fields. It does take an email or phone the contact lacks: a phone-only contact sent again with an email gets that email, so email blasts reach it. A tenant-scoped call fills only its own tenant's contacts: an organization-level contact it matches is linked as it is. - Re-adding someone who unsubscribed from this audience does not
re-subscribe them. When they opt back in, remove their suppression with
DELETE /v1/suppressions: lifting areason: "unsubscribe"row also clears the unsubscribe on their audience memberships. - Scope:
write:contacts.
3. Send
curl -X POST https://www.sendoka.com/api/v1/audiences/aud_.../send \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: newsletter-2026-04-send" \
-d '{
"channel": "email",
"from": "news@yourdomain.com",
"template": "newsletter",
"variables": { "issue": "April", "company": "your team" },
"ramp_minutes": 60
}'
channel, from and template are required. The subject and body come from
the template. There is no subject field on this call; use
subject_variants to A/B test subject lines. from must be on a verified
domain (email) or a verified sender (SMS).
Personalization. Each recipient renders against its own variables. The
contact's fields come first ({{name}}, {{first_name}}, {{email}},
{{phone}}, and the same under {{contact.*}}), then the request's
variables, then the contact's metadata, which wins. So a template like
Hi {{#if first_name}}{{first_name}}{{else}}there{{/if}}, here is the {{issue}} issue for {{company}}.
greets each contact by name and uses their own company where they have one,
falling back to the blast-wide "your team". If a placeholder resolves for no
recipient at all, for example a typo like {{frist_name}}, the whole blast is
refused with 422 TEMPLATE_MISSING_VARIABLES before anything is
scheduled, and the error names the variable. A key that only some contacts
carry is not refused: it renders empty for the others, so give it a default in
variables or wrap it in {{#if}}. See
per-recipient variables.
Retries. Send an Idempotency-Key header. If the connection drops after
Sendoka accepted the blast, retrying with the same key and body returns the
original response, including the same job_id, instead of scheduling the
audience a second time. See idempotency.
Scopes: write:audiences plus send:email or send:sms for the channel.
Response 200, returned as soon as the rows are scheduled:
{
"job_id": "job_...",
"audience_id": "aud_...",
"total": 9917,
"scheduled": 9805,
"suppressed": 100,
"skipped_no_address": 9,
"skipped_duplicate": 3,
"deferred_quiet_hours": 0,
"starts_at": "2026-04-07T14:00:00.000Z",
"ends_at": "2026-04-07T15:00:00.000Z"
}
total is the number of members still subscribed to the audience and
scheduled the messages created. Every other member is counted once, under
why it was left out: suppressed (on your suppression list),
skipped_no_address (no address on the blast's channel) or
skipped_duplicate (an address an earlier member already has, so nobody is
sent the blast twice). The four always add up to total. The scheduled
messages go out from the scheduled-send cron, which runs every minute, spread evenly between
starts_at and ends_at when you pass ramp_minutes.
4. Follow the job
Poll it:
curl https://www.sendoka.com/api/v1/jobs/job_... \
-H "Authorization: Bearer $SENDOKA_API_KEY"
{
"id": "job_...",
"audience_id": "aud_...",
"channel": "email",
"status": "running",
"total": 9805,
"suppressed": 112,
"processed": 6205,
"pending": 3600,
"counts": { "scheduled": 3600, "sent": 1800, "delivered": 4300, "bounced": 105 },
"starts_at": "2026-04-07T14:00:00.000Z",
"ends_at": "2026-04-07T15:00:00.000Z",
"created_at": "2026-04-07T13:59:58.000Z"
}
status moves scheduled → running → completed, or goes to canceled.
A job whose every recipient was suppressed (total: 0) reads completed at
once. counts is keyed by message status and only lists statuses that have
messages. A message that failed transiently and is waiting for its automatic
retry shows under failed in counts but is still counted in pending, so
the job stays running (and job.completed waits) until that retry settles.
Scope: read:messages.
Or subscribe a webhook endpoint to the job events instead of polling:
| Event | When | data |
|---|---|---|
job.started |
The blast's first message has gone out | id, audience_id, channel, total, suppressed |
job.completed |
Nothing is left pending | the above plus sent, delivered, failed, canceled |
job.canceled |
DELETE /v1/jobs/{id} |
id, audience_id, channel, total, canceled |
The per-message message.* events still fire for every recipient. Verify
signatures as in verify webhooks.
To stop a blast that is still ramping, call
DELETE /api/v1/jobs/job_.... Every row the cron has not yet claimed is
canceled, and the cron also cancels any row of a canceled job that it claims
later instead of sending it. The key needs the send:* scope for the job's
channel.
Why one call instead of a loop over /v1/emails
The blast route handles, in one request:
- Drops members who unsubscribed from this audience, and addresses on your suppression list, including one-click unsubscribes, since a blast is broadcast mail. Tenant suppressions apply to a tenant's audience.
- Checks every placeholder against every recipient, so a typo refuses the blast instead of mailing thousands of blank greetings.
- Checks the whole blast against your plan allowance and spend cap, sender verification, domain warm-up and SMS destination rules once, up front, rather than once per message.
- Records the job before the messages, so every row it schedules can be
found and canceled through
job_id. - Inserts messages in chunks sized by serialized bytes, so a large HTML template cannot exceed the database's statement size.
- Bakes open and click tracking into each stored body (on by default for email
blasts; pass
"track_opens": false/"track_clicks": falseto opt out), and gives each recipient their own{{unsubscribe_url}}andList-Unsubscribeheader.
A loop over /v1/emails does none of that for you. It also costs one request,
one rate-limit check and one usage check per recipient.
Limits
- 10,000 recipients per send. A larger audience is refused with
413 AUDIENCE_TOO_LARGE, so split it into several audiences. - 1,000 contacts per
POST /v1/audiences/{id}/contactscall. ramp_minutesfrom 0 to 1440.subject_variants: 2–5, email only.variables: any JSON, at most 10 levels deep and 64 KB serialized (details). Contactmetadatavalues are strings.- 900,000 bytes of stored message (subject, HTML and text after tracking,
plus headers) per recipient, and 500,000,000 bytes for the whole blast. Past
either, the blast is refused with
413 PAYLOAD_TOO_LARGE(rendered size).