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 name and metadata it 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 a reason: "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": false to opt out), and gives each recipient their own {{unsubscribe_url}} and List-Unsubscribe header.

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}/contacts call.
  • ramp_minutes from 0 to 1440.
  • subject_variants: 2–5, email only.
  • variables: any JSON, at most 10 levels deep and 64 KB serialized (details). Contact metadata values 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).