BlogEngineering

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.

Daniel Sternlicht6 min read

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_taken on automations and templates. A taken identifier elsewhere was 409 conflict.
  • A spent plan was 402 plan_limit_reached when sending and 402 plan_limit when adding a domain.
  • A from domain with no verified sending domain was 403 no_verified_identity on a send and 422 no_verified_identity on creating an automation. Same condition, two statuses.
  • "This record's state does not allow that" had five names: not_cancelable for cancelling an email that was no longer scheduled, in_flight for deleting a campaign mid-send, already_decided for an approval someone else had decided, identity_missing for approving a draft whose domain had been deleted, and invalid_request for 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_variables always carries missing, 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.

  • engineering
  • agents
  • api

Daniel Sternlicht

Co-founder, SendRaven

Try it

Give your agent an inbox, an outbox, and a seatbelt.

3,000 emails a month free, and an MCP server your agent can use today.

Start freeRead the docs