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}/contactsPOST /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 intomessageswithstatus="scheduled". Every other member is counted under exactly one reason, soscheduled + 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'ssuppressed(GET /v1/jobs/{id},job.*webhooks) is the same suppression-list count, and the job'stotalisscheduled.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 inmessages.metadata.subject_variant— score the arms withGET /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_...);NULLgroups the non-blast (transactional) traffic.group_by=variant— one row persubject_variantsarm (A,B, …), read frommessages.metadata.subject_variant.- Filters
job=andvariant=scope any grouping to one blast / one arm. - Every row now carries
unsubscribed(distinct unsubscribe events, counted like opens/clicks) next tobounced/complained, plus anunsubscribe_rateover 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:
The contact row, always present:
Variable Value {{contact.id}}The contact's id ( con_…orctc_…){{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, useHi {{#if first_name}}{{first_name}}{{else}}there{{/if}}.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 anamehere replaces every contact's name.contactis the exception: an object merges into the seededcontactfield by field, so{ "contact": { "company": "Acme" } }adds{{contact.company}}and keeps{{contact.name}}.The contact's
metadata, which overrides both. Metadata values are strings, so a metadata key namedcontact(a CSV column called "contact", say) replaces the seededcontactobject 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
nameormetadata(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 malformedaudience_idis a422.DELETE /api/internal/jobs/{id}— developer or owner (requireDeveloperSession,403otherwise). The session-authenticated twin ofDELETE /v1/jobs/{id}, with the same behaviour: rows still cancelable are flipped tocanceled(rows the scheduled-send cron already claimed assendingstill deliver), the job is markedcanceledeven when nothing was left to stop, and the response is{ id, status: "canceled", canceled_count }. A retried DELETE is a no-op. Malformed id422, unknown or out-of-scope job404. Writesmessage_status_eventsrows and ajob.canceledaudit 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:
emailorphone(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
nameandmetadataare 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
emailcell 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: anemailthe API accepts, aphonein E.164. Formatting is stripped (+1 (415) 555-0100imports as+14155550100) and00reads as+. A national number (no+or00) needsdefault_country, an ISO alpha-2 code given as?default_country=USor besidecsvin 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 inskippedif not. - The response is
{ audience_id, inserted, updated, skipped, error_count, errors, default_country, default_country_source }, whereerrorslists the first 100 cells left out as{ row, column, reason }(row1 is the first row under the header), anddefault_countryis the country national numbers were read against (nullif none) with its source,requestorsms_allowed_countries. - Max 10,000 rows per upload. Split larger files.
- Max 100 columns in the header (
422beyond it): every column pastemail,phoneandnamebecomes 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.