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 into messages with status="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 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}, 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_...); 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.

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.
  • 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: 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 only 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).
  • 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.