POST /v1/messages
Docs / Sending
POST

/v1/messages

Ask us to send one email. We render it from a template, tag it with the product that sent it (an Application), and hand it to your own mail server (a transport). One accepted call = one message against your plan; retries of the same message never count again.

Authentication

Authorization: Bearer spw_

Keys are prefixed spw_, belong to a named person, and are shown exactly once. Abilities are chosen at mint and never widen. This endpoint requires messages:send. No key may carry both messages:send and messages:read-content — a leaked sending key can send mail in your name, but it can never open a stored message body.

Parameters

templatestringrequiredTemplate slug, e.g. auth/password-reset. The queued message records the exact version and git sha it rendered from.
applicationstringrequiredApplication slug the message is attributed to. Required for org-wide keys; keys scoped to one Application attribute automatically.
idempotency_keystring ≤ 255optionalSend-exactly-once protection across retries — details below. Also accepted as an Idempotency-Key header.

Worked example

curl -X POST https://spoolway.com/api/v1/messages \
  -H "Authorization: Bearer spw_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pwreset-usr_8812-20260802T1402" \
  -d '{
        "template":    "auth/password-reset",
        "application": "auth-prod",
        "to":          { "email": "[email protected]", "name": "Casey Reid" },
        "variables":   {
          "reset_url":       "https://basalt.app/reset/tk_9f2a81",
          "expires_minutes": 30
        }
      }'
202 Acceptedqueued — the id below is the record's name forever
{
  "id": "2ee35675-6bae-42be-aa5e-6d4618d0c61d",
  "status": "queued",
  "application_id": 1,
  "over_monthly_quota": false,
  "attachments_captured": 0
}
HONESTY NOTE — WHAT THIS CANNOT TELL YOU202 means queued — not sent, not delivered. On a plain SMTP transport the record will progress to Handed to your server and stop there, because that is the last thing anyone can see. "Delivered" appears only when a provider webhook feed reports it, always tagged with its source. See evidence coverage.

The failures, documented like they matter

You'll decide whether to trust this API by reading this list — that's what it's for. Every error is JSON with a human message. Transport failures also carry a stable type of no_usable_transport.

401bad or missing key
response body
{ "message": "Unauthenticated" }
What to do: check the Authorization: Bearer spw_ header (prefix is always spw_); revoked keys stay revoked — mint a new one in the panel.
403key lacks messages:send (or account suspended)
response body
{
  "message": "This API key does not have the \"messages:send\" ability. Abilities are chosen when the key is minted and cannot be widened — mint a new key that includes this ability.",
  "type": "forbidden_ability",
  "required_ability": "messages:send"
}
What to do: mint a new key with the right abilities — abilities are chosen at mint and never widen. A key that can send can never open a stored message body.
403key bound to an archived Application
response body
{
  "message": "This key is bound to the Application \"legacy-mailer\", which is archived. Archived Applications accept no new sends; use a key for a live Application.",
  "type": "application_archived",
  "application": "legacy-mailer"
}
What to do: type application_archived: archived Applications accept no new sends, by key or by slug. Send with a key for a live Application.
404template slug not on this account
response body
{ "message": "No template with slug \"auth/password-resett\" on this account. Sync it from git or create it in the panel before sending against it." }
What to do: confirm the slug exists in this Account — slugs are per-account, not global. Sync from git or create it in the panel.
409Idempotency-Key reused with a different body
response body
{ "message": "Idempotency-Key was already used with a different request body" }
What to do: that's a bug in your retry logic — reuse the original key only with the original body. See .
422missing required variables — all named, not just the first
response body
{ "message": "Missing template variables: reset_url, expires_minutes" }
What to do: the message lists every missing name — one round-trip tells you the whole gap, so you never play validation whack-a-mole.
429daily send limit or per-minute rate limit
response body
HTTP/1.1 429 · Retry-After: 60

{
  "message": "Daily send limit exceeded: plan daily send limit — used 1,000 of 1,000. Upgrade your plan to raise the daily send cap, or wait until midnight (UTC).",
  "type": "limit_exceeded",
  "limit": {
    "dimension": "daily_send",
    "binding": "plan",
    "limit": 1000,
    "used": 1000,
    "lift": "Upgrade your plan to raise the daily send cap, or wait until midnight (UTC)."
  }
}
What to do: honour Retry-After and reuse the same idempotency key on the retry — the send stays exactly-once. See .
503no usable transport
response body
{
  "message": "no_usable_transport: No transport is configured for this application, or all configured transports are disabled. Add or re-enable a transport in the panel, then retry with the same idempotency key. The message was not queued.",
  "type": "no_usable_transport",
  "last_transport_error": "connection refused to smtp.example:587"
}
What to do: the message was not queued — nothing will retry invisibly. Verify the transport in the panel (the Test button shows the verbatim session) and re-send with the same idempotency key.

Idempotency

Send the same Idempotency-Key header (or idempotency_key body field) and the message is created at most once. A replay within the retention window returns the original 202 — same UUID id, same body. Keys are retained for 24 hours, then may be reused. Build the key from your own domain facts: pwreset-usr_8812-20260802T1402 beats a random UUID you can't reconstruct after a crash. Full contract:

Reusing a key with a different body returns a 409 — see the failures above.