Developer docs
Send transactional email and SMS through one API. Keys, quotas, webhooks, templates, and audiences — shared between channels.
Quickstart#
Send your first email in under a minute. Drop in your SENDOKA_API_KEY and run.
curl -sS https://api.sendoka.com/api/v1/emails \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "you@sandbox.sendoka.com",
"to": ["user@example.com"],
"subject": "Hello from Sendoka",
"html": "<p>It works.</p>"
}'const res = await fetch("https://api.sendoka.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDOKA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "you@sandbox.sendoka.com",
to: ["user@example.com"],
subject: "Hello from Sendoka",
html: "<p>It works.</p>",
}),
});
const msg = await res.json();
console.log(msg.id);import os, httpx
r = httpx.post(
"https://api.sendoka.com/api/v1/emails",
headers={"Authorization": f"Bearer {os.environ['SENDOKA_API_KEY']}"},
json={
"from": "you@sandbox.sendoka.com",
"to": ["user@example.com"],
"subject": "Hello from Sendoka",
"html": "<p>It works.</p>",
},
timeout=30,
)
r.raise_for_status()
print(r.json()["id"])No verified domain? Every org gets a sandbox.sendoka.com sender auto-provisioned. For the full walk-through see /docs/guides/getting-started/quickstart.
Authentication#
Every request carries a Bearer token in Authorization. Keys come in two flavors:
sok_live_…— production. Real SES/SNS calls, real delivery, real billing.sok_test_…— sandbox. Simulated send, same response shape, no delivery.
Authorization: Bearer sok_live_your_api_key_hereKeys can be scoped per-channel (emails:send, sms:send), constrained to a CIDR list, bound to a tenant, or limited to specific domains via allowed_domain_ids.
Errors#
All errors share the same envelope. code is stable for programmatic handling; request_id appears in the X-Request-Id header and should be quoted in support tickets.
{
"error": {
"type": "validation_error",
"code": "VALIDATION_ERROR",
"message": "Subject is required",
"request_id": "req_Hg8JpNvKXWg4f",
"errors": [
{ "path": ["subject"], "code": "invalid_type", "message": "Required" }
]
}
}Common codes
| Code | HTTP | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing/invalid/revoked/expired key |
| INSUFFICIENT_SCOPE | 403 | Key lacks required scope |
| IP_NOT_ALLOWED | 403 | Caller IP outside key's allowed_cidrs |
| TENANT_FORBIDDEN | 403 | Tenant-bound key acting on another tenant |
| DOMAIN_NOT_ALLOWED_FOR_KEY | 403 | from domain not in key's allowlist |
| VALIDATION_ERROR | 422 | Schema failed — see error.errors[] |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different body |
| IDEMPOTENCY_IN_FLIGHT | 409 | Replay during 30s lock |
| ALL_SUPPRESSED | 422 | All recipients on suppression list |
| ATTACHMENT_TOO_LARGE | 413 | Inline attachments > 7 MB combined |
| AUDIENCE_TOO_LARGE | 413 | Audience > 10 000 recipients |
| TEMPLATE_MISSING_VARIABLES | 422 | Template var(s) not supplied |
| NOT_SCHEDULED | 409 | DELETE on non-scheduled message |
| RATE_LIMITED | 429 | Burst limit exceeded |
| USAGE_LIMIT_EXCEEDED | 429 | Plan monthly quota reached |
| WARMUP_LIMIT_EXCEEDED | 429 | Domain warmup day cap reached |
| NOT_FOUND | 404 | ID unknown in your org / tenant |
| INTERNAL_ERROR | 500 | Unhandled exception — retry with backoff |
Send email#
Request body
| Parameter | Type | Description | |
|---|---|---|---|
| from | string | required | Sender email (verified domain) |
| to | string[] | required | Recipients (1–50) |
| cc | string[] | optional | CC list (optional) |
| bcc | string[] | optional | BCC list (optional) |
| reply_to | string[] | optional | Reply-To list |
| subject | string | required | Unless supplied by `template` |
| html | string | optional | HTML body |
| text | string | optional | Plain text body |
| tags | string[] | optional | Max 10 |
| metadata | object | optional | Custom string/number/bool values |
| scheduled_at | string | optional | ISO 8601 UTC, future time |
| attachments | object[] | optional | Max 10, combined ≤ 7 MB inline |
| template | string | optional | Template slug |
| variables | object | optional | {{var}} substitutions |
| track_opens | boolean | optional | Default true |
| track_clicks | boolean | optional | Default true |
Example
curl -X POST https://api.sendoka.com/api/v1/emails \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Welcome",
"html": "<h1>Welcome</h1>"
}'await fetch("https://api.sendoka.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDOKA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "hello@yourdomain.com",
to: ["user@example.com"],
subject: "Welcome",
html: "<h1>Welcome</h1>",
}),
});httpx.post(
"https://api.sendoka.com/api/v1/emails",
headers={
"Authorization": f"Bearer {KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Welcome",
"html": "<h1>Welcome</h1>",
},
){
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Welcome",
"html": "<h1>Welcome</h1>"
}Postman: set method POST, URL https://api.sendoka.com/api/v1/emails, Authorization Bearer {{apiKey}}, Body → raw / JSON with the snippet above. Or download the full collection (single + batch + scheduled + template + attachments).
More request shapes
{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"template": "order_shipped",
"variables": {
"order_id": "ord_abc123",
"tracking_url": "https://track.example.com/ord_abc123"
}
}{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Reminder: appointment tomorrow at 9am",
"html": "<p>...</p>",
"scheduled_local": "2026-05-19 09:00",
"scheduled_at_tz": "America/New_York"
}{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"cc": ["manager@example.com"],
"bcc": ["audit@example.com"],
"reply_to": ["support@yourdomain.com"],
"subject": "Welcome",
"html": "<h1>Welcome</h1>"
}{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Your invoice",
"html": "<p>See attached.</p>",
"attachments": [
{
"filename": "invoice.pdf",
"content": "<base64-encoded bytes>",
"content_type": "application/pdf"
},
{
"filename": "receipt.png",
"url": "https://example.com/receipt.png",
"content_type": "image/png"
}
]
}{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Newsletter",
"html": "<p>Visit our <a href=\"https://example.com\">site</a></p>",
"track_opens": true,
"track_clicks": true,
"tags": ["newsletter"]
}Response
{
"id": "msg_01HN...",
"channel": "email",
"status": "sent",
"created_at": "2026-04-22T10:14:22.113Z"
}Send SMS#
| Parameter | Type | Description | |
|---|---|---|---|
| from | string | optional | E.164 sender, or use `from_pool` |
| from_pool | string | optional | Messaging pool id |
| to | string | required | E.164 (e.g. +15551234567) |
| body | string | required | Max 1600 chars |
| media_url | string[] | optional | MMS media URLs |
| tags | string[] | optional | Max 10 |
| metadata | object | optional | Custom key-value pairs |
| scheduled_at | string | optional | ISO 8601 UTC |
| template | string | optional | Template slug |
| variables | object | optional | Substitutions |
Example
curl -X POST https://api.sendoka.com/api/v1/sms \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": "+15551234567",
"to": "+15559876543",
"body": "Your code is 1234"
}'await fetch("https://api.sendoka.com/api/v1/sms", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDOKA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
from: "+15551234567",
to: "+15559876543",
body: "Your code is 1234",
}),
});httpx.post(
"https://api.sendoka.com/api/v1/sms",
headers={
"Authorization": f"Bearer {KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"from": "+15551234567",
"to": "+15559876543",
"body": "Your code is 1234",
},
){
"from": "+15551234567",
"to": "+15559876543",
"body": "Your code is 1234"
}Postman: set method POST, URL https://api.sendoka.com/api/v1/sms, Authorization Bearer {{apiKey}}, Body → raw / JSON with the snippet above. Or download the full collection (single + batch + scheduled + MMS + pool + 10DLC provisioning).
Sender kinds
from can be an E.164 number (US 10DLC, US/CA toll-free) or an alphanumeric sender ID (UK, AU, most of EU). Both have to be registered up front via POST /v1/phone-numbers:
// Register an alphanumeric sender (UK / AU / EU)
POST /api/v1/phone-numbers
{
"kind": "alphanumeric",
"sender_id": "Acme",
"iso_country": "GB"
}No AWS provisioning step — the sender ID is usable on the next send.iso_country in US / CA / IN is rejected (use 10DLC, toll-free, or DLT respectively).
More request shapes
{
"from": "+15551234567",
"to": "+15559876543",
"template": "order_shipped",
"variables": {
"order_id": "ord_abc123",
"tracking_url": "https://track.example.com/ord_abc123"
}
}{
"from": "+15551234567",
"to": "+15559876543",
"body": "Reminder: appointment tomorrow at 9am",
"scheduled_local": "2026-05-18 09:00",
"scheduled_at_tz": "America/New_York"
}{
"from_pool": "support",
"to": "+15559876543",
"body": "Your support ticket has an update."
}{
"from": "+15551234567",
"to": "+15559876543",
"body": "Receipt attached. Reply STOP to opt out.",
"media_url": ["https://example.com/receipt.png"],
"tags": ["transactional", "receipt"],
"metadata": {
"order_id": "ord_abc123",
"user_id": "usr_42"
}
}Response
{
"id": "msg_01HN...",
"channel": "sms",
"status": "sent",
"created_at": "2026-04-22T10:14:22.113Z"
}Batch send#
Up to 100 messages per call. One round-trip; per-row success/failure in the response.
SMS — two shapes
[
{ "from": "+15555550100", "to": "+15555550199", "body": "Personalized 1", "tags": ["promo"] },
{ "from": "+15555550100", "to": "+15555550200", "body": "Personalized 2", "tags": ["promo"] }
]{
"from": "Ezzeefy",
"to": ["+61470594555", "+61470594556", "+61470594557"],
"body": "Sale ends tonight",
"tags": ["promo"],
"metadata": { "campaign": "sale_q2" }
}Email — two shapes
[
{ "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>" }
]{
"from": "hello@yourdomain.com",
"to": ["a@example.com", "b@example.com", "c@example.com"],
"subject": "Newsletter",
"html": "<h1>This month's update</h1>",
"reply_to": ["support@yourdomain.com"],
"tags": ["newsletter"],
"metadata": { "campaign": "may_2026" }
}Bulk shape fans out to per-recipient envelopes — each recipient gets their own message id, tracking pixel, and unsubscribe header (recipients don't see each other). Per-item shape preserves multi-recipient TO/CC/BCC headers if you need them.
Response
{
"data": [
{ "index": 0, "id": "msg_...", "status": "sent", "error": null },
{ "index": 1, "id": null, "status": "failed",
"error": { "code": "VALIDATION_ERROR", "message": "..." } }
],
"total": 2,
"succeeded": 1,
"failed": 1
}Batch doesn't support idempotency keys — use single-send with Idempotency-Key for at-most-once semantics. For 10k+ recipients use audiences below.
Scheduled sends#
Include scheduled_at (ISO 8601 UTC, future). The message is stored with status: "scheduled" and fired by a minute-granularity cron.
{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Launch day",
"html": "<p>...</p>",
"scheduled_at": "2026-05-01T12:00:00Z"
}Recipient-timezone scheduling:
{
"scheduled_local": "09:00",
"scheduled_at_tz": "America/New_York"
}Cancel scheduled#
Flips a scheduled message to canceled. Returns 409 NOT_SCHEDULED otherwise.
Templates#
Slug-referenced content with {{variables}}. Manage in the dashboard; reference on send.
{
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"template": "welcome-v2",
"variables": { "name": "Fareed", "plan": "Pro" }
}Missing variables return 422 TEMPLATE_MISSING_VARIABLES. Subject/html/text can come from the template or be overridden per-call.
Audiences#
For bulk sends to curated lists (newsletters, announcements). Sendoka chunks inserts by serialized byte count to stay under Neon's 1 MB statement cap, drops suppressed addresses, and fans out webhooks asynchronously.
// POST /audiences/:id/send
{
"from": "news@yourdomain.com",
"template": "newsletter",
"channel": "email"
}
// → 200
{ "audience_id": "aud_...", "queued": 23417, "skipped_suppressed": 112, "status": "queued" }Max 10 000 recipients per send. Larger lists should be split.
Message status#
Full message object with delivery status and (for bounces) provider diagnostics.
Status values
queued — accepted, internal flight state
scheduled — waiting for scheduled_at
sent — handed to provider
delivered — provider confirmed
bounced — rejected by recipient mail server / carrier
failed — provider error before send
canceled — scheduled send canceled
List messages#
| Parameter | Type | Description | |
|---|---|---|---|
| limit | number | optional | 1–100 (default 20) |
| cursor | string | optional | From `next_cursor` |
| status | string | optional | Filter by status |
| tag | string | optional | Filter by tag |
{
"data": [ ... ],
"has_more": true,
"next_cursor": "eyIyMDI2..."
}Cursor is base64url of {created_at}|{id}. Never rely on idalone — nanoid isn't monotonic.
Webhooks#
Configure endpoints in the dashboard or via API. Every event is signed with your endpoint's secret.
Headers on every delivery
X-Sendoka-Event: message.delivered
X-Sendoka-Delivery-Id: whd_01HN...
X-Sendoka-Signature: t=1713820800,v1=<hex>Verify the signature (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody: string, signature: string, secret: string) {
const [tPart, vPart] = signature.split(",");
const t = tPart.split("=")[1];
const v = vPart.split("=")[1];
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error("stale");
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v, "hex"), Buffer.from(expected, "hex"))) {
throw new Error("bad signature");
}
return JSON.parse(rawBody);
}Non-2xx responses and timeouts are retried with exponential backoff (5 attempts: ~1s, 5s, 25s, 125s, 625s). Return 200 within 10s — do slow work in a queue.
Event catalog#
Envelope:
{
"event": "message.delivered",
"data": {
"message_id": "msg_01HN...",
"channel": "email",
"status": "delivered"
},
"timestamp": "2026-04-22T10:14:22.113Z"
}Events
| Event | When it fires |
|---|---|
| message.sent | Provider (SES/SNS) accepted the send. Does not imply delivery. |
| message.delivered | Recipient mail server / carrier confirmed acceptance. |
| message.bounced | Rejected. Address is auto-suppressed (tenant-scoped in platform mode). |
| message.failed | Provider error pre-send, or SMS STOP / complaint. |
At-least-once delivery — dedup on X-Sendoka-Delivery-Id. Ordering not guaranteed across events for the same message.
Idempotency#
Pass Idempotency-Key: {uuid} on POST requests. Replays within 24h return the original response. Body must match (hashed).
curl -X POST https://api.sendoka.com/api/v1/emails \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Idempotency-Key: welcome:user_12345:signup" \
-H "Content-Type: application/json" \
-d '{ ... }'409 IDEMPOTENCY_MISMATCH — same key, different body
409 IDEMPOTENCY_IN_FLIGHT — replay landed during the 30s lock; retry in ~500ms
Rate limits#
Two layers: per-key burst (sliding window) and per-org monthly quota.
Free — 60/min, 3 000 emails + 500 SMS / month
Pro — 600/min, 10 000 emails + 1 000 SMS / month (overage metered)
Enterprise — 6 000/min, custom quotas
Headers on every response
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
X-RateLimit-Reset: 1713820800
X-RateLimit-Plan: pro
X-RateLimit-Scope: org | keyPlatform mode (multi-tenant)#
Use Sendoka as infrastructure for your own SaaS. Each of your customers becomes a tenant with isolated suppressions, webhooks, domains, templates, audiences, and usage.
// Create tenant
{
"name": "Acme Corp",
"slug": "acme",
"external_ref": "cust_12345"
}Platform-root keys can create tenants and act on any tenant's resources. Tenant-bound keys (tenant_idon the key) see only their slice. Bounces under tenant A don't suppress for tenant B.