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> — required
  • Idempotency-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, cc and bcc must be the verified sign-in address of a member of your org — the address a teammate signs in with, once it is verified. On POST /v1/emails that is judged after suppression; /v1/emails/batch judges each item's full list. Anything else is refused with 403 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 route POST /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 no Retry-After: retrying before midnight is refused the same way, so do not retry it. Email codes from POST /v1/verifications are 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 key
  • 422 VALIDATION_ERROR — zod failure, missing subject with no template, unknown template
  • 422 ALL_SUPPRESSED / 422 TO_ALL_SUPPRESSED — every recipient, or every to recipient, is suppressed in this send's stream
  • 422 UNSUBSCRIBE_REQUIRES_BROADCAST{{unsubscribe_url}} on a transactional send. Set "stream": "broadcast", or drop the token
  • 422 UNSUBSCRIBE_MULTI_RECIPIENT{{unsubscribe_url}} with more than one destination
  • 403 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 no Retry-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 short
  • 429 USAGE_LIMIT_EXCEEDED — free plan cap hit
  • 429 RATE_LIMITED — per-plan burst exceeded
  • 500 INTERNAL_ERROR — SES failed (message inserted with status: "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's stream.
  • From your org's sandbox sender with a live key, an item whose full to + cc + bcc reaches anyone who is not a verified member of your org fails on its own with SANDBOX_RECIPIENT_NOT_ALLOWED, and an item that would pass the org's 100-recipient daily sandbox cap with SANDBOX_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 an id, and the message is visible in the dashboard and through GET with status=failed. A message.failed webhook 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": null and error code MESSAGE_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 no message.sent webhook fires for it. Do not retry the item; retrying sends a duplicate.
  • Rare fourth case: "status": "failed" with "id": null and error code PROVIDER_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 from status=failed, and no message.failed webhook fires — the response body is the only trace of it. Unlike MESSAGE_NOT_RECORDED, retrying is safe: nothing was delivered. error.message still carries the provider's rejection.
  • Idempotency-Key is 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, and message.sent webhooks. 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_at not 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"