# Error codes

Every failure returns the same envelope:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "activation_limit_reached",
    "message": "Key already used on 1 device(s).",
    "param": null,
    "hint": "Ask the customer to deactivate a device, or issue a new key.",
    "doc_url": "https://docs.keyforge.gg/errors#activation_limit_reached",
    "request_id": "req_4x54dm5yyvxemf"
  }
}
```

`type` is `invalid_request_error` (4xx) or `api_error` (5xx). `param` names the offending
field for `400`s. Branch on `code`; the message may be reworded.

## 400

| Code | Meaning | Fix |
|---|---|---|
| `invalid_request` | malformed body / bad JSON | send a JSON object |
| `validation_failed` | a field failed a rule | read `param` |
| `unsupported_media_type` | not `application/json` | set the header |
| `payload_too_large` | body > 256 KB | trim metadata |
| `method_not_allowed` | wrong verb | check the spec |
| `unsupported_platform` | store not in the registry | `GET /v1/platforms` for valid ids |
| `unsupported_region` | store does not sell that region | use one of the store's regions |
| `unsupported_version` | `Keyforge-Version` not served | drop the header |

## 401

| Code | Meaning |
|---|---|
| `unauthorized` | no `Authorization` header |
| `invalid_token` | unknown, malformed or revoked token |

## 403

| Code | Meaning |
|---|---|
| `insufficient_scope` | token lacks the scope; `hint` lists the scopes it has |

## 404

| Code | Meaning |
|---|---|
| `not_found` | no such route |
| `product_not_found` | unknown `product_id` or slug |
| `license_not_found` | checksum-valid key that was never issued |
| `license_malformed` | string fits no store's key format | copy the key again, mind the dashes |
| `license_invalid_checksum` | typo: a direct key's checksum block fails | re-read the last block |
| `not_activated` | deactivate for a device that holds no seat |
| `webhook_not_found` | unknown endpoint id |
| `token_not_found` | unknown token id |

## 409

| Code | Meaning | Recovery |
|---|---|---|
| `license_revoked` | key revoked, terminal | issue a replacement |
| `license_expired` | past `expires_at` | sell a renewal |
| `not_yet_releasable` | preorder, release date not reached | deliver now, activate at release |
| `activation_limit_reached` | all seats used | deactivate a device / new key |
| `already_activated` | that device holds a seat | reuse it, or deactivate first |
| `product_sunset` | product retired | pick a live product |
| `product_exists` | admin: duplicate slug | reuse the existing id |
| `key_prefix_taken` | admin: prefix in use | choose another 4-char prefix |
| `region_mismatch` | region-locked product, wrong region | sell the matching region |
| `idempotency_conflict` | same `X-Idempotency-Key`, different body | new key or same body |

## 413 / 415 / 429

`payload_too_large`, `unsupported_media_type`, and `rate_limited` (see
[rate limits](api.md#rate-limits); honour `Retry-After` and `X-Ratelimit-Reset`).

## 5xx

`internal_error` — retry up to twice with backoff; keep `request_id`. Key allocation
collisions surface here as `"Could not allocate a unique key, retry the request."`.

## Validation responses are not errors

`POST /v1/licenses/{key}/validate` returns `200` even for a dead key. Read `valid`
and `reason` instead of catching an exception.
