Audiences

Reusable contact groups you can target with a single templated send.

Model

Table Purpose
audiences Named group (slug-unique per org)
contacts People with email (at most 320 characters) / phone / name / free-form metadata
audience_members Join table

A blast renders the template once per contact. The contact's own fields ({{contact.name}}, {{name}}, {{contact.email}}, …) and its metadata keys (CSV columns other than email / phone / name, or metadata on POST /v1/audiences/{id}/contacts) are available as {{variables}}. A contact's metadata overrides a same-named key in the send request's variables — the request supplies blast-wide defaults, the contact the per-recipient values. See Per-recipient variables.

Public REST

Test keys

A sok_test_* key can send to an audience (POST /v1/audiences/{id}/send schedules test-mode rows that never reach a provider) and read audiences and contacts, but it cannot change them. Audiences, memberships and contacts have no test-mode copy: a live blast mails whoever is on the list, at the address and with the name and metadata the contact row holds. So these answer 403 ENVIRONMENT_ESCALATION to a test key, before the request body is read:

  • POST /v1/audiences, PATCH /v1/audiences/{id}, DELETE /v1/audiences/{id}
  • POST /v1/audiences/{id}/contacts
  • POST /v1/contacts, PATCH /v1/contacts/{id}, DELETE /v1/contacts/{id}

Creating a new audience is refused too: nothing records which key created a row, so a test key could not be kept to "its own" lists, and a new audience still claims its slug (unique per org and tenant) and can be joined by slug through POST /v1/contacts. To test blasts, create the audience and its contacts once with a live key (write:audiences, write:contacts), then send with the test key. Keep that audience out of live blasts: its contacts are real rows. See Running CI with a test key.

A test blast's rows wait behind every live scheduled message, and an organization may have at most 20,000 test-mode messages waiting to send (a tenant at most 5,000): a test blast that would pass it answers 429 TEST_SCHEDULE_LIMIT_EXCEEDED before anything is rendered or scheduled. Each recipient of a test blast is also one of test mode's daily allowance of messages (1,000 a UTC day on Free; rate limits): a blast that does not fit whole answers 429 TEST_VOLUME_LIMIT_EXCEEDED and schedules nothing. It is charged last, after the blast has rendered, so a blast refused as too large (413) or for its unsubscribe links (422) spends none of the day; a blast the day already cannot take is refused before it renders (the allowance is looked at first, without spending it). A platform-root key's blast of a tenant's audience counts against that tenant's share. Its job.* webhooks say "environment": "test".

Contacts and slugs

POST /v1/contacts is create-or-return. If a contact the key could have created already has the email (case-insensitive) or, failing that, the phone, the call answers 200 { "id": "con_...", "created": false } and links that contact to the audiences named, instead of making a second contact the next blast would mail again. The match takes the name posted, and the posted metadata is merged over its own (posted keys win, the rest stay), so per-recipient merge values re-posted before a blast are the ones it renders. Fields left out of the call are not touched. A new contact answers 201 { "id": "con_...", "created": true }.

Every slug in audiences (on POST /v1/contacts and PATCH /v1/contacts/{id}) must name one audience the key can reach, or the request is refused with 422 and nothing is written: AUDIENCE_NOT_FOUND names the slugs that matched nothing. A root key reaching several tenants' audiences under one slug gets the org-level audience of that slug when there is one, and 422 AUDIENCE_SLUG_AMBIGUOUS when there is not.

PATCH /v1/contacts/{id} with audiences changes only memberships in audiences the key can reach: unlisted ones there are removed, listed ones are added, and one the contact already has is kept as it is, unsubscribe included. For a contact that belongs to a tenant, slugs resolve over (and the diff removes from) only that tenant's audiences, whatever the key: a tenant's contact belongs only on its tenant's lists, which are the only blasts that send to it. A root key naming an org-level or another tenant's audience for such a contact gets 422 AUDIENCE_NOT_FOUND, though the audience exists.

Looking a contact up

GET /v1/contacts takes optional email and phone query parameters, exact matches within the contacts the key can reach (its project, and its tenant for a tenant-bound key). email is compared case-insensitively, since contacts are stored lower-cased. phone is the stored E.164 number; encode + as %2B (a literal + decodes to a space and is read back as +). Given both, a contact must match both. Omitted or empty, the list is unfiltered, exactly as before. A value no contact holds returns an empty page, not an error.

curl "https://www.sendoka.com/api/v1/contacts?email=ada@example.com" \
  -H "Authorization: Bearer $SENDOKA_API_KEY"

Send to every member

