/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
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
Worked example
PHP
Node
Python
Ruby
{
"id": "2ee35675-6bae-42be-aa5e-6d4618d0c61d",
"status": "queued",
"application_id": 1,
"over_monthly_quota": false,
"attachments_captured": 0
}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.
401 response body
{ "message": "Unauthenticated" }{ "message": "Unauthenticated" }403 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"
}{
"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"
}403 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"
}{
"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"
}404 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." }{ "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." }404 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." }{ "message": "No application with slug \"billing\" on this account. Create it in the panel, or omit \"application\" and use a key scoped to one." }422 response body
{ "message": "application is required for org-wide keys" }{ "message": "application is required for org-wide keys" }422 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." }{ "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." }422 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."
]
}
}{
"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."
]
}
}409 response body
{ "message": "Idempotency-Key was already used with a different request body" }{ "message": "Idempotency-Key was already used with a different request body" }422 response body
{ "message": "Template \"auth/password-reset\": missing variables: reset_url, expires_minutes" }{ "message": "Template \"auth/password-reset\": missing variables: reset_url, expires_minutes" }422 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
}
}{
"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
}
}429 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 } }
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 } }
429 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 } }
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 } }
503 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"
}{
"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"
}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.
What happens next
The 202 is where most APIs stop documenting and where this product starts. The message enters your archive under the UUID id from the response — the same id the panel shows — attributed to its Application, and its delivery record fills in as each attempt runs — every attempt, every verbatim server response, the template version it rendered from, and the body under your retention policy. Open Message Log with that id; the log is searchable by anyone you've given a free viewer seat. The same archive is readable over the API: , , and (with a separate key) .