402, not 429: what an email API's errors should say to a model
When an agent gets an error, it has to pick one of three things to do next: retry, fix the request, or stop and tell a person. Why a spent plan is a 402, why two 429s mean opposite things, and the day we collapsed a dozen error names into one vocabulary.
When a person hits an API error, they read the message, open the docs and decide. When an agent hits one, the error is the docs. Whatever comes back is all the model has to go on when it picks its next move, and there are only three moves: send the same request again after a pause, change the request, or stop and tell a person.
An error that points at the wrong one of those is worse than a vague error. A vague error makes the agent hesitate. A wrong one makes it act, confidently, in a loop.
The spent plan
A workspace on the Free plan sends its 3,000th email of the month. Free has no overage, so the next send is refused. (Paid plans never stop here; they go into metered overage, because cutting off password resets to protect a margin is the wrong trade.) What status should the refusal be?
The obvious answer is 429. It is a limit, and 429 is the limit code. It is also the code every HTTP client library, and every agent that has read enough of them, treats as "back off and retry". Nothing about a spent plan clears in a few seconds or a few hours. It clears when someone upgrades or the month turns. A 429 here produces a client that retries politely until the first of the month.
So it is a 402:
{
"error": {
"type": "plan_limit_reached",
"message": "The Free plan includes 3,000 emails a month and 3,000 have been sent. Upgrade to keep sending."
}
}The comment next to that line in the send route says the same thing in fewer words: the caller has not sent too fast, they have run out of plan, and retrying will not help while paying will. The same 402 comes back from adding a sending domain when the plan has no room for another, and from approving a held draft that would go past the allowance, where the approval stays pending.
The status is for the library. The type is for the model.
It would be tidy if the status code carried the whole instruction. It cannot, and the clearest case is our own.
We send two different 429s. rate_limited means the key made more requests in a minute than it is allowed. Wait a moment and send the same request again; that is exactly what 429 is for. daily_limit means the key's daily send cap, set by whoever created the key, would be exceeded. That cap is a guardrail on an autonomous sender. It clears at 00:00 UTC or when a person raises it, and an agent that backs off and retries is trying to get around the thing that was put there to stop it.
Both are honest 429s, since both are "too many". They carry opposite instructions. A status code has room for a class of problem, not a decision, so the decision lives in type, and every surface an agent reads spells it out per type. The send tools in our MCP server say 429 daily_limit (this key's daily cap; wait for tomorrow, do not retry now) and 402 plan_limit_reached (the plan's monthly allowance; a person has to upgrade). llms.txt says "Stop; do not retry in a loop." The errors page puts a Retry? column on every row, with four answers: yes after a pause, no because the request needs fixing, no because a person must act, and sometimes, with the row saying when.
That only works if the MCP server gets the type to the model. A bare "Error: request failed" or an HTTP status alone throws away the one field that says what to do, so a failed tool call reads like this:
Error: Template is missing values for: name (HTTP 422 missing_variables; missing: name)Message, status, type and fix, in one line.
One vocabulary, as of 15 September 2026
A type is only worth branching on if it means the same thing everywhere. Until 15 September ours did not, because each route had named its own errors as it was written, and every name made sense locally:
- A taken slug was
409 slug_takenon automations and templates. A taken identifier elsewhere was409 conflict. - A spent plan was
402 plan_limit_reachedwhen sending and402 plan_limitwhen adding a domain. - A
fromdomain with no verified sending domain was403 no_verified_identityon a send and422 no_verified_identityon creating an automation. Same condition, two statuses. - "This record's state does not allow that" had five names:
not_cancelablefor cancelling an email that was no longer scheduled,in_flightfor deleting a campaign mid-send,already_decidedfor an approval someone else had decided,identity_missingfor approving a draft whose domain had been deleted, andinvalid_requestfor editing a revoked key.
None of this bothers a person reading one endpoint's docs. It is expensive for anything that learns on one route and applies it on the next: every SDK, every retry wrapper, every model. An agent that has learned conflict means "choose another identifier" meets slug_taken and has to guess. And the 403 was actively misleading: 403 says the credential is not allowed, so an agent reasonably concludes it needs a different key, when the key was fine and the domain was never verified.
Now there are 25 types, and each means one thing on every route. A taken identifier is 409 conflict. A state that does not allow the action is 409 invalid_state, and the message names the state. A missing verified domain is 422 no_verified_identity wherever it is sent. An unknown path is 404 not_found with the message "No such API route", so a typo in a URL is distinguishable from a record that does not exist.
The rule that keeps it that way is written down: a new error reuses an existing type, and a new type is added only when a caller must act on it differently from every existing one. When that happens, the OpenAPI description, the errors page and the MCP tool descriptions change in the same commit, because a type that exists in the code and nowhere an agent reads is the old problem again.
The message names the fix
type is stable and safe to branch on. message is for whoever reads it, and it earns its place by saying what to do:
- A 404 for an id that is really a name says so and hands back the id. Passing "Newsletter" where an audience id belongs is the commonest agent mistake, and a bare "No such audience" left models retrying it in a different case.
422 missing_variablesalways carriesmissing, the list of template variables with no value. The fix is in the error.- Validation failures carry
details, one entry per problem with the path of the field at fault.
Those are the only extra fields, and they sit inside error, so a client written against one error parses all of them. (One response, a refused automation enrolment, also puts fields beside error, and the errors page says so.)
What is not an error
The last part is the one agents get wrong most often: responses that look like failure and are not. A key that holds its sends for approval answers 202 with status: "pending_approval". Retrying that does not help; it queues a second draft. A send where every recipient was suppressed answers 202 with skipped: true and a reason, and it will be skipped again. The errors page lists these under their own heading, Not an error, because an agent that treats them as errors does the one thing that makes them worse.
The rule, in one line
An error tells the caller which of three things to do next, the status tells its library the class, the type tells the model the decision, and the same type means the same thing on every route.
