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. - Re-adding someone who unsubscribed from this audience does not re-subscribe them.
- 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": 112,
"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,
scheduled the messages created, and suppressed the difference: members on
your suppression list, plus any with no address on the blast's channel. Those 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.
counts is keyed by message status and only lists statuses that have
messages. 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. 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).