Troubleshooting · Permissions

403 Forbidden — the key is fine, and that is exactly the problem

30-SECOND VERSION
  1. Don't rotate the key — 403 means identity already passed; a new key on the same account carries the same permissions, so it cannot help.
  2. Hold the credential, vary the model: retry with a basic model. Basic 200 + target 403 = entitlement. Two requests, thirty seconds.
  3. Every model 403s and it tracks your location = region block. Intermittent and request-specific = endpoint policy.

A 403 means the server read your credential, accepted it, worked out who you are, and then declined the request anyway. Identity already succeeded. So every fix aimed at identity — regenerating the key, re-pasting it, switching header styles, reinstalling the client — is guaranteed to change nothing. 403 is not asking who are you. It is answering you may not do this, and the answer to that does not live in your key.

Reaching for a new key on a 403 is the single most common and most expensive mistake in this family of errors. It costs an hour and it cannot possibly work: a new key issued to the same account carries the same permissions as the old one. Rotating is not a fix, it is re-running the same experiment.

What you're seeing

any of these
API Error: 403 {"type":"error","error":{"type":"permission_error","message":"You do not have permission to use this model"}}

403 Forbidden

{"error":{"message":"You do not have permission to access this resource","type":"permission_error","code":"403"}}

<html><head><title>403 Forbidden</title></head>

The fourth one matters more than it looks. An HTML 403 with no JSON body means the API never saw your request — something in front of it refused you at the edge. A JSON body with "type":"permission_error" means the API did see it, understood it, and said no. Those are different problems with different owners.

401 or 403 — the distinction that decides everything

These two get treated as one problem constantly, and the correct responses are close to opposite.

401 Unauthorized403 Forbidden
What the server decidedit does not know who you areit knows exactly who you are
Your credentialrejected, missing, or malformedaccepted
Does a new key help?yes, if the old one was genuinely badno — same account, same permissions
What changes the outcomethe credentialthe model, region, feature, or account state you asked for
Where the fix livesyour configurationthe account, or the operator's policy
Right page401 Unauthorizedthis one

If you are not certain which one you have, read the body rather than the status line — step 1 below. Endpoints do not always agree with themselves.

The three sources of a 403

They look almost identical in the response and they behave completely differently. The fingerprint column is how you tell them apart without asking anyone.

Where it comes fromTypical messageFingerprintWho can fix it
① Entitlementthe account lacks that model or that featurenames a specific model or featuremodel-specific — a more basic model succeeds with the same credentialthe account owner, or the operator
② Region or compliancewhere the request comes fromgeneric, sometimes mentions region or countrylocation-specific — usually fails for every model, identically, every timethe operator; nothing in your config touches it
③ Endpoint policyconcurrency limits, content policy, account stategeneric; often HTML with no JSON bodyrequest- or time-specific — a different prompt or a later attempt succeedsyou, by changing the request — or the operator

Note what the fingerprints have in common: none of them involve the key. All three are distinguished by varying something other than the credential and watching what changes.

Check in this order

1 · Confirm it is really a 403, and read the body rather than the status line#

terminal
# <model-id>: copy an id from https://api.9coding.com/v1/models
curl -sS -o /tmp/403body.txt -w 'status=%{http_code}\n' https://api.9coding.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "content-type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"<model-id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
cat /tmp/403body.txt

Read the type field, not the number:

  • "type":"permission_error" → a genuine 403. Stay here.
  • "type":"authentication_error" → the status code and the body disagree. Trust the body. This is a credential problem in a 403's coat. Go to 401 Unauthorized.
  • No JSON body at all — HTML, or empty → you were refused before the API layer. Source ③, edge side. Nothing about your model or your key is involved.
  • No status code came back at all → this was never a 403. See connection errors.

2 · The one test that separates ① from ② and ③ — change the model, keep the credential#

This is the whole technique. Hold the credential constant, vary the thing 403 is actually about, and let the two results tell you which source you have.

terminal
# <model-id>: copy an id from https://api.9coding.com/v1/models
for M in "<model-id>" "<a more basic model available on the same endpoint>"; do
  printf '%s -> ' "$M"
  curl -sS -o /dev/null -w '%{http_code}\n' https://api.9coding.com/v1/messages \
    -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
    -H "content-type: application/json" \
    -H "anthropic-version: 2023-06-01" \
    -d "{\"model\":\"$M\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"
