Emails
POST /api/v1/emails
Send a single email. Source: src/app/api/v1/emails/route.ts.
Request
{
"from": "sender@yourdomain.com",
"to": ["recipient@example.com"],
"subject": "Hello",
"html": "<p>Hi</p>",
"text": "Hi",
"tags": ["welcome"],
"stream": "transactional",
"metadata": { "campaign": "launch" },
"scheduled_at": "2026-05-01T12:00:00Z",
"attachments": [
{ "filename": "invoice.pdf", "content": "<base64>", "content_type": "application/pdf" }
],
"template": "welcome-email",
"variables": { "name": "Fareed" }
}
Validation (sendEmailSchema):
| Field | Type | Rules |
|---|---|---|
from |
string (email) | required |
to |
string[] (email) | 1–50 items |
subject |
string | required unless template supplies it |
html |
string | optional |
text |
string | optional |
stream |
"transactional" | "broadcast" |
default "transactional" — see below |
tags |
string[] | max 10 |
metadata |
Record<string,string> | optional |
scheduled_at |
ISO 8601 UTC | if in future (>1s), message stored with status: "scheduled" |
attachments |
object[] | max 10 — { filename, content (base64), content_type } |
template |
string | slug of a template — subject/html/text derived from it when omitted |
variables |
object | Any JSON per key: scalars substitute into {{key}}, arrays feed {{#each}}, objects feed dotted paths. Max 10 levels deep and 64 KB serialized (limits) |
stream — transactional vs broadcast
stream decides two things together, and they have to move together.
transactional (default) |
broadcast |
|
|---|---|---|
List-Unsubscribe + List-Unsubscribe-Post |
not added | added, single-recipient sends only |
Blocked by all suppressions (bounce, complaint, STOP, manual) |
yes | yes |
Blocked by broadcast suppressions (one-click unsubscribes) |
no | yes |
{{unsubscribe_url}} in html / text |
422 UNSUBSCRIBE_REQUIRES_BROADCAST |
substituted with a per-recipient signed link |
Send a newsletter as "stream": "broadcast". Gmail and Yahoo bulk-sender rules
require RFC 8058 one-click on marketing mail, and only this stream both carries
the header and honors the opt-outs it collects.
Leave receipts, password resets, OTPs and alerts on the default. Marking them
broadcast means a single unsubscribe from your marketing also stops that
recipient's password resets — the exact failure
the stream split exists to prevent.
The header is only added to a single-recipient envelope. The token binds to
to[0], so on a multi-recipient send one recipient could unsubscribe another;
{{unsubscribe_url}} there is refused with 422 UNSUBSCRIBE_MULTI_RECIPIENT.
Send individually — or use the bulk shape of /v1/emails/batch, which fans one
body out to one envelope per recipient.
A List-Unsubscribe you supply in headers is left exactly as you sent it and
is never overwritten. It does not promote the send to broadcast: the stream is
declared, never inferred, so a header pointing at your own opt-out endpoint
cannot quietly start filtering your transactional mail.
Headers
Authorization: Bearer <key>— requiredIdempotency-Key: <uuid>— optional, 24h replay window
Response — 200
Immediate send:
{ "id": "msg_...", "channel": "email", "status": "sent", "created_at": "..." }
Scheduled:
{
"id": "msg_...",
"channel": "email",
"status": "scheduled",
"scheduled_at": "2026-05-01T12:00:00.000Z",
"created_at": "..."
}
The sandbox sender
Every org gets its own {org-slug}.sandbox.sendoka.com sender at signup, so you
can send before you have verified a domain. It sends on Sendoka's own domain, so
with a live key it is restricted:
- Recipients: every address in
to,ccandbccmust be the verified sign-in address of a member of your org — the address a teammate signs in with, once it is verified. OnPOST /v1/emailsthat is judged after suppression;/v1/emails/batchjudges each item's full list. Anything else is refused with403 SANDBOX_RECIPIENT_NOT_ALLOWED, and nothing is sent. When a refused address belongs to a member who has not verified it yet, the message says so: that member verifies it in the dashboard under Settings → Your sign-in email → Send verification link (the session routePOST /api/auth/resend-verification). Password accounts created before signup required an emailed code often still read as unverified. - Volume: 100 recipients per org per UTC day, counted like usage (to + cc +
bcc) across every sandbox sender the org holds, dashboard template test sends
included. Past it:
429 SANDBOX_DAILY_LIMIT, whose message gives the reset time (the next 00:00 UTC). It carries noRetry-After: retrying before midnight is refused the same way, so do not retry it. Email codes fromPOST /v1/verificationsare refused past the cap but do not count toward it. - No broadcasts: an audience send from the sandbox sender is refused
outright (
403 SANDBOX_RECIPIENT_NOT_ALLOWED).
A scheduled send counts against the day it is created on, and its recipients are
checked both then and again when it fires; one that no longer passes is canceled
with the reason in error_message. Test keys
(sok_test_…) never reach a provider, so none of this applies to them. To mail
anyone else, add and verify your own domain (features/domains.md).
Errors
401 UNAUTHORIZED— missing/invalid key or expired key422 VALIDATION_ERROR— zod failure, missing subject with no template, unknown template422 ALL_SUPPRESSED/422 TO_ALL_SUPPRESSED— every recipient, or everytorecipient, is suppressed in this send's stream422 UNSUBSCRIBE_REQUIRES_BROADCAST—{{unsubscribe_url}}on a transactional send. Set"stream": "broadcast", or drop the token422 UNSUBSCRIBE_MULTI_RECIPIENT—{{unsubscribe_url}}with more than one destination403 SANDBOX_RECIPIENT_NOT_ALLOWED— live send from your org's sandbox sender to an address that is not a member's verified sign-in address (above)429 SANDBOX_DAILY_LIMIT— the sandbox sender's 100 recipients for this UTC day are spent; the message gives the reset time, and there is noRetry-After(not retryable before then)413 PAYLOAD_TOO_LARGE— body over 6 MB, or the template passes a render cap: its{{#each}}blocks run more than 5,000 iterations or render more than 2,000,000 characters, or a part renders more than 10,000,000 characters (rendering rules); refused, never sent cut short429 USAGE_LIMIT_EXCEEDED— free plan cap hit429 RATE_LIMITED— per-plan burst exceeded500 INTERNAL_ERROR— SES failed (message inserted withstatus: "failed")
POST /api/v1/emails/batch
Scope: send:email. maxDuration pinned to 300s. Accepts two shapes:
Shape 1 — per-item array (1–100 items)
Each item is one envelope. Item's to array becomes the visible TO header
so multi-recipient emails work (e.g. to: ["a@", "b@"] lands as
To: a@, b@ — both recipients see each other).
[
{ "from": "hello@yourdomain.com", "to": ["a@example.com"], "subject": "Personalized 1", "html": "<p>hi 1</p>" },
{ "from": "hello@yourdomain.com", "to": ["b@example.com"], "subject": "Personalized 2", "html": "<p>hi 2</p>" }
]
stream is per item — one batch can carry a newsletter and a receipt, and
each is filtered against its own set of suppressions. It behaves exactly as on
single-send, including the {{unsubscribe_url}} rule, which fails that item
alone (UNSUBSCRIBE_REQUIRES_BROADCAST / UNSUBSCRIBE_MULTI_RECIPIENT) rather
than the request.
Shape 2 — bulk broadcast (one body to N recipients)
Each to entry becomes its own envelope — recipients don't see each
other, each gets their own message id, tracking pixel, and (on
"stream": "broadcast") their own unsubscribe header and {{unsubscribe_url}}
link. Up to 100 recipients per call. stream applies to all of them: one body
to N recipients is one stream.
{
"from": "hello@yourdomain.com",
"to": ["a@example.com", "b@example.com", "c@example.com"],
"subject": "Newsletter",
"html": "<h1>This month's update</h1><a href=\"{{unsubscribe_url}}\">Unsubscribe</a>",
"stream": "broadcast",
"reply_to": ["support@yourdomain.com"],
"tags": ["newsletter"],
"metadata": { "campaign": "may_2026" },
"attachments": [
{ "filename": "may.pdf", "url": "https://example.com/may.pdf", "content_type": "application/pdf" }
]
}
Mirrors SendGrid Personalizations / Resend's bulk send. Internally fans out to per-recipient items; the gates and response shape are identical to Shape 1.
Response (both shapes)
{
"data": [
{ "index": 0, "id": "msg_...", "status": "sent", "error": null },
{ "index": 1, "id": null, "status": "failed", "error": { "code": "SUPPRESSED", "message": "..." } }
],
"total": 2,
"succeeded": 1,
"failed": 1
}
- Usage incremented per successful item.
- Plan quota enforced upfront — batches exceeding remaining quota are rejected (429) before any send.
- Suppressed recipients fail per-item with
SUPPRESSED; rest of the batch proceeds. Which suppressions apply depends on that item'sstream. - From your org's sandbox sender with a live key, an item
whose full
to+cc+bccreaches anyone who is not a verified member of your org fails on its own withSANDBOX_RECIPIENT_NOT_ALLOWED, and an item that would pass the org's 100-recipient daily sandbox cap withSANDBOX_DAILY_LIMIT; the rest of the batch proceeds. - An item the provider refused (
PROVIDER_ERROR) is recorded like a failed single-send: when that row lands it comes back with anid, and the message is visible in the dashboard and throughGETwithstatus=failed. Amessage.failedwebhook fires for it — batch only; single-send records the row but sends no webhook. Items rejected by validation before any send (VALIDATION_ERROR,SUPPRESSED,SENDER_NOT_VERIFIED) have"id": null— nothing was ever attempted for them, and nothing is fanned out. - Rare third case:
"status": "sent"with"id": nulland error codeMESSAGE_NOT_RECORDED. The provider accepted the message — it was delivered and is billed — but the message row could not be stored, so there is no id to fetch and nomessage.sentwebhook fires for it. Do not retry the item; retrying sends a duplicate. - Rare fourth case:
"status": "failed"with"id": nulland error codePROVIDER_ERROR. The provider refused the item and the failure row could not be stored, so there is no id to fetch, the item is absent from the dashboard and fromstatus=failed, and nomessage.failedwebhook fires — the response body is the only trace of it. UnlikeMESSAGE_NOT_RECORDED, retrying is safe: nothing was delivered.error.messagestill carries the provider's rejection. Idempotency-Keyis honoured on batch (both shapes), with the same 24h window as single-send. Batch is where retries actually happen: it is the request most likely to hit a timeout or a client-side retry wrapper, and the one whose duplicate is most expensive — several hundred duplicate messages, charges, andmessage.sentwebhooks. A batch replay returns the recorded per-item outcome array verbatim. It is not a re-attempt: items that failed on the first call stay failed in the replay. To retry only the failed items, send them as a new batch under a new key. Full contract: idempotency.md.template+scheduled_atnot supported on either shape — use single-send.
GET /api/v1/emails
List emails for the auth'd org + environment.
Query params
| Param | Default | Notes |
|---|---|---|
limit |
20 | max 100 |
cursor |
— | base64url of `{ISO} |
status |
— | sent / delivered / bounced / failed / scheduled |
tag |
— | messages carrying this tag |
to |
— | exact recipient address match |
created_after |
— | ISO 8601; inclusive lower bound on created_at |
created_before |
— | ISO 8601; inclusive upper bound on created_at |
Response
{
"data": [{
"id": "msg_...",
"channel": "email",
"status": "delivered",
"from": "a@x.com",
"to": "b@y.com",
"subject": "...",
"created_at": "...",
"sent_at": "...",
"delivered_at": "..."
}],
"has_more": true,
"next_cursor": "eyIyMDI2..."
}
Pass next_cursor as ?cursor= on the next request.
GET /api/v1/emails/{id}
Retrieve one email by ID. 404 if not owned by caller's org.
Returns what was actually sent — from, from_name, to, cc, bcc,
reply_to, subject, headers, attachments (filename + content type only;
the bytes are never stored), track_opens, track_clicks, tags, metadata
— plus status, the timestamps, error_message, provider_response and the
full status_history.
Add ?include=body for the rendered html and text. Both come back null
once MESSAGE_CONTENT_TTL_DAYS has redacted the row; that is not the same
answer as the message having had no HTML part, and it is never a 404.
curl "https://www.sendoka.com/api/v1/emails/msg_01HN...?include=body" \
-H "Authorization: Bearer $SENDOKA_API_KEY"