invalid_request
HTTP 400 retryable: false REST and MCP
The request was authenticated and understood, but a field in it does not satisfy the schema or a hand-written check the route applies before doing any work.
Why it happens
Two different layers produce this code. The outer one is the error handler: a Zod parse failure or a JSON syntax error is caught centrally and converted, and the response carries a details.fields array naming each path that failed, so you do not have to guess which key was wrong. The inner one is a set of explicit checks that a schema cannot express, and those are the ones worth knowing about, because they are where a syntactically valid request still gets refused.
Every condition below was read in the implementation at the file and line given. They are the branches that raise this code, not a summary of what the name suggests.
- The body does not parse against the route's schema, or it is not valid JSON. The response includes details.fields listing the failing paths.
packages/api/src/index.ts:83 - A carriage return or line feed appears in the subject or in any recipient address on a send. This is a header-injection guard and it runs before anything is written.
packages/domain/src/services.ts:1522 - The union of to, cc and bcc, deduplicated, exceeds 25 unique recipients. The cap counts unique addresses, not entries, so the same address in to and cc costs one.
packages/domain/src/services.ts:1526 - A list limit parameter is outside 1 to 100, or is not an integer.
packages/domain/src/services.ts:3141 - Bytes are written to an upload that is not in the staging state, or whose reservation has already expired.
packages/domain/src/services.ts:3645 - The request body could not be read off the wire at all, for a reason other than exceeding the size cap.
packages/api/src/index.ts:74
How to fix it
- Read details.fields on the response. It is a deduplicated list of the failing paths and it is populated for every schema failure, so it names the key rather than describing it.
- Strip CR and LF from the subject and from every recipient address before sending. An address assembled from a display name and a user-supplied string is the usual source.
- Deduplicate recipients yourself before counting against the 25-unique-recipient cap, because the server counts the deduplicated union of to, cc and bcc.
- Do not retry unchanged. This code is not retryable, and the same body will fail the same way.
Why it works this way
The header-injection check rejects the whole request rather than stripping the offending characters. Silently rewriting a subject would mean the message that went out is not the message you asked for, and the caller would have no way to notice.
Messages you will see
The message field is written for a human reading a log and is not a contract
value. Branch on code instead. These are the exact strings this code is sent
with today, listed so that a search for one of them lands here.
Where it appears
These are the documented surfaces that raise it. The REST base is https://api.emailforagents.ai/v1, and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp.
POST /projects/{project_id}/inboxes/{inbox_id}/messagesGET /projects/{project_id}/eventsPUT /projects/{project_id}/uploads/{upload_id}/content
The response
Read request_id off the body or the x-request-id header before
doing anything else. It is the only handle on one specific attempt, and for a 503 it is
the only handle at all, because the underlying detail stays server-side.
HTTP/1.1 400
content-type: application/json
x-request-id: req_...
{
"error": {
"code": "invalid_request",
"message": "The request does not match the required format. Check the fields and JSON syntax.",
"request_id": "req_...",
"retryable": false
}
}
Verified against packages/api/src/index.ts:83. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.