done
Basic modelYour modelWhat you have learned
200403① Entitlement. Your credential is valid, your account is active, your network is fine. You simply do not have that model. No configuration change on your machine can grant it
403403Not model-specific → ② or ③. Continue to step 3
401401Wrong page. Go to 401 Unauthorized
200404 / not_found_errorSome endpoints report a model you may not use as not found rather than forbidden, so it does not disclose what exists. See Model not available. (Which of the two shapes you get varies by endpoint — check it against yours.)

The top row is the outcome most people never reach, because they stopped at "403, must be the key" and rotated instead. Two requests, thirty seconds, and you know whether to touch your config at all.

3 · Is it the request rather than the account? (③)#

Keep the model. Strip the request down to nothing.

terminal
# <model-id>: copy an id from https://api.9coding.com/v1/models
curl -sS -o /dev/null -w '%{http_code}\n' https://api.9coding.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "content-type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"<model-id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
  • A bare hi succeeds, your real request 403s → the account is fine and something in the request tripped a policy. Bisect the payload: remove the system prompt, then tools, then attachments, then any unusual parameter — one at a time, re-running after each. The step that flips 403 back to 200 is your answer.
  • The bare request 403s too → nothing about the content matters. Go to step 4.
One timing tell, and it is a reliable one: entitlement does not flicker. A 403 that comes and goes with load, or clears after a pause, is ③ — concurrency or abuse control — not ①. If it is intermittent, stop looking at permissions.

4 · Is it where you are, rather than who you are? (②)#

Run the same credential, same model, from a second network location.

  • Works elsewhere, fails here → ② Region or compliance restriction.
  • Region blocks have a distinctive shape: they hit every model equally, they are immediately reproducible, and they do not respond to anything in your configuration. Compare that with ①, which is selective, and ③, which is intermittent.
  • Judge them by that behaviour, not by the response body. A location refusal can be returned by the API as JSON, or applied at an edge layer that answers with HTML or with nothing at all — the shape varies and is not a reliable discriminator. What is reliable is that it fails for everything, everywhere on this network, every time.

There is nothing to fix on your machine for ②, and whether a given restriction may be routed around is a terms-of-service question rather than a technical one. Read the operator's terms before you spend time on it.

5 · Check which account the credential actually belongs to#

Permission attaches to an account, an organisation, or a project scope — not to the string in your environment. Two keys that look interchangeable can carry entirely different entitlements.

terminal
env | grep -i anthropic

Check every hit, not just the one you remember setting, and check project- or workspace-scoped configuration files as well. A common version of this: a key that works in one repository fails in another, because the second repository has its own configuration file pointing at a different account. The key is not the variable. The account behind it is.

When it isn't your problem

You are looking at the account or the operator's policy — with nothing to fix on your machine — when:

  • it worked earlier today, you changed no configuration, and now every model returns 403,
  • other clients on the same account fail identically,
  • the response body is HTML rather than JSON, meaning the API never received the request,
  • or the failure tracks load rather than tracking what you asked for.

Check the operator's status page, changelog, and your account or billing state — in that order. An endpoint that publishes neither status page nor changelog is one you cannot debug from outside, which is worth knowing before you depend on it. Ours is at status.

When reporting it, include the request id — 9Coding error responses carry one in the form (request id: 2026...). That id plus the timestamp, the model name and the full error text lets the exact call be traced. A report that only says it doesn't work cannot be investigated.

What not to do

Everything in this list is aimed at identity, and identity already passed:

  • Don't regenerate the key. Same account, same permissions, same 403.
  • Don't switch ANTHROPIC_API_KEYANTHROPIC_AUTH_TOKEN. That is a 401-shaped fix; if it changes anything, you had a 401. With 9Coding, set ANTHROPIC_AUTH_TOKEN — the key begins with sk-9c-, and ANTHROPIC_BASE_URL is exactly https://api.9coding.com, no trailing slash and no /v1.
  • Don't retry in a loop. 403 is a decision, not a queue. Retrying is correct for 429 and 529, and pointless here — except in the specific case of ③, where a paced retry after a pause is a diagnostic, not a fix.
  • Don't reinstall the client. The server made this decision; the client just printed it.

Related