success: false and an HTTP status. Most user-facing failures also carry a stable machine-readable code. Switch on code, never on message. Message text is human-facing prose and is rewritten without notice, while a code is part of the contract.
Error envelope
boolean
required
Always
false on an error. This is the reliable discriminator against the
success envelope, which is { "success": true, "data": … }.string
required
A single human-readable sentence in English. Show it to a developer, log it,
put it in a support ticket. Do not parse it and do not branch on it.
string
A stable
snake_case identifier for the failure. Present on most
business-rule errors and absent on generic ones such as schema validation, a
bad UUID in the path, or a rate limit. Treat a missing code as “handle by
status”.string | number | boolean | string[]
Some errors carry extra scalar or string-array context alongside
code. Only
scalars and arrays of strings are forwarded; nested objects are stripped.Validation errors are a single string
A malformed body or query string is rejected by the schema validator. When several fields fail at once, only the first message is returned, as a plain string. There is no field-level array and nocode.
Because only the first failure is reported, fixing one validation error can
reveal another on the next attempt. Validate client-side against the
documented fields rather than discovering them one round trip at a time.
Unknown properties are rejected
Request bodies and query strings are whitelisted. Sending a property that is not documented for that endpoint returns400 property <name> should not exist and nothing is written. This is why ?page=2 fails on most list endpoints. See Pagination for which endpoints accept it.
Status codes
A
413 is raised while reading the request body, before routing, so that
response does not use the JSON envelope above. Every other status on this page
does.Error codes
Verified codes reachable on the public API, grouped by the endpoint that raises them.Sending identity
Raised byPOST /public/messages/email.
Transactional email is not exempt from the mailbox check. A verified domain
alone is not enough: the exact address you put in
fromEmail must itself be a
confirmed sender address. Enumerate the ones you can use with
GET /public/sender-addresses.SMS identity and credits
Raised byPOST /public/messages/sms and POST /otp/send.
Message content
Raised byPOST /public/messages/email and POST /public/messages/sms. template_not_found is also raised by GET /public/templates/:id/merge-fields.
Profiles
Raised by the profile write endpoints. The message endpoints resolve or create a contact fromto, so they can raise these too, and POST /forms/subscribe raises the first two.
Tags and webhooks
Errors with no code
Some failures are returned as a status and a message only. Branch on the status.When
POST /otp/send fails at the provider, the credit hold is released before
the 503 is returned. You are not charged for a code that was never sent.Handling errors
Branch oncode where one exists, fall back to the status, and treat message as diagnostic text.
Which errors are worth retrying
Retry with backoff
Retry with backoff
429 and 503, plus any 5xx. On 429, wait at least the number of
seconds in the Retry-After header. On 503 from POST /otp/send the
credit hold has already been released, so a retry mints a fresh code and is
safe.Never retry unchanged
Never retry unchanged
Every
400, 401, 403, 404 and 409. These describe the request or the
workspace state, so the same request will fail the same way. Fix the payload,
the credentials, the sending identity or the credit balance first.Retry with care
Retry with care
A
202 from a transactional send means the message was queued, not
delivered. If a request times out on your side after the server accepted it,
a blind retry sends a second message. Check
GET /public/messages for a record before
resending.The X-Request-Id header
Every response carries anX-Request-Id header. Log it alongside failures and quote it in support requests. You can also set the header yourself to correlate your logs with ours: a value of 1 to 64 characters made of letters, digits, - or _ is echoed back unchanged, and anything else is replaced with a generated id.
Rate limits
Two independent buckets apply to every request. A request must pass both.Per-endpoint limits
Where an endpoint sets its own limit, it replaces the default 60 bucket. The per-key bucket still applies on top.
The per-phone limit on
POST /otp/send returns 429 Too many verification requests for this number. It is separate from the request bucket, so it fires even when you are well under 60 per minute.
POST /otp/verify allows at most 5 code attempts per verification. Exceeding that is not a 429: the endpoint answers 200 with { "verified": false, "reason": "too_many_attempts" } and the verification is discarded, so the user needs a fresh code.
Reading the rate-limit headers
Every response carries the current state of both buckets. The per-key bucket uses the same header names with a-tenant suffix.
X-RateLimit-Reset is the seconds remaining in the current window. On a 429 a Retry-After header is added, also in seconds, and named Retry-After-tenant when it was the per-key bucket that tripped. The body is:
Accepted is not delivered
POST /public/messages/email and POST /public/messages/sms return 202 with a messageId as soon as the message is queued. The full pipeline still runs afterwards at the worker: suppression, per-endpoint checks, the SMS credit wallet and the provider handoff. A message can be accepted and then fail, and that failure never appears as an HTTP error on the original request.
Track the outcome with GET /public/messages/:id or subscribe to the delivery webhooks instead of polling.
