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.
  • 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 scheduledrunningcompleted, 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": 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).