Error reference

permission_denied

HTTP 403 retryable: false REST and MCP

The credential resolved to a valid principal, and that principal's current authority does not include the action you asked for.

Why it happens

Authority is recomputed on every request. The bearer token is a lookup handle, not a claim set: the server re-reads the credential's scopes from current database state and intersects them with the current grant of the human who authorised it, so revoking a person's access takes effect on the next request made by every credential they created. The single most common trigger is the scope check, which compares the required scope against that recomputed set. The most surprising ones are the creation-time checks on new credentials, which refuse to mint authority the creator does not already hold.

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.

How to fix it

  1. Call GET /me and compare the scopes it reports against the one the route needs. The response is the recomputed set, which is the set that will actually be used.
  2. If the credential was fine yesterday, check whether the human who created it lost a role. Authority is intersected with that person's current grant on every request.
  3. When creating a key, request only scopes you hold yourself. The check runs at creation, so a key that was minted successfully never carries a scope its creator lacked.
  4. At the MCP endpoint, complete an OAuth connection grant. An API key cannot be made to work there by adding scopes to it.
  5. For an approval failure, request a fresh approval. An approval is bound to a payload hash, so editing the draft after approval invalidates it by design.

Why it works this way

An API key is rejected at the MCP endpoint by design, and this is the clearest example of the model. A bearer key carries no human behind it, so there is nothing to intersect the per-request authority check against. A connection grant always names the person who authorised it, which is what makes revocation and the per-request recomputation meaningful. Adding scopes to a key would not fix this, because the missing thing is not a scope.

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.

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": "permission_denied",
    "message": "This credential cannot perform that action.",
    "request_id": "req_...",
    "retryable": false
  }
}

Verified against packages/domain/src/authorize.ts:7. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one place a code is mapped to a status.

All error codes · Documentation · Quickstart