POST /api/v1/audiences/:id/send
{
  "channel": "email",
  "from": "hello@yourdomain.com",
  "template": "weekly-newsletter",
  "variables": { "cta": "Read more" },
  "ramp_minutes": 60,
  "subject_variants": [
    "Your weekly update",
    "Quick update for {{name}}"
  ],
  "scheduled_local": "2026-05-01 09:00",
  "scheduled_at_tz": "America/New_York"
}

dlt_template_id is required when any member of the audience has a +91 (India) phone number and the channel is sms — the whole blast is refused with 422 DLT_TEMPLATE_REQUIRED otherwise. It must reference a dlt_ template in approved status whose entity and template have provider-side ids; the id is stored on every enqueued message so the scheduled-send cron can attach the carrier's EntityId / TemplateId when each one actually fires.

SMS blasts run the same two destination gates as POST /v1/sms, in the same order and in every environment, over every recipient left after suppression filtering: a recipient in a high-cost satellite range refuses the whole blast with 422 HIGH_COST_DESTINATION, and a recipient whose country is off the org's allowed SMS destinations refuses it with 403 SMS_DESTINATION_NOT_ALLOWED. The message says how many recipients are affected and quotes a few of them; nothing is scheduled. Refusing rather than skipping is deliberate — the alternative silently shrinks the list the org asked to text, and an audience containing a satellite range is the shape a pumping run takes.

Only well-formed E.164 contacts are judged. POST /v1/contacts enforces the shape but the dashboard CSV importer stores phone as given, and both gates prefix-match on digits alone — so a locally-formatted number would read as a satellite range or as the wrong country and refuse the blast over a formatting artifact. A contact whose number carries no readable calling code is passed through and fails on its own at the provider.

A live email blast from your org's sandbox sender ({org-slug}.sandbox.sendoka.com) is refused outright with 403 SANDBOX_RECIPIENT_NOT_ALLOWED, whoever is in the audience: that sender only reaches your own org members' verified addresses (emails.md). Broadcasts need your own verified domain.

A malformed schedule (scheduled_at_tz missing, unparseable scheduled_local) returns 422 — it does not fall back to sending immediately.

Returns:

{
  "job_id": "job_...",
  "audience_id": "aud_...",
  "total": 4200,
  "scheduled": 4180,
  "suppressed": 15,
  "skipped_no_address": 4,
  "skipped_duplicate": 1,
  "deferred_quiet_hours": 0,
  "starts_at": "2026-05-01T13:00:00.000Z",
  "ends_at": "2026-05-01T14:00:00.000Z"
}
  • total = audience members, scheduled = rows inserted into messages with status="scheduled". Every other member is counted under exactly one reason, so scheduled + suppressed + skipped_no_address + skipped_duplicate = total: suppressed = filtered by the org suppression list; skipped_no_address = no address for the channel (a stored address longer than 320 characters counts as none); skipped_duplicate = an address an earlier member of the list already has, which is sent once, not once per contact (compared case-insensitively). The job's suppressed (GET /v1/jobs/{id}, job.* webhooks) is the same suppression-list count, and the job's total is scheduled.
  • ramp_minutes (0–1440) spreads sends evenly across the window; default fires all at once.
  • subject_variants (2–5, each at most 998 characters — the template subject's cap) runs an A/B on the email subject. Each contact is assigned deterministically (hash of contact id mod N), so retries stay on the same variant. The chosen variant letter (A, B, …) lands in messages.metadata.subject_variant — score the arms with GET /v1/analytics?group_by=variant&job=job_... (see below).
  • Max recipients per call: 10_000. Split bigger audiences.

Scope: write:audiences.

Send with an Idempotency-Key. A retry under the same key replays the recorded response — the same job_id — and never schedules the list again; a retry while the first request is still scheduling gets 409 IDEMPOTENCY_IN_FLIGHT (a big blast can take minutes, so retry later under the same key to learn its job_id). The key is bound to the audience: reusing it for a different audience is 409 IDEMPOTENCY_MISMATCH, so use one key per blast. Both SDKs attach a key automatically. A refusal writes nothing and frees the key. If scheduling fails part-way after the job was created, the answer is 500 AUDIENCE_SEND_INCOMPLETE with the job in error.job_id: rows that landed will send unless you DELETE /v1/jobs/{id}. Once one chunk of rows fails, no further chunk is started, and the 500 is sent only after the chunks already being written have finished, so no row lands after the answer. A retry under the same key replays that 500 rather than blasting over them. The same 500 is recorded, provisionally, the moment the job is created, so a request cut off mid-blast (killed at the 300s limit, say) is not blasted again either: a retry gets 409 IDEMPOTENCY_IN_FLIGHT for 330s, then the replayed job. A request without a key gets the same 500, but nothing is recorded: sending it again schedules the whole list a second time. See idempotency.md.

Open/click tracking

Email blasts are tracked by default — a broadcast is the mail whose engagement you are measuring. Every recipient's stored body gets the same rewrite POST /v1/emails applies with track_opens / track_clicks: links wrapped through the signed click redirect and a signed open pixel injected, both minted against that recipient's message id, so an open or click attributes to the right row. The unsubscribe link is never wrapped — the opt-out URL goes out verbatim.

Pass "track_opens": false and/or "track_clicks": false to opt a blast out. (Note the default is the opposite of /v1/emails, where transactional mail defaults to untracked.) The flags land on each message row, so GET and the dashboard resend path report the truth per message. SMS blasts ignore both.

SMS quiet hours

Audience SMS blasts observe destination-local quiet hours, 21:00–08:00. A recipient whose ramp slot would land inside that window has their row deferred into the next sendable window (which opens at 08:00 destination-local) — still scheduled, fired by the same cron, never dropped, and still stopped by DELETE /v1/jobs/{id} like any other row of the blast. deferred_quiet_hours in the response counts them (inside scheduled, not in addition to it) and ends_at stretches to the last deferred fire time. Transactional /v1/sms sends are never deferred.

Deferred rows do not all land on the window's first instant: each keeps its relative position in the ramp, wrapped into the sendable window. A blast with ramp_minutes stays ramped after deferral instead of collapsing into a burst at 08:00.

The destination's local time comes from the phone number's calling code, which maps to an offset range — for a country spanning several timezones (DST included), the send is allowed only when every plausible zone is outside quiet hours, and the window opens at 08:00 in the westernmost one. That is deliberately conservative: a +1 (US/Canada) blast has a ~5.5-hour daily window rather than ever reaching a handset at a compliance-relevant hour because we guessed the wrong zone. The three +1 territories outside that band — Guam (+1671), the Northern Mariana Islands (+1670) and American Samoa (+1684) — are timed by area code on their own clocks instead, since 08:00 in Hawaii is 04:00 in Guam. Numbers whose calling code the table does not know are sent without deferral — as are numbers not in E.164 (no leading +), since a national-format number has no calling code to read and guessing one would time the send against the wrong country's window.

Quiet hours are on for every org (organizations.sms_quiet_hours, default enforced). An org that handles quiet hours itself can have them set to off — there is no self-serve control for that yet, so ask support.

Scoring a blast: analytics dimensions

GET /v1/analytics groups and filters by the blast and its A/B arms:

GET /api/v1/analytics?group_by=variant&job=job_abc123&days=7
  • group_by=job — one row per send job (job_...); NULL groups the non-blast (transactional) traffic.
  • group_by=variant — one row per subject_variants arm (A, B, …), read from messages.metadata.subject_variant.
  • Filters job= and variant= scope any grouping to one blast / one arm.
  • Every row now carries unsubscribed (distinct unsubscribe events, counted like opens/clicks) next to bounced / complained, plus an unsubscribe_rate over delivered — the number an A/B subject test moves most.

Per-recipient variables

Each recipient renders against its own merge of three layers, lowest precedence first:

  1. The contact row, always present:

    Variable Value
    {{contact.id}} The contact's id (con_… or ctc_…)
    {{contact.name}}, {{name}} The contact's name, trimmed
    {{contact.first_name}}, {{first_name}} The first word of the name
    {{contact.email}}, {{email}} The contact's email
    {{contact.phone}}, {{phone}} The contact's phone

    A field the contact does not have is null. It renders empty, and it still counts as supplied, so even an audience where nobody has a name does not refuse the blast. For a fallback greeting, use Hi {{#if first_name}}{{first_name}}{{else}}there{{/if}}.

  2. The request's variables: blast-wide defaults. Any JSON value is accepted: arrays for {{#each}} and nested objects for dotted paths, within the size and depth limits. A key here replaces the same key from the contact row, so a name here replaces every contact's name. contact is the exception: an object merges into the seeded contact field by field, so { "contact": { "company": "Acme" } } adds {{contact.company}} and keeps {{contact.name}}.

  3. The contact's metadata, which overrides both. Metadata values are strings, so a metadata key named contact (a CSV column called "contact", say) replaces the seeded contact object for that recipient. {{contact}} then renders that string and {{contact.name}} renders empty. Rename the column to keep both.

{{unsubscribe_url}} is set after the merge. For an email blast it is the recipient's own one-click opt-out link, and neither variables nor metadata can replace it. Each recipient's subject, HTML and text may carry it at most 10 times each — a {{#each}} around it counts every copy — or the blast is refused with 422 UNSUBSCRIBE_TOKEN_LIMIT and nothing is scheduled.

Missing variables refuse the blast

Before anything is scheduled, the blast checks every placeholder against every recipient left after suppression filtering. A placeholder that resolves for no recipient refuses the whole blast with 422 TEMPLATE_MISSING_VARIABLES, and no job is created. That is what a typo looks like: {{frist_name}} is missing for everyone, whatever order the audience comes back in.

Template 'newsletter' expects variables not supplied: frist_name — no recipient has it (checked all 4180 after the per-contact merge). …

Not yet for tenant-scoped keys (platform mode, X-Sendoka-Tenant-Ref): their blasts still send, rendering the placeholder as an empty string as they always have, while platform integrations move to sending "" for facts they do not have. The refusal will apply to them too in a later release; send a default in variables now rather than rely on the empty render.

A placeholder that resolves for some recipients does not refuse the blast. The recipients without it render it as an empty string, as blasts always have. Sparse keys are normal on a real list:

  • A CSV import leaves a blank cell out of that contact's metadata rather than storing "".
  • Re-importing, or bulk-adding, a contact that already exists links it to the audience without touching its name or metadata (it only takes an email or phone it did not have).
  • An integration may write a fact into metadata only for the contacts it knows it for.

To fill the gap, pass a default in variables. Metadata still wins wherever a contact has the key:

{ "variables": { "company": "your team" } }

Or branch in the template: {{#if company}}at {{company}}{{/if}}.

The rules otherwise match single sends (Required variables). Names used inside a block body are not required, and neither is the subject of an {{#if}}. When subject_variants is given, the variants are checked in place of the template subject. SMS templates are checked on text only.

Rendered size

Each recipient's message is measured as it is stored: subject, HTML and text after open and click tracking rewrote the links, plus its headers, serialized as JSON. One recipient may be at most 900,000 bytes by that measure, and the whole blast at most 500,000,000 bytes (50 KB per recipient across a full 10,000-contact audience). The whole-blast total bounds memory, so a message containing any character outside Latin-1 (a curly quote, an em dash, an emoji) counts two bytes per character toward it, the way it is held in memory, when that is more than its UTF-8 size. A template that passes one of the renderer's caps (5,000 {{#each}} iterations, 2,000,000 characters of loop output, 10,000,000 characters in all) is also refused (rendering rules).

The first recipient past any of these caps refuses the whole blast with 413 PAYLOAD_TOO_LARGE before anything is scheduled; the message names the contact. A large {{#each}} loop is the usual cause for one recipient, since its body is repeated once per item. For the whole blast, split the audience.

Render work

The template is parsed once per blast and rendered once per recipient. The renders share a budget of 250,000,000 units of work, counted as for a single render (rendering rules) except that looking a name up in the recipient's own variables counts two, and merging a request variables.contact object into the contact's fields counts 16 per key copied, once per recipient. A top-level placeholder therefore costs three units, and the budget is about 10,000 nodes per recipient, half of them placeholders, across a full audience; a typical newsletter spends a few hundred. A blast that passes it is refused with 413 PAYLOAD_TOO_LARGE, naming the recipient it ran out at, before anything is scheduled. One render is capped at 5,000,000 units on its own.

Click tracking draws on the same budget. Every tracked link costs one unit per 16 characters of its href, and a link whose href has to be decoded also costs 10 units per & in it (each HTML entity starts with one). An href is decoded once per blast and remembered, so a newsletter whose links are the same for everyone pays for them once; one that differs per recipient (a {{contact.id}} in a query string, say) pays for every recipient, which for a few & a link is still a few hundred units. It is hrefs dense with entities — tens of thousands of numeric references in one link — that run the budget out, and the blast is refused like any render past it.

The missing-variable check has two budgets. The template's distinct variables times the recipients may be at most 10,000,000 (a thousand variables over a full audience); past it the blast is refused before any recipient is checked. The checks themselves may spend at most 80,000,000 units, priced as the render prices the same lookups (five for a dotted path of one step), plus the contact merge per recipient; past it the blast is refused part way. Both refusals are 413 PAYLOAD_TOO_LARGE, with nothing scheduled.

The plan's usage cap, the spend cap and the tenant's monthly cap are checked before the render, so a blast they refuse costs no rendering. Email is checked on the recipient count. SMS is checked on the recipient count first (every message is at least one segment) and again on the rendered segments after the render.

Dashboard

/overview/audiences lists audiences with contact counts and surfaces an inline Import / Send drawer per audience (owners only).

The drawer sends one Idempotency-Key per blast, held by the page and the browser tab (it survives closing the drawer and a reload) until the blast is answered: pressing Send again with the form unchanged — a double click, or a retry after the connection dropped — replays the first blast's job, or is refused while it is still scheduling, instead of scheduling the list twice. Editing the form, a successful send, or canceling a blast that stopped part-way (AUDIENCE_SEND_INCOMPLETE, shown in the drawer with its cancel button) starts a new key. A request the platform cut off mid-blast leaves a provisional record from the moment its job was created: pressing Send again is refused as still scheduling for 330s, then shows that job as AUDIENCE_SEND_INCOMPLETE with its cancel button. What it does not cover: another tab or browser mints its own key, and if the provisional record itself failed to write, only the lock guards the key, so once it lapses (330s) the same form sends again. Because every Send is keyed, the drawer also shares the key's Redis dependency — see idempotency.md.

Blast list and cancel

The page loads its blasts from GET /api/internal/jobs on mount, so a reload or a visit to another page mid-ramp keeps each blast's badge and Cancel button. Several blasts per audience are tracked, not just the latest.

  • GET /api/internal/jobs[?audience_id=aud_…] — any member (requireSession). Returns { data: [{ id, audience_id, channel, status, total, suppressed, starts_at, ends_at, created_at }] }: every open job (scheduled / running) plus finished ones from the last 7 days, open first, newest first within each, capped at 100 rows. Bounded to the active org and project. A malformed audience_id is a 422.
  • DELETE /api/internal/jobs/{id} — developer or owner (requireDeveloperSession, 403 otherwise). The session-authenticated twin of DELETE /v1/jobs/{id}, with the same behaviour: rows still cancelable are flipped to canceled (rows the scheduled-send cron already claimed as sending still deliver), the job is marked canceled even when nothing was left to stop, and the response is { id, status: "canceled", canceled_count }. A retried DELETE is a no-op. Malformed id 422, unknown or out-of-scope job 404. Writes message_status_events rows and a job.canceled audit entry (metadata.via: "dashboard").

CSV import

Internal-only — POST /api/internal/audiences/:id/import-csv accepts a CSV payload:

email,name,company
alice@example.com,Alice,Acme
bob@example.com,Bob,Widgetco
  • Required header column: email or phone (or both).
  • Optional: name.
  • Every other column becomes a key on contacts.metadata.
  • A blank cell adds no key: that contact's metadata has no entry for the column, rather than an empty string.
  • A row whose email or phone matches an existing contact links that contact to the audience. Its name and metadata are not updated, so new columns carry no key for it. In a blast, such a placeholder renders empty for those contacts (missing variables). The one thing a match takes from the row is an address it lacks: a phone-only contact matched by a row with an email and that phone gets the email (an address another contact already holds is not copied, and one the contact has is never overwritten).
  • Rows of one file that share an address make one contact: a row with only a phone, after a row with an email and that phone, joins the earlier row's contact instead of making a second one.
  • Matching only reaches contacts the audience's blast can send to: for a tenant's audience, that tenant's contacts and the org-level ones; for an org-level audience, only org-level ones. A new contact belongs to the audience's tenant.
  • An email cell is at most 320 characters (RFC 5321). A longer one is left out and reported like any other cell that is not an address (below).
  • Address cells must be addresses, as on POST /v1/audiences/{id}/contacts: an email the API accepts, a phone in E.164. Formatting is stripped (+1 (415) 555-0100 imports as +14155550100) and 00 reads as +. A national number (no + or 00) needs default_country, an ISO alpha-2 code given as ?default_country=US or beside csv in a JSON body (the dashboard's import box has a picker for it). Without one, an org whose SMS destination allowlist names exactly one country reads national numbers as that country; otherwise they are not stored. A cell that is not an address is left out and the row imports on its other address if it has a good one, or counts in skipped if not.
  • The response is { audience_id, inserted, updated, skipped, error_count, errors, default_country, default_country_source }, where errors lists the first 100 cells left out as { row, column, reason } (row 1 is the first row under the header), and default_country is the country national numbers were read against (null if none) with its source, request or sms_allowed_countries.
  • Max 10,000 rows per upload. Split larger files.
  • Max 100 columns in the header (422 beyond it): every column past email, phone and name becomes a metadata key on every contact. Drop the columns you don't need before uploading.

Content-type either text/csv (body = raw CSV) or application/json with { "csv": "..." }.

Audits as audience.csv_imported.