policy_denied
HTTP 403 retryable: false REST and MCP
Nothing is wrong with the credential or the request; the inbox's own sending policy evaluated this message and returned a deny decision.
Why it happens
Policy evaluation is a separate stage from permission. It runs against the canonical payload and returns allow, approval or deny, and the response carries the reason codes in details.reason_codes so you can tell which rule fired. The policy is also re-evaluated at commit time, not only at submission: a send that was approved and then waits is re-checked against the policy in force when it commits, and an approval decision that no longer satisfies current policy is refused rather than honoured. The daily recipient cap lands here too, which is worth knowing because the contract declares a separate daily_limit_exceeded code that nothing throws.
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.
- Policy evaluation on submission returns deny. The reason codes are attached to the error as details.reason_codes.
packages/domain/src/services.ts:1546 - Policy re-evaluation at commit returns deny, or returns approval for a send that is not carrying a human approval.
packages/domain/src/services.ts:4280 - The atomic daily recipient reservation finds no headroom. This is the daily cap, and the code it produces is policy_denied rather than the declared-but-unused daily_limit_exceeded.
packages/domain/src/daily-limits.ts:25
How to fix it
- Read details.reason_codes on the response. It names the rules that fired, which is the only way to tell a recipient block from a daily cap from an allow-list miss.
- Check the inbox policy at GET /projects/{project_id}/inboxes/{inbox_id}/policy. Recipient allow rules, recipient block rules and the daily recipient limit all live there.
- If the rule was require-approval rather than a block, submit a draft and let a human release it instead of retrying the direct send. A send carrying no human approval is refused when policy asks for one.
- Do not retry a daily-cap denial on a short backoff. The cap resets on its own schedule and this code is not retryable.
Why it works this way
Re-evaluating at commit rather than trusting the submission decision is what makes approval.invalidated meaningful. An approval granted under one policy does not carry authority into a stricter one, so tightening a policy takes effect on sends that are already in flight.
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}/inboxes/{inbox_id}/policyMCP tool: send_message
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 403
content-type: application/json
x-request-id: req_...
{
"error": {
"code": "policy_denied",
"message": "Workspace policy blocked this send.",
"request_id": "req_...",
"retryable": false
}
}
Verified against packages/domain/src/services.ts:1546. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.