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

ccaddress or listoptionalSame shapes as to. Counted with to and bcc toward the recipients in one message.
bccaddress or listoptionalSame shapes as to. Counted with to and cc.
fromobjectoptionalWhen sent, email is required and name is optional. With no from, the address is noreply@ plus the name of the verified sending domain with the smallest id, and the display name is the account name. With no verified domain, the address is noreply@ plus the account slug plus .invalid. That name is reserved, so a mail server rejects it. The call is still accepted, and the failure shows up as a delivery failure. If a domain has been verified by the time delivery runs, the message fails then and is not offered to a server. Once any domain is verified, a From whose host is not one of those domains is a 422 whose body is only a message, with no type: From domain "…" is not verified for this account. Verify it under Domains (TXT ownership), or send From an address on a domain you have already verified.
reply_tostringoptionalOptional email address, a string rather than an object.
subjectstringoptionalWith a template, this string is rendered in place of the template subject. With neither a subject nor a template subject, the stored subject is (no subject).
htmlstringoptionalWith a template, rendered in place of the template HTML. A request with no template, no template_version_id, and neither html nor text is a 422 whose message is "template or html/text body required".
textstringoptionalWith a template, rendered in place of the template text. One of html or text is enough for a raw message.
templatestringunless bodyRequired unless html or text is sent, or template_version_id is set. Slug of a published template, for example auth/password-reset. A locale tries the localised slug first, then the language, then this one. The message records the template version it rendered from. An unknown slug is 404. A template with no published version is 422.
template_version_idintegeroptionalWhen set, that version is rendered and the template slug is not consulted. A version that is not on this account is 404.
localestringoptionalOptional, at most 16 characters, shaped like de, de-AT or pt-BR. With a template slug, welcome and de-AT tries welcome.de-AT, then welcome.de, then welcome. A slug that already ends in a locale is used as written.
applicationstringorg-wideApplication slug the message is attributed to. Required for an org-wide key: omitting it is 422, message "application is required for org-wide keys". A key scoped to one Application uses that Application and ignores this field. An unknown slug is 404. An archived Application is 403 application_archived.
transportstringoptionalName of a transport on this account. When omitted, the Application's default is used, then the account's. A name that is missing or disabled, or no default that can send, is 503, type no_usable_transport, and the message is not queued.
attachmentsarrayoptionalObjects with filename, content (base64) and an optional content_type. Refused with 422 when the plan does not store attachments, or when the count or the size is past the limit. The 202 body includes attachments_captured, the number of rows written.
tagsobjectoptionalStored on the message and returned by list and show as sent. The examples send an object. The validator only checks that the field may be absent, so a JSON array is not refused.
headersobjectoptionalHeader name to string. Reserved names are refused with 422 before the message is queued: date, from, sender, reply-to, to, cc, bcc, message-id, return-path, mime-version, content-type, content-transfer-encoding, content-disposition, and any name starting with x-spoolway-. The error names them. A name that is not a header name, or a value with a line break, is also 422. In-Reply-To and References are allowed.
triggerstringoptionalStored on the message. List and show do not return it.
idempotency_keystring ≤ 255optionalSend-exactly-once protection across retries — details below. Also accepted as an Idempotency-Key header, which is merged in before the 255-character check.
marketingbooleanoptionaltrue for a newsletter, a digest or an announcement. With one recipient it carries a one-click unsubscribe, and an unsubscribe stops marketing mail only. Leave it out for password resets, receipts and other transactional mail: they carry no unsubscribe, and no marketing unsubscribe stops them.

Worked example

curl
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
        }
      }'
PHP
$ch = curl_init("https://spoolway.com/api/v1/messages");
curl_setopt_array($ch, [
  CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("SPOOLWAY_KEY"),
    "Idempotency-Key: pwreset-usr_8812-20260802T1402",
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "template"    => "auth/password-reset",
    "application" => "auth-prod",
    "to"          => ["email" => "[email protected]", "name" => "Casey Reid"],
    "variables"   => ["reset_url" => $resetUrl, "expires_minutes" => 30],
  ]),
]);
$message = json_decode(curl_exec($ch), true); // $message["id"] is a UUID
Node
const res = await fetch("https://spoolway.com/api/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPOOLWAY_KEY}`,
    "Idempotency-Key": "pwreset-usr_8812-20260802T1402",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    template: "auth/password-reset",
    application: "auth-prod",
    to: { email: "[email protected]", name: "Casey Reid" },
    variables: { reset_url: resetUrl, expires_minutes: 30 },
  }),
});
const message = await res.json(); // message.id is a UUID
Python
import os, requests

message = requests.post(
    "https://spoolway.com/api/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": "pwreset-usr_8812-20260802T1402",
    },
    json={
        "template": "auth/password-reset",
        "application": "auth-prod",
        "to": {"email": "[email protected]", "name": "Casey Reid"},
        "variables": {"reset_url": reset_url, "expires_minutes": 30},
    },
).json()  # message["id"] is a UUID
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/messages")
req = Net::HTTP::Post.new(uri, {
  "Authorization"   => "Bearer #{ENV["SPOOLWAY_KEY"]}",
  "Idempotency-Key" => "pwreset-usr_8812-20260802T1402",
  "Content-Type"    => "application/json",
})
req.body = {
  template: "auth/password-reset", application: "auth-prod",
  to: { email: "[email protected]", name: "Casey Reid" },
  variables: { reset_url: reset_url, expires_minutes: 30 },
}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
message = JSON.parse(res.body) # message["id"] is a UUID
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.

over_monthly_quota is true when accepted calls this month are already at the plan's monthly allowance. The send is still accepted. The allowance does not refuse it. attachments_captured is how many attachment rows this accept wrote. 0 means the request sent none, not that an attachment was refused.

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
401 response body
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)
403 response body
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
403 response body
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
404 response body
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.
404unknown application slug
404 response body
response body
{ "message": "No application with slug \"billing\" on this account. Create it in the panel, or omit \"application\" and use a key scoped to one." }
What to do: the slug is not on this account. Create the Application in the panel, or omit application and use a key scoped to one.
422application required for an org-wide key
422 response body
response body
{ "message": "application is required for org-wide keys" }
What to do: an org-wide key must name the Application. A key scoped to one can omit it. There is no type on this body.
422From domain not verified
422 response body
response body
{ "message": "From domain \"mail.example\" is not verified for this account. Verify it under Domains (TXT ownership), or send From an address on a domain you have already verified." }
What to do: verify that domain, or send From an address on a domain this account has already verified. The message names the domain. There is no type.
422validation, with errors
422 response body
response body
{
  "message": "The to field must be an address, an object with an email, or a list of either.",
  "errors": {
    "to": [
      "The to field must be an address, an object with an email, or a list of either."
    ]
  }
}
What to do: message names the problem and errors repeats it per field. There is no type.
409Idempotency-Key reused with a different body
409 response 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
422 response body
response body
{ "message": "Template \"auth/password-reset\": missing 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.
422recipients_per_message
422 response body
response body
{
  "message": "Too many recipients in one message: 101 of a maximum 100 across to, cc and bcc.",
  "type": "limit_exceeded",
  "limit": {
    "dimension": "recipients_per_message",
    "binding": "platform",
    "lift": "Split the send into batches of 100 recipients or fewer. This ceiling is the same one the inbound SMTP shim applies.",
    "limit": 100,
    "used": 101
  }
}
What to do: type limit_exceeded, dimension recipients_per_message. Split the send. The same ceiling applies to the inbound SMTP shim. This is not a retry.
429daily send limit or per-minute rate limit
429 response body
response body
HTTP/1.1 429 · Retry-After: 21600

{
  "message": "Daily send limit exceeded: plan daily send limit — used 1,000 of 1,000 recipients today. Upgrade your plan to raise the daily send cap, or wait until midnight (UTC).",
  "type": "limit_exceeded",
  "limit": {
    "dimension": "daily_send",
    "binding": "plan",
    "lift": "Upgrade your plan to raise the daily send cap, or wait until midnight (UTC).",
    "limit": 1000,
    "used": 1000
  }
}
What to do: honour Retry-After and reuse the same idempotency key on the retry — the send stays exactly-once. See .
429recipient_messages_per_hour
429 response body
response body
HTTP/1.1 429 · Retry-After: 600

{
  "message": "One recipient has already received 60 messages from this account in the last hour, of a maximum 60.",
  "type": "limit_exceeded",
  "limit": {
    "dimension": "recipient_messages_per_hour",
    "binding": "account",
    "lift": "This ceiling exists to catch a retry loop, not to shape normal traffic. If an address legitimately needs more, it can be raised per account.",
    "limit": 60,
    "used": 60
  }
}
What to do: type limit_exceeded, dimension recipient_messages_per_hour. Retry-After is 600 seconds, which can fall inside the hour, so a retry at that instant can be refused again.
503no usable transport
503 response body
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. An Idempotency-Key is scoped to the application. 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.