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 / 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
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": 20,
"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",suppressed= filtered by the org suppression list.ramp_minutes(0–1440) spreads sends evenly across the window; default fires all at once.subject_variants(2–5) 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}, and 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. 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.
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. - 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.
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.
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 only 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). - Max 10,000 rows per upload. Split larger files.
Content-type either text/csv (body = raw CSV) or application/json with { "csv": "..." }.
Audits as audience.csv_imported.