v1api.sendoka.com

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>"
  }'

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.
bash
Authorization: Bearer sok_live_your_api_key_here

Keys 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.

Read: API keys guide →

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.

json
{
  "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

CodeHTTPMeaning
UNAUTHORIZED401Missing/invalid/revoked/expired key
INSUFFICIENT_SCOPE403Key lacks required scope
IP_NOT_ALLOWED403Caller IP outside key's allowed_cidrs
TENANT_FORBIDDEN403Tenant-bound key acting on another tenant
DOMAIN_NOT_ALLOWED_FOR_KEY403from domain not in key's allowlist
VALIDATION_ERROR422Schema failed — see error.errors[]
IDEMPOTENCY_MISMATCH409Same key, different body
IDEMPOTENCY_IN_FLIGHT409Replay during 30s lock
ALL_SUPPRESSED422All recipients on suppression list
ATTACHMENT_TOO_LARGE413Inline attachments > 7 MB combined
AUDIENCE_TOO_LARGE413Audience > 10 000 recipients
TEMPLATE_MISSING_VARIABLES422Template var(s) not supplied
NOT_SCHEDULED409DELETE on non-scheduled message
RATE_LIMITED429Burst limit exceeded
USAGE_LIMIT_EXCEEDED429Plan monthly quota reached
WARMUP_LIMIT_EXCEEDED429Domain warmup day cap reached
NOT_FOUND404ID unknown in your org / tenant
INTERNAL_ERROR500Unhandled exception — retry with backoff

Read: full error catalog →

Send email#

POSThttps://api.sendoka.com/api/v1/emails

Request body

ParameterTypeDescription
fromstringrequiredSender email (verified domain)
tostring[]requiredRecipients (1–50)
ccstring[]optionalCC list (optional)
bccstring[]optionalBCC list (optional)
reply_tostring[]optionalReply-To list
subjectstringrequiredUnless supplied by `template`
htmlstringoptionalHTML body
textstringoptionalPlain text body
tagsstring[]optionalMax 10
metadataobjectoptionalCustom string/number/bool values
scheduled_atstringoptionalISO 8601 UTC, future time
attachmentsobject[]optionalMax 10, combined ≤ 7 MB inline
templatestringoptionalTemplate slug
variablesobjectoptional{{var}} substitutions
track_opensbooleanoptionalDefault true
track_clicksbooleanoptionalDefault 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>"
  }'

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"
  }
}

Response

json
{
  "id": "msg_01HN...",
  "channel": "email",
  "status": "sent",
  "created_at": "2026-04-22T10:14:22.113Z"
}

Send SMS#

POSThttps://api.sendoka.com/api/v1/sms
ParameterTypeDescription
fromstringoptionalE.164 sender, or use `from_pool`
from_poolstringoptionalMessaging pool id
tostringrequiredE.164 (e.g. +15551234567)
bodystringrequiredMax 1600 chars
media_urlstring[]optionalMMS media URLs
tagsstring[]optionalMax 10
metadataobjectoptionalCustom key-value pairs
scheduled_atstringoptionalISO 8601 UTC
templatestringoptionalTemplate slug
variablesobjectoptionalSubstitutions

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"
  }'

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:

json
// 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"
  }
}

Response

json
{
  "id": "msg_01HN...",
  "channel": "sms",
  "status": "sent",
  "created_at": "2026-04-22T10:14:22.113Z"
}

Batch send#

POSThttps://api.sendoka.com/api/v1/emails/batch
POSThttps://api.sendoka.com/api/v1/sms/batch

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"] }
]

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>" }
]

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

json
{
  "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.

json
{
  "from": "hello@yourdomain.com",
  "to": ["user@example.com"],
  "subject": "Launch day",
  "html": "<p>...</p>",
  "scheduled_at": "2026-05-01T12:00:00Z"
}

Recipient-timezone scheduling:

json
{
  "scheduled_local": "09:00",
  "scheduled_at_tz": "America/New_York"
}

Read: timezone schedules →

Cancel scheduled#

DELETEhttps://api.sendoka.com/api/v1/emails/:id
DELETEhttps://api.sendoka.com/api/v1/sms/:id

Flips a scheduled message to canceled. Returns 409 NOT_SCHEDULED otherwise.

Templates#

Slug-referenced content with {{variables}}. Manage in the dashboard; reference on send.

json
{
  "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.

Read: templates guide →

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.

POSThttps://api.sendoka.com/api/v1/audiences
POSThttps://api.sendoka.com/api/v1/audiences/:id/contacts
POSThttps://api.sendoka.com/api/v1/audiences/:id/send
GEThttps://api.sendoka.com/api/v1/audiences/:id/status
json
// 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.

Read: bulk audience recipe →

Message status#

GEThttps://api.sendoka.com/api/v1/emails/:id
GEThttps://api.sendoka.com/api/v1/sms/:id

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#

GEThttps://api.sendoka.com/api/v1/emails
GEThttps://api.sendoka.com/api/v1/sms
ParameterTypeDescription
limitnumberoptional1–100 (default 20)
cursorstringoptionalFrom `next_cursor`
statusstringoptionalFilter by status
tagstringoptionalFilter by tag
json
{
  "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

text
X-Sendoka-Event: message.delivered
X-Sendoka-Delivery-Id: whd_01HN...
X-Sendoka-Signature: t=1713820800,v1=<hex>

Verify the signature (Node)

ts
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.

Read: verify webhooks recipe →

Event catalog#

Envelope:

json
{
  "event": "message.delivered",
  "data": {
    "message_id": "msg_01HN...",
    "channel": "email",
    "status": "delivered"
  },
  "timestamp": "2026-04-22T10:14:22.113Z"
}

Events

EventWhen it fires
message.sentProvider (SES/SNS) accepted the send. Does not imply delivery.
message.deliveredRecipient mail server / carrier confirmed acceptance.
message.bouncedRejected. Address is auto-suppressed (tenant-scoped in platform mode).
message.failedProvider 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.

Read: full event catalog →

Idempotency#

Pass Idempotency-Key: {uuid} on POST requests. Replays within 24h return the original response. Body must match (hashed).

bash
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

text
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
X-RateLimit-Reset: 1713820800
X-RateLimit-Plan: pro
X-RateLimit-Scope: org | key

Platform 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.

POSThttps://api.sendoka.com/api/v1/tenants
GEThttps://api.sendoka.com/api/v1/tenants/:id
GEThttps://api.sendoka.com/api/v1/tenants/:id/usage
PATCHhttps://api.sendoka.com/api/v1/tenants/:id
DELETEhttps://api.sendoka.com/api/v1/tenants/:id
json
// 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.

Read: first-tenant walkthrough →