Skip to main content
Every failed request returns JSON with 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.
The body never contains a statusCode or an error field. Both are removed before the response is written. Read the status from the HTTP status line.

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 no code.
Other real examples of that message, verbatim:
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 returns 400 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

Insufficient SMS credits returns two different statuses. POST /public/messages/sms returns 403 with code: "insufficient_credits". POST /otp/send returns 402 with no code. Handle both if you use both endpoints. 402 is not returned anywhere else in the API.
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 by POST /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 by POST /public/messages/sms and POST /otp/send.

Message content

Raised by POST /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 from to, 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 on code where one exists, fall back to the status, and treat message as diagnostic text.

Which errors are worth retrying

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.
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.
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 an X-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.
The 60 per minute bucket is keyed by IP, not by workspace. Several workspaces behind one egress address, such as a shared cloud function or a single NAT gateway, consume the same 60. Under that arrangement the per-key bucket is effectively unreachable and the IP bucket is your real ceiling. Plan bulk work around 60 per minute unless you control the source address.

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:
Back off exponentially with jitter, starting from Retry-After. If you are scanning a large collection, request the maximum page size so the same work costs fewer requests. See Paging reliably.

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.