@kashdao/cli 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +78 -27
  3. package/dist/{account-CVJZ3HVT.js → account-QHPOIZ6I.js} +9 -9
  4. package/dist/{auth-V5WVZBCO.js → auth-TE7BCN5Q.js} +12 -12
  5. package/dist/{auth-V5WVZBCO.js.map → auth-TE7BCN5Q.js.map} +1 -1
  6. package/dist/{chunk-MQUJG224.js → chunk-2HFKULGG.js} +2 -2
  7. package/dist/{chunk-BVFDUI73.js → chunk-2SR77BYA.js} +10 -9
  8. package/dist/chunk-2SR77BYA.js.map +1 -0
  9. package/dist/chunk-6QSTFU4R.js +9 -0
  10. package/dist/{chunk-KAM6KOFD.js.map → chunk-6QSTFU4R.js.map} +1 -1
  11. package/dist/{chunk-R4NGUZKN.js → chunk-6UU43MF5.js} +5 -3
  12. package/dist/chunk-6UU43MF5.js.map +1 -0
  13. package/dist/{chunk-A3IH5HUV.js → chunk-AROIP4EQ.js} +3 -3
  14. package/dist/chunk-AROIP4EQ.js.map +1 -0
  15. package/dist/{chunk-QALDZHTX.js → chunk-H2QGFDZP.js} +9 -11
  16. package/dist/chunk-H2QGFDZP.js.map +1 -0
  17. package/dist/{chunk-L4AFV2HK.js → chunk-IOLKGV45.js} +3 -3
  18. package/dist/chunk-IOLKGV45.js.map +1 -0
  19. package/dist/{chunk-KADB3OKE.js → chunk-VFBQVUL4.js} +2 -2
  20. package/dist/{chunk-X2VM6VKM.js → chunk-WL7XUYPH.js} +19 -20
  21. package/dist/chunk-WL7XUYPH.js.map +1 -0
  22. package/dist/{chunk-N26VGVGB.js → chunk-ZSMI63N3.js} +109 -37
  23. package/dist/chunk-ZSMI63N3.js.map +1 -0
  24. package/dist/client-REDY6K7J.js +14 -0
  25. package/dist/{completion-PK74S3EZ.js → completion-MXTJYK3D.js} +4 -4
  26. package/dist/{config-T5U6E6KU.js → config-TIJXUJ5N.js} +32 -23
  27. package/dist/config-TIJXUJ5N.js.map +1 -0
  28. package/dist/{config-store-UTFDUCKW.js → config-store-INJY4DDT.js} +6 -4
  29. package/dist/{docs-VA7CJ2ZF.js → docs-M5RZAQ6P.js} +5 -5
  30. package/dist/{eoa-RL3NLN3H.js → eoa-N55DYIN3.js} +18 -16
  31. package/dist/eoa-N55DYIN3.js.map +1 -0
  32. package/dist/{errors-2NNXRNES.js → errors-SMR7X5DU.js} +2 -2
  33. package/dist/{explain-3SPMWDYX.js → explain-HIDEZVLV.js} +31 -8
  34. package/dist/explain-HIDEZVLV.js.map +1 -0
  35. package/dist/{global-options-WFQ6CEFK.js → global-options-YONBFB7N.js} +4 -4
  36. package/dist/{health-2KD6XUTJ.js → health-LSJKP3NH.js} +9 -9
  37. package/dist/index.js +31 -28
  38. package/dist/index.js.map +1 -1
  39. package/dist/{markets-H6EK2XDE.js → markets-AGL6Q2ES.js} +23 -12
  40. package/dist/markets-AGL6Q2ES.js.map +1 -0
  41. package/dist/{output-FGTXXZEQ.js → output-M4NFSWGW.js} +4 -4
  42. package/dist/{portfolio-R63D5QB2.js → portfolio-RBRVUEYP.js} +11 -11
  43. package/dist/{portfolio-R63D5QB2.js.map → portfolio-RBRVUEYP.js.map} +1 -1
  44. package/dist/{protocol-ZB6R25IM.js → protocol-WYQAKN6N.js} +18 -16
  45. package/dist/protocol-WYQAKN6N.js.map +1 -0
  46. package/dist/{quote-OF6BTAPQ.js → quote-6CNHU36A.js} +10 -10
  47. package/dist/redeem-JESSTZ46.js +108 -0
  48. package/dist/redeem-JESSTZ46.js.map +1 -0
  49. package/dist/{schema-3HQN4RFL.js → schema-6EXADLRW.js} +12 -8
  50. package/dist/{schema-3HQN4RFL.js.map → schema-6EXADLRW.js.map} +1 -1
  51. package/dist/{setup-IIPQXLPL.js → setup-H46GTSVW.js} +8 -8
  52. package/dist/{trace-WMO3KDHS.js → trace-VDBWWTYL.js} +10 -10
  53. package/dist/{trade-YBCI6XD2.js → trade-FTSY23QG.js} +10 -10
  54. package/dist/trade-FTSY23QG.js.map +1 -0
  55. package/dist/{version-WB2ACHMU.js → version-JTLF3464.js} +7 -7
  56. package/dist/{version-check-PRAVPFC6.js → version-check-7JGBY6FZ.js} +4 -4
  57. package/dist/{webhooks-DVS55O4T.js → webhooks-JEWNX5HZ.js} +10 -10
  58. package/dist/{webhooks-DVS55O4T.js.map → webhooks-JEWNX5HZ.js.map} +1 -1
  59. package/dist/{with-retry-2MD2CNK2.js → with-retry-DYSHNLG7.js} +12 -6
  60. package/dist/with-retry-DYSHNLG7.js.map +1 -0
  61. package/package.json +5 -5
  62. package/dist/chunk-A3IH5HUV.js.map +0 -1
  63. package/dist/chunk-BVFDUI73.js.map +0 -1
  64. package/dist/chunk-KAM6KOFD.js +0 -9
  65. package/dist/chunk-L4AFV2HK.js.map +0 -1
  66. package/dist/chunk-N26VGVGB.js.map +0 -1
  67. package/dist/chunk-QALDZHTX.js.map +0 -1
  68. package/dist/chunk-R4NGUZKN.js.map +0 -1
  69. package/dist/chunk-X2VM6VKM.js.map +0 -1
  70. package/dist/client-75FVHGOH.js +0 -14
  71. package/dist/config-T5U6E6KU.js.map +0 -1
  72. package/dist/eoa-RL3NLN3H.js.map +0 -1
  73. package/dist/explain-3SPMWDYX.js.map +0 -1
  74. package/dist/markets-H6EK2XDE.js.map +0 -1
  75. package/dist/protocol-ZB6R25IM.js.map +0 -1
  76. package/dist/trade-YBCI6XD2.js.map +0 -1
  77. package/dist/with-retry-2MD2CNK2.js.map +0 -1
  78. /package/dist/{account-CVJZ3HVT.js.map → account-QHPOIZ6I.js.map} +0 -0
  79. /package/dist/{chunk-MQUJG224.js.map → chunk-2HFKULGG.js.map} +0 -0
  80. /package/dist/{chunk-KADB3OKE.js.map → chunk-VFBQVUL4.js.map} +0 -0
  81. /package/dist/{client-75FVHGOH.js.map → client-REDY6K7J.js.map} +0 -0
  82. /package/dist/{completion-PK74S3EZ.js.map → completion-MXTJYK3D.js.map} +0 -0
  83. /package/dist/{config-store-UTFDUCKW.js.map → config-store-INJY4DDT.js.map} +0 -0
  84. /package/dist/{docs-VA7CJ2ZF.js.map → docs-M5RZAQ6P.js.map} +0 -0
  85. /package/dist/{errors-2NNXRNES.js.map → errors-SMR7X5DU.js.map} +0 -0
  86. /package/dist/{global-options-WFQ6CEFK.js.map → global-options-YONBFB7N.js.map} +0 -0
  87. /package/dist/{health-2KD6XUTJ.js.map → health-LSJKP3NH.js.map} +0 -0
  88. /package/dist/{output-FGTXXZEQ.js.map → output-M4NFSWGW.js.map} +0 -0
  89. /package/dist/{quote-OF6BTAPQ.js.map → quote-6CNHU36A.js.map} +0 -0
  90. /package/dist/{setup-IIPQXLPL.js.map → setup-H46GTSVW.js.map} +0 -0
  91. /package/dist/{trace-WMO3KDHS.js.map → trace-VDBWWTYL.js.map} +0 -0
  92. /package/dist/{version-WB2ACHMU.js.map → version-JTLF3464.js.map} +0 -0
  93. /package/dist/{version-check-PRAVPFC6.js.map → version-check-7JGBY6FZ.js.map} +0 -0
@@ -2,21 +2,21 @@
2
2
  import {
3
3
  createTable,
4
4
  truncate
5
- } from "./chunk-R4NGUZKN.js";
5
+ } from "./chunk-6UU43MF5.js";
6
6
  import {
7
7
  readGlobals
8
- } from "./chunk-A3IH5HUV.js";
8
+ } from "./chunk-AROIP4EQ.js";
9
9
  import {
10
10
  print,
11
11
  printJson,
12
12
  style
13
- } from "./chunk-MQUJG224.js";
14
- import "./chunk-KADB3OKE.js";
13
+ } from "./chunk-2HFKULGG.js";
14
+ import "./chunk-VFBQVUL4.js";
15
15
  import {
16
16
  CliError,
17
17
  ERROR_CATALOG,
18
18
  lookupErrorCode
19
- } from "./chunk-X2VM6VKM.js";
19
+ } from "./chunk-WL7XUYPH.js";
20
20
 
21
21
  // src/commands/explain.ts
22
22
  import { Command } from "commander";
@@ -32,24 +32,47 @@ var API_ERROR_DOCS = Object.freeze({
32
32
  "API_KEY_REVOKED": '# `API_KEY_REVOKED`\n\n**HTTP status:** 401 \xB7 **Title:** "API key revoked"\n\n## When it fires\n\nThe key is well-formed and recognised, but its `revoked_at` column is set. The auth middleware fails closed.\n\n## Why it happens\n\n- A user (you or a teammate) explicitly revoked the key via Settings \u2192 API Keys \u2192 Revoke.\n- An operator revoked the key via `kash-admin api-keys revoke <id>` (e.g., suspected leak).\n- A security automation revoked it (e.g., per-key velocity anomaly tripped a kill switch).\n\n## How to fix\n\n- If revocation was unintentional, ask the operator who ran it (the audit log records who/when/why).\n- If revocation was intentional and you need to keep working, **issue a new key**. Revoked keys cannot be un-revoked \u2014 that\'s the security property.\n- Update every place the old key was stored (env vars, secret stores, CI configs) before the new key goes live.\n\n## Related codes\n\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) \u2014 key was deleted, not revoked\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) \u2014 key reached its `expires_at` (no operator action)\n',
33
33
  "API_TRADE_PROCESSING_HALTED": '# `API_TRADE_PROCESSING_HALTED`\n\n**HTTP status:** 503 \xB7 **Title:** "Service unavailable" \xB7 **Retry-After:** 300s\n\n## When it fires\n\nThe coarse `API_TRADE_PROCESSING` kill switch is active. Ops uses this to halt all trade-write traffic platform-wide during incidents (e.g., RPC outage, oracle failure, security investigation).\n\nRead endpoints (markets, portfolio, trade lookup) stay available throughout \u2014 only the write path is blocked.\n\n## Why it happens\n\n- Active incident under operator response.\n- Pre-deploy lockdown for a high-risk migration.\n- A security-driven freeze in response to anomaly detection.\n\n## How to fix\n\n- Honour `Retry-After: 300` and back off. Do NOT poll faster \u2014 the kill switch is binary, polling won\'t unblock you sooner.\n- Subscribe to the status page or our incident webhook to know when service resumes.\n- In the meantime, your read paths still work \u2014 surface a "trading temporarily paused" banner in your UI rather than failing silently.\n\n## Related codes\n\n- [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) \u2014 single-route kill switch (more granular than the coarse halt)\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 specific dependency rather than platform-wide\n',
34
34
  "API_VERSION_UNSUPPORTED": '# `API_VERSION_UNSUPPORTED`\n\n**HTTP status:** 410 \xB7 **Title:** "API version unsupported"\n\n## When it fires\n\nYour request sent an `X-Kash-Api-Version` header whose value is not in\n`SUPPORTED_PUBLIC_API_VERSIONS`. The runtime accepts only\ndate-string values that the API has actively published as a supported\ncontract version. Unrecognized values \u2014 typos, future-dated guesses,\nversions long past their sunset window \u2014 return this error.\n\n## Why 410?\n\n`410 Gone` is the right semantic: the version was either once\naccepted (an old date past its 12-month deprecation window) or never\nhas been (a typo, a future date). Either way, sending the request\nagain with the same value will not succeed. `400` would imply the\nclient could fix syntax and retry without changing the value \u2014 which\nis not the case here. `406 Not Acceptable` is closer but is reserved\nfor content-type negotiation.\n\n## Why it happens\n\n- **Typo in the pin** \u2014 sent `2026-04-30` when the canonical version\n is `2026-04-29`.\n- **Pinned to a sunsetted version** \u2014 the date you pinned against\n passed its 12-month `Sunset` window and was removed from the\n supported set.\n- **Pinned to a future version** \u2014 your client was upgraded with a\n version pin that the production API hasn\'t shipped yet (canary\n drift between client release and server deploy).\n- **Multi-valued header confusion** \u2014 a load balancer or proxy\n appended a second `X-Kash-Api-Version` value (`,`-joined) and the\n runtime correctly refuses to silently pick one.\n\n## How to recover\n\nThe response body\'s `metadata.supported` field carries the full list\nof accepted versions, in canonical-default-first order:\n\n```json\n{\n "type": "https://docs.kash.bot/developer-docs/api-errors/API_VERSION_UNSUPPORTED",\n "title": "API version unsupported",\n "status": 410,\n "code": "API_VERSION_UNSUPPORTED",\n "detail": "Requested API version \'2099-01-01\' is not supported. Send no header to use the current default (2026-04-29), or pick a value from the \'supported\' list.",\n "instance": "/v1/markets",\n "requestId": "01HQGY8K2N3X4Z5C6D7E8F9G0H",\n "requested": "2099-01-01",\n "supported": ["2026-04-29"],\n "current": "2026-04-29"\n}\n```\n\nPick a value from `supported` and resend the request. The simplest\nrecovery is to **drop the header** entirely and let the runtime\ndefault to `current` \u2014 that\'s the recommended consumer mode unless\nyou have a specific reason to pin.\n\n## Migration policy\n\nWhen the API ships a new version, the previous version stays in\n`SUPPORTED_PUBLIC_API_VERSIONS` for **12 months** (per AD-15 / RFC\n8594). During that window, every response from the deprecated\nversion carries `Sunset: <date>` and `Deprecation: true` headers so\nyour monitoring can flag the impending expiry well before it hits.\n\nIf you\'re seeing this error on a date that is older than 12 months\nsince its `Sunset` was advertised, that\'s working as intended \u2014 it\'s\ntime to update the pin.\n\n## Best practice\n\nDon\'t pin if you don\'t have to. The default contract (no header)\nruns against the current `PUBLIC_API_VERSION`, and we promise\nadditive, non-breaking changes within a date version. Pin only when\nyou\'re locking down a release for compliance or contract-test\nreasons.\n\nIf you do pin, follow the registry: read\n`https://api.kash.bot/v1/openapi.json` (or the live OpenAPI route)\nand let your client library auto-select the highest version it\nrecognises.\n\n## See also\n\n- `apps/public-api/src/plugins/api-version.ts` \u2014 the negotiation\n plugin source.\n- `packages/constants/src/public-api-version.ts` \u2014 the canonical\n registry.\n- `apps/public-api/openapi/index.json` \u2014 the snapshot index, listing\n every published version.\n- `docs/runbooks/hmac-algorithm-rotation.md` \u2014 example of a version\n bump driven by a security event.\n',
35
+ "CHAIN_NOT_SUPPORTED": '# `CHAIN_NOT_SUPPORTED`\n\n**HTTP status:** 400 \xB7 **Title:** "Chain not supported"\n\n## When it fires\n\nThe request targets a chain the API does not support. This happens in two ways:\n\n1. **A route that performs an on-chain read** (`POST /v1/redemptions`,\n `GET /v1/markets/{id}/quote`) resolved a **market** on a chain the API does not\n support for on-chain reads.\n2. **`POST /v1/competitions/{competitionId}/claim`**, which performs **no on-chain\n read**: the competition runs on a chain whose escrow adapter is not implemented\n in this environment (for example the `evm` / `solana` adapters), so the prize\n claim cannot be recorded.\n\n## Why it happens\n\n- The market (case 1) or the competition (case 2) is on a chain this environment\n does not support.\n\nYour request is well-formed \u2014 this is a chain-support condition, not a client\ninput error, so it is a permanent `400` (retrying will not help). (For the\non-chain-read routes, a _supported_ chain missing a contract address is a\nserver-config fault and returns `500 INTERNAL_ERROR`, not this code.)\n\n## How to fix\n\n- **Trade / redemption / quote routes:** confirm the market\'s chain is one of the\n supported chains (the `detail` lists them).\n- **Competition claim route:** confirm the competition runs on a chain this\n environment supports for prize claims. There is no on-chain read to check here \u2014\n the chain simply has no escrow adapter yet.\n- If you believe the chain should be supported, contact support \u2014 it is a server\n configuration gap, not something the caller can correct.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md)\n',
35
36
  "CLIENT_REQUEST_ID_CONFLICT": '# `CLIENT_REQUEST_ID_CONFLICT`\n\n**HTTP status:** 409 \xB7 **Title:** "Client request id conflict"\n\n## When it fires\n\nThe same `(user_id, clientRequestId)` body pair was used for an earlier trade with a **different body**. The trade row is unique on `(user_id, clientRequestId)` once you opt in.\n\n## Why it happens\n\n- Power-user mistake: `clientRequestId` is a body field with stricter conflict semantics than the HTTP `Idempotency-Key` header. Most callers should use the header; opt into `clientRequestId` only if you specifically want trade-row-scoped dedup.\n- A bug regenerated the body but kept the `clientRequestId` constant.\n\n## How to fix\n\n- For most use cases, drop `clientRequestId` and use only `Idempotency-Key`. See `apps/public-api/README.md` \xA7 Idempotency.\n- If you need `clientRequestId`\'s row-scoped dedup, generate a fresh value per logical operation.\n- The conflicting trade row is referenced in the `detail` field \u2014 fetch it via `GET /v1/trades/:id` to see what was previously submitted.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) \u2014 same idea, header-level\n',
37
+ "COMPETITION_ALREADY_ENTERED": "# `COMPETITION_ALREADY_ENTERED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Already entered this competition\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/join` was called for a group (or user) that already has an entry in this competition.\n\n## Why it happens\n\nEntries are unique per competition \u2014 one per group, one per user. The existing entry may still be in `funding` (not yet fully paid), which is exactly when a caller is most likely to try joining again.\n\n## Why a second entry is refused rather than created\n\nThe entry fee is POOLED. A second entry would split the group's contributions across two pools, and neither might reach the minimum entry fee \u2014 so the group would pay twice and compete in neither.\n\n## How to fix\n\n- Fund the EXISTING entry: `POST /v1/competitions/{competitionId}/contribute` with that entry's `entrantId`.\n- A withdrawn or rejected entry does not block a fresh one; any other state does.\n\n## Related codes\n\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) \u2014 the entry exists but is not accepting money.\n- [`COMPETITION_NOT_GROUP_OWNER`](./COMPETITION_NOT_GROUP_OWNER.md) \u2014 the caller may not create the entry.\n",
38
+ "COMPETITION_ALREADY_IN_ANOTHER_ENTRY": '# `COMPETITION_ALREADY_IN_ANOTHER_ENTRY`\n\n**HTTP status:** 409 \xB7 **Title:** "Already in another entry"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/contribute` was called by someone who is already funding a **different** entry in the same competition.\n\n## Why it happens\n\nOne member funds at most one entry per competition. `contribute_to_competition` refuses when the caller already has a `competition_member_contributions` row against another `entrant_id` in that competition.\n\n## Why it is refused rather than accepted\n\nContribution decides the payout split, and standings are attributed per entrant. A member funding two entries in one competition would hold a share of two competing prize pools, and their trading would be attributable to both \u2014 so the competition could not be scored coherently.\n\n## How to fix\n\n- Contribute to the entry you already belong to. `GET /v1/competitions/{competitionId}` lists your entry.\n- If you meant to move to a different entry, leave the first one before funding another; a contribution already made is not transferable between entries.\n\n## Note for operators\n\nThis used to share `COMPETITION_NOT_ENTRY_MEMBER`\'s code, because both refusals were raised as SQLSTATE `P0002`. That answered "You are not a member of this entry", which is **false** in this case \u2014 the caller _is_ a member, of another entry \u2014 and gave them nothing to act on. The refusal now raises `23505` and has its own code, so the two are distinguishable both in logs and to the caller.\n\n## Related codes\n\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) \u2014 the caller does not belong to the entry at all.\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) \u2014 the entry or competition is not accepting money.\n',
39
+ "COMPETITION_CONTRIBUTION_BELOW_MINIMUM": '# `COMPETITION_CONTRIBUTION_BELOW_MINIMUM`\n\n**HTTP status:** 400 \xB7 **Title:** "Contribution below minimum ticket"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/contribute` was called with an `amountAtomic` below the competition\'s minimum ticket, or with an amount that is not a positive integer.\n\n## Why it happens\n\nA competition may set `min_ticket_atomic` \u2014 the smallest amount any single member may contribute. It exists so that one member cannot buy a whole group in while the rest ride free: every member who is going to share in the winnings must put in at least the ticket.\n\nThe error message names the actual minimum for that competition.\n\n## How to fix\n\n- Read the competition\'s minimum ticket and contribute at least that much.\n- Send `amountAtomic` as an atomic USDC decimal STRING (6 decimals \u2014 `"40000000"` is 40 USDC), never a JSON number. A number silently loses precision above 2^53, and this value decides how a real prize is split.\n\n## Related codes\n\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) \u2014 the entry or competition is not accepting money.\n- [`VALIDATION_FAILED`](./VALIDATION_FAILED.md) \u2014 the body failed schema validation before reaching the contribution logic.\n',
40
+ "COMPETITION_CONTRIBUTION_REF_CONFLICT": "# `COMPETITION_CONTRIBUTION_REF_CONFLICT`\n\n**HTTP status:** 409 \xB7 **Title:** \"Contribution reference already used for a different amount\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/contribute` was called with an `externalRef` that this member has already used for this entry, but with a **different** `amountAtomic`.\n\n## Why it happens\n\n`externalRef` is your idempotency handle for a contribution \u2014 normally the on-chain transfer's identifier. Sending the same ref twice is safe and expected: a retry after a dropped connection re-sends the same ref, and the second call is a no-op that returns the member's unchanged running total. That is the behaviour you want, and it is preserved.\n\nSending the same ref with a _different_ amount is a different thing entirely. It cannot be a replay of the first request, so one of the two is wrong. Crediting the new amount would double-count against a pot that must reconcile with escrow; silently ignoring it would be worse, because the response would say `200` and report a total that does not include the money you believe you just sent. Under pooled funding that total decides how a real prize is split, so a member who thinks they contributed twice as much would expect twice the share.\n\nThe API refuses instead, and the message names both amounts \u2014 the one already recorded and the one requested \u2014 so you can tell which of your two requests actually stands.\n\n## How to fix\n\n- **If the first request was the correct one**, no action is needed: it is already recorded. Read the member's total back from the contribute response or the entry, and continue.\n- **If you meant to contribute an additional amount**, send it under a **new** `externalRef`. Contributions accumulate, so a second ref adds to the member's total rather than replacing it.\n- **If you reused a ref by mistake** (for example a hardcoded or non-unique value in a retry loop), make `externalRef` unique per contribution. It only has to be unique per member per entry, so the same on-chain identifier used by two _different_ members is fine.\n\n## Related codes\n\n- [`COMPETITION_CONTRIBUTION_BELOW_MINIMUM`](./COMPETITION_CONTRIBUTION_BELOW_MINIMUM.md) \u2014 the amount is under the competition's minimum ticket.\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) \u2014 the entry or competition is not accepting money at all.\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) \u2014 you are not a member of the entry you are funding.\n",
41
+ "COMPETITION_ENTRY_NOT_FUNDABLE": "# `COMPETITION_ENTRY_NOT_FUNDABLE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Competition entry not fundable\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/contribute` was called for a competition or an entry that is not accepting money right now.\n\n## Why it happens\n\n- The competition is not an escrow competition. Only `onchain_escrow` competitions pool an entry fee; `sponsored`, `offchain_airdrop` and `none` competitions have nothing to fund.\n- The competition is outside its funding window \u2014 before `submission_opens_at`, or at/after `join_deadline`.\n- The competition has left `published`/`registration` (it has started, been cancelled, or already settled).\n- The entry is no longer accepting contributions: it has been withdrawn, rejected, disqualified or refunded. An entry in `funding` or `joined` accepts them; anything else does not.\n\nNote that a fully-funded entry still accepts contributions \u2014 a member may top up a `joined` entry, which increases their share of any prize. It is the competition's window, not the funding threshold, that closes this door.\n\n## How to fix\n\n- Check the competition's state and `join_deadline` before contributing.\n- If the entry was refunded or withdrawn, create a new entry and fund that instead.\n\nThis is a state conflict, not an authorization failure, and retrying will not help until the state changes.\n\n## Related codes\n\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) \u2014 the caller is not part of this entry.\n- [`COMPETITION_CONTRIBUTION_BELOW_MINIMUM`](./COMPETITION_CONTRIBUTION_BELOW_MINIMUM.md) \u2014 the amount is under the minimum ticket.\n",
42
+ "COMPETITION_NOTHING_TO_REFUND": '# `COMPETITION_NOTHING_TO_REFUND`\n\n**HTTP status:** 404 \xB7 **Title:** "No refundable contribution"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/refund` on a **cancelled** competition where the authenticated caller has no outstanding refundable contribution.\n\n## Why it happens\n\nTwo different situations produce it, and they are deliberately the same answer to you:\n\n1. **You never contributed** to any entry in this competition.\n2. **You have already been refunded.** The amount is resolved as your contribution _net of refunds already paid to you_, so once you have been paid there is nothing left to resolve.\n\nThe second case is what makes the endpoint safe to retry: a repeated call after a successful refund finds nothing to do and returns this code, rather than paying you a second time. It is not an error condition so much as a terminal state.\n\nNote this is scoped to **you**. In a pooled group entry each member is refunded their own contribution independently \u2014 another member still being owed money does not make your already-settled refund outstanding again.\n\n## How to fix\n\n- If you expected a refund, check that the contribution was recorded against the account whose API key you are using. Contributions are attributed to the authenticated caller at the time they are made.\n- If you have already received the refund, no action is needed \u2014 this is the expected response to a repeat call.\n\n## Related codes\n\n- [`COMPETITION_NOT_CANCELLED`](./COMPETITION_NOT_CANCELLED.md) \u2014 the competition is not cancelled, so nothing is refundable yet.\n- [`COMPETITION_REFUND_FAILED`](./COMPETITION_REFUND_FAILED.md) \u2014 a refund was resolved but the write refused it.\n',
43
+ "COMPETITION_NOT_CANCELLED": "# `COMPETITION_NOT_CANCELLED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Competition is not cancelled\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/refund` was called for a competition that is not in the `cancelled` state.\n\n## Why it happens\n\nA refund returns a contributor's own stake, and a stake is only owed back once the competition has been cancelled. Before that:\n\n- A **running** competition still legitimately holds the stake \u2014 that is what entering paid for.\n- A **settled** competition pays out through the prize path, not this one. Use [`POST /v1/competitions/{id}/claim`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md).\n\nThe state gate lives in the resolver, not just in the route, so this endpoint cannot become a general withdrawal hatch on a live competition.\n\n## How to fix\n\n- Read the competition's `state`. If it is `running`, `registration` or `published`, there is nothing to refund yet.\n- If it is `settled` or `paid_out`, claim the prize instead.\n- Competitions are cancelled automatically when the entrant threshold is unmet at the join deadline, or by an admin. Once that happens the refund becomes available with no further action from you.\n\n## Related codes\n\n- [`COMPETITION_NOTHING_TO_REFUND`](./COMPETITION_NOTHING_TO_REFUND.md) \u2014 the competition IS cancelled, but you have nothing outstanding.\n- [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md) \u2014 the mirror-image refusal on the claim path.\n",
44
+ "COMPETITION_NOT_ENTRY_MEMBER": "# `COMPETITION_NOT_ENTRY_MEMBER`\n\n**HTTP status:** 403 \xB7 **Title:** \"Not a member of this entry\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/contribute` was called by someone who does not belong to the entry they tried to fund.\n\n## Why it happens\n\n- For a **group** entry, the caller is not in `group_members` for that group.\n- For an **individual** entry, the caller is not the entrant.\n\nThe member is always the AUTHENTICATED caller \u2014 it is never read from the request body \u2014 so this cannot be worked around by naming a different user.\n\n## Why it is refused rather than accepted\n\nContribution decides the PAYOUT SPLIT. A winning group's prize is divided in proportion to what each member contributed, so allowing a stranger to pay in would buy them a share of that group's prize.\n\n## How to fix\n\n- Join the group before contributing to its entry.\n- Confirm the `entrantId` in the request body is the entry you meant \u2014 funding another group's entry by mistake produces this error.\n\n## Related codes\n\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) \u2014 the entry or competition is not accepting money.\n",
45
+ "COMPETITION_NOT_FOUND": '# `COMPETITION_NOT_FOUND`\n\n**HTTP status:** 404 \xB7 **Title:** "Competition not found"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/claim` was called with a `competitionId` that does not correspond to any competition.\n\n## Why it happens\n\n- The `competitionId` path parameter is a well-formed UUID but names no competition (typo, wrong environment, or a draft that was deleted).\n\n## How to fix\n\n- Verify the `competitionId`. It must be the UUID of a real competition you entered and that has settled.\n\n## Related codes\n\n- [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md), [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md)\n',
46
+ "COMPETITION_NOT_GROUP_OWNER": "# `COMPETITION_NOT_GROUP_OWNER`\n\n**HTTP status:** 403 \xB7 **Title:** \"Not the group owner\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/join` was called to enter a group the caller does not own, or to enter an individual entry on someone else's behalf.\n\n## Why it happens\n\n- **Group entry:** only a member with the `owner` role may enter the group into a competition. A plain member cannot.\n- **Individual entry:** the entrant must be the caller themselves.\n\n## Why entering and funding differ\n\nEntering is an act of the group; funding is an act of a member. Any member may pay into an existing entry (see [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md)), but only the owner may commit the group to a competition in the first place \u2014 the entry occupies one of the competition's limited slots and binds the whole roster.\n\n## How to fix\n\n- Ask a group owner to create the entry, then contribute to it.\n- For an individual entry, call as the entrant.\n\n## Related codes\n\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) \u2014 about funding an entry, not creating one.\n- [`COMPETITION_ALREADY_ENTERED`](./COMPETITION_ALREADY_ENTERED.md) \u2014 an entry already exists.\n",
47
+ "COMPETITION_NOT_SETTLED": '# `COMPETITION_NOT_SETTLED`\n\n**HTTP status:** 409 \xB7 **Title:** "Competition not settled"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/claim` was called for a competition that has not reached a claimable state. Prizes can only be claimed once winners have been declared.\n\n## Why it happens\n\n- The competition is still `published`, `registration`, `running`, or `scoring_frozen` \u2014 winners have not been declared yet.\n- The competition was `cancelled`; there is no prize to claim (paid entrants are refunded through the refund path, not this one).\n\n## How to fix\n\n- Wait until the competition is `settled` (or `paid_out`). The keeper declares winners automatically once scoring is frozen and the pot is funded.\n- Poll the competition\'s state before attempting a claim.\n\n## Related codes\n\n- [`COMPETITION_NOT_FOUND`](./COMPETITION_NOT_FOUND.md), [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md)\n',
48
+ "COMPETITION_PRIZE_AUTO_DISTRIBUTED": '# `COMPETITION_PRIZE_AUTO_DISTRIBUTED`\n\n**HTTP status:** 409 \xB7 **Title:** "Competition prize auto-distributed"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/claim` was called for a competition whose\nprizes are **distributed automatically** \u2014 settlement mode `offchain_airdrop` or\n`none`. Only escrow/sponsored competitions (`onchain_escrow`, `sponsored`) are\npull-claimed through this endpoint.\n\n## Why it happens\n\n- The competition settles in an auto-distribution mode, so there is no prize to\n pull-claim: winnings arrive through the automatic distribution path rather than\n a caller-initiated claim.\n\nThis is a state conflict on the competition, not an authorization failure about\nthe caller (that would be `403 COMPETITION_PRIZE_NOT_CLAIMABLE`), so it is a\npermanent `409` \u2014 retrying will not help.\n\n## How to fix\n\n- Nothing to do on the claim endpoint. Your prize, if any, is distributed\n automatically for this competition; check your balance / payout history rather\n than re-claiming.\n\n## Related codes\n\n- [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md), [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md)\n',
49
+ "COMPETITION_PRIZE_CLAIM_FAILED": '# `COMPETITION_PRIZE_CLAIM_FAILED`\n\n**HTTP status:** 409 \xB7 **Title:** "Competition prize claim failed"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/claim` authorised the claim and dispatched it to the escrow ledger, but the underlying payout landed (or remains) in a `failed` state. No prize was disbursed.\n\n## Why it happens\n\n- A prior payout attempt for this exact `(competition, entrant, kind, round)` recorded a terminal `failed` state and still occupies the idempotency slot. It cannot be re-attempted through the API.\n\n## How to fix\n\n- Contact support. An operator must clear the failed payout row before a fresh claim can succeed. Retrying the request will not change the outcome.\n\n## Related codes\n\n- [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md), [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md)\n',
50
+ "COMPETITION_PRIZE_NOT_CLAIMABLE": "# `COMPETITION_PRIZE_NOT_CLAIMABLE`\n\n**HTTP status:** 403 \xB7 **Title:** \"Competition prize not claimable\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/claim` was called and the caller cannot\nclaim in the competition's active settlement, for one of two **user-specific**\nreasons. This code deliberately does not distinguish them so a caller cannot\nenumerate another entrant's standing.\n\n## Why it happens\n\n**1. The caller owns no winning entrant.**\n\n- The authenticated user did not win the competition (their entrant is not in the\n settlement's winner set).\n- The winning entrant is a **group** entrant and the authenticated user is not\n that group's owner. Only the group owner may claim a group's prize.\n- A re-settlement superseded a prior round in which the user won; only the active\n (non-superseded) settlement's winners are payable.\n\n**2. The caller owns MORE THAN ONE winning entrant** in the active settlement (a\n`user` entry plus an owned `group`, or two owned groups). The endpoint refuses\nrather than pay an arbitrary one. An operator records the payouts by hand; the\nprize is not lost, it just cannot be pulled through the API. See the\n`kash-{env}-competition-multiple-owned-winners` alarm and its runbook.\n\n## How to fix\n\n- Confirm you entered the competition and placed in a paying position.\n- If the winning entrant is a group, the claim must be made with an API key\n belonging to the group's owner.\n- If you own several winning entries, contact support \u2014 your prizes are recorded\n manually and the claim endpoint cannot disburse them automatically.\n\n## Related codes\n\n- [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md), [`COMPETITION_PRIZE_AUTO_DISTRIBUTED`](./COMPETITION_PRIZE_AUTO_DISTRIBUTED.md), [`COMPETITION_PRIZE_CLAIM_FAILED`](./COMPETITION_PRIZE_CLAIM_FAILED.md)\n",
51
+ "COMPETITION_REFUND_FAILED": '# `COMPETITION_REFUND_FAILED`\n\n**HTTP status:** 409 \xB7 **Title:** "Refund could not be recorded"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/refund` resolved an outstanding amount for you, but the payout write then refused to record it.\n\n## Why it happens\n\nThe refund amount is checked **twice, independently**: once when reading what you are owed, and again inside the write, which bounds the payout to your own recorded contribution. This code means those two disagreed.\n\nThat should not happen, and it is reported rather than retried on purpose. The write\'s bound is the authority \u2014 it is what guarantees a member of a pooled entry cannot draw down more than they put in \u2014 so when the read and the write disagree the safe action is to pay nothing and say so. Silently retrying could turn a bookkeeping disagreement into a double payment.\n\nThe realistic cause is a concurrent refund for the same contribution landing between the two steps.\n\n## How to fix\n\n- **Retry once.** If a concurrent refund completed, the retry resolves nothing outstanding and returns [`COMPETITION_NOTHING_TO_REFUND`](./COMPETITION_NOTHING_TO_REFUND.md), which means you have been paid.\n- If it persists, do not keep retrying \u2014 the amounts genuinely disagree and an operator needs to reconcile the contribution and payout rows. The response detail carries the amounts the write rejected.\n\n## Related codes\n\n- [`COMPETITION_NOTHING_TO_REFUND`](./COMPETITION_NOTHING_TO_REFUND.md) \u2014 nothing outstanding, including after a successful refund.\n- [`COMPETITION_PRIZE_CLAIM_FAILED`](./COMPETITION_PRIZE_CLAIM_FAILED.md) \u2014 the same shape on the prize-claim path.\n',
52
+ "COMPETITION_SPONSOR_NO_SMART_ACCOUNT": "# `COMPETITION_SPONSOR_NO_SMART_ACCOUNT`\n\n**HTTP status:** 409 \xB7 **Title:** \"Organization has no smart account\"\n\n## When it fires\n\n`POST /v1/competitions/{competitionId}/sponsor` was called by an organization that has no `smart_account_address`, so there is no address to sponsor from.\n\n## Why the address is not taken from the request\n\nA prefund is reclaimable: if the competition is cancelled, `sponsor_reclaim` pays the money back to **whatever address the prefund names**. The attribution therefore decides who can withdraw it, so the address is derived server-side from the caller's organization rather than accepted in the body.\n\n## How to fix\n\n- Provision the organization's smart account, then retry.\n- Confirm the API key belongs to the organization you intend to sponsor from \u2014 the sponsor is always the key's organization.\n\n## Related codes\n\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) \u2014 the competition is not accepting money.\n",
36
53
  "CONFIRMATION_EXPIRED": '# `CONFIRMATION_EXPIRED`\n\n**HTTP status:** 409 \xB7 **Title:** "Confirmation expired"\n\n## When it fires\n\nThe confirmation token is past its `confirmation_expires_at`. High-value trades have a short confirmation window (60 seconds by default; configurable per-tier).\n\n## Why it happens\n\n- A human-in-the-loop confirmation flow took longer than the window allows.\n- A queue/worker latency between `POST /v1/trades` returning the token and the consumer calling confirm.\n\n## How to fix\n\n- Tighten the consumer\'s confirm call \u2014 ideally fire it immediately after receiving the token.\n- For human-in-the-loop flows that need longer, request a per-key window extension (mm tier customers who pipeline trades through human review get longer windows).\n- The trade is automatically cancelled when the window expires \u2014 re-create it to retry.\n\n## Related codes\n\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md)\n',
37
54
  "CONFIRMATION_TOKEN_INVALID": "# `CONFIRMATION_TOKEN_INVALID`\n\n**HTTP status:** 409 \xB7 **Title:** \"Confirmation token invalid\"\n\n## When it fires\n\nThe token you sent in `POST /v1/trades/:id/confirm` doesn't match the stored hash for that trade.\n\n## Why it happens\n\n- Wrong trade id \u2014 the token is one-time and tied to a specific trade.\n- Token was truncated or modified in transit/storage.\n- Replay against a different trade than the one that issued the token.\n\nThe token plaintext is **never persisted server-side** \u2014 only the HMAC. We compare your candidate via `timingSafeEqual` to prevent per-byte timing attacks.\n\n## How to fix\n\n- Re-confirm that the trade id in the URL matches the trade id from the create response.\n- Capture the token verbatim from `confirmation.token` in the create response \u2014 the SDK does this automatically; raw HTTP callers must preserve case and length (43 chars, base64url).\n- If you've lost the token (only shown once in the create response), the trade can't be confirmed \u2014 wait for it to auto-expire and re-create.\n\n## Related codes\n\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md), [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md)\n",
38
55
  "CONFIRMATION_TOKEN_USED": "# `CONFIRMATION_TOKEN_USED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Confirmation token used\"\n\n## When it fires\n\nThe token has already been redeemed. Confirmation tokens are **one-time use** \u2014 the first successful confirm clears the stored hash so a replay can't re-fire the trade.\n\n## Why it happens\n\n- A retry layer that doesn't check the trade's status before re-confirming (most common).\n- Two parallel confirmation attempts from the same consumer (race condition in the caller).\n- A double-click in a human-in-the-loop UI.\n\n## How to fix\n\n- Check the trade's status via `GET /v1/trades/:id` \u2014 if it's anything past `pending_confirmation`, the confirm already succeeded.\n- Make your retry loop idempotent: poll trade status first, only confirm if `status === 'pending_confirmation'`.\n- For UI flows: disable the confirm button immediately on click to prevent double-submission.\n\n## Related codes\n\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md) \u2014 same outcome via different state-check path\n",
39
56
  "DEPENDENCY_UNAVAILABLE": '# `DEPENDENCY_UNAVAILABLE`\n\n**HTTP status:** 503 \xB7 **Title:** "Service unavailable" \xB7 **Retry-After:** 30s\n\n## When it fires\n\nA required external dependency (Postgres, Secrets Manager, Redis, blockchain RPC) is unreachable, and the route can\'t degrade further.\n\nDistinct from [`INTERNAL_ERROR`](./INTERNAL_ERROR.md) (which is "we don\'t know what happened") and [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) (which is "ops disabled this on purpose").\n\n## Why it happens\n\n- Transient infrastructure blip (most common \u2014 clears in seconds).\n- Sustained dependency outage under operator response.\n- A rare cascade where one dependency degraded and another can\'t compensate.\n\n## How to fix\n\n- Honour `Retry-After: 30` and back off with jitter. Most occurrences resolve within one retry.\n- If retries keep failing past 5 minutes, check the status page.\n- The TS SDK handles this automatically (`KashServerError`, retried with backoff up to its retry budget).\n\n## Related codes\n\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) \u2014 ops-driven, not infra-driven\n- [`REQUEST_TIMEOUT`](./REQUEST_TIMEOUT.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\n',
57
+ "DISPUTE_ALREADY_FILED": '# `DISPUTE_ALREADY_FILED`\n\n**HTTP status:** 409 \xB7 **Title:** "Dispute already filed"\n\n## When it fires\n\n`POST /v1/markets/:id/disputes` was called for a market whose current resolution proposal you have already disputed. Each user may file exactly one dispute per proposal \u2014 the `(resolution_request_id, user_id)` unique constraint enforces this at the database level, so concurrent retries resolve to a single row. If your dispute is upheld and the market is proposed again, the new proposal can be disputed.\n\n## Why it happens\n\n- Your client retried a filing whose first attempt actually succeeded (e.g., the response was lost in flight).\n- Two processes sharing the same API key raced to file for the same market.\n\n## How to fix\n\n- Treat this as success-shaped: your dispute exists and is blocking finalization until an operator reviews it. This holds even when your first attempt returned a 503 after recording the dispute \u2014 on this 409 the API re-stages the dispute-filed notification for a still-open dispute, so nothing is lost by stopping your retries here.\n- Fetch the existing dispute \u2014 including its review outcome (`status`, `reviewedAt`, `reviewNote`) \u2014 via `GET /v1/markets/:id/disputes`.\n- There is no amend path; the original `reason` stands. If you have material new evidence, contact support.\n\n## Related codes\n\n- [`MARKET_NOT_DISPUTABLE`](./MARKET_NOT_DISPUTABLE.md), [`RESOURCE_NOT_FOUND`](./RESOURCE_NOT_FOUND.md)\n',
40
58
  "IDEMPOTENCY_KEY_CONFLICT": '# `IDEMPOTENCY_KEY_CONFLICT`\n\n**HTTP status:** 409 \xB7 **Title:** "Idempotency key conflict"\n\n## When it fires\n\nThe same `Idempotency-Key` was used for two requests with **different bodies**. The server refuses to either re-execute (would violate idempotency) or replay the cached response (would mislead the caller about which request "won").\n\n## Why it happens\n\n- Most common: a retry layer regenerated the body on each attempt instead of capturing it once and replaying.\n- A bug in the caller that mutates the body in-place between the request being prepared and the request being sent.\n- Two distinct logical operations accidentally sharing a key (e.g., a global key generator that didn\'t account for concurrency).\n\n## How to fix\n\n- Pin the request body before generating the idempotency key. Either:\n - Capture body + key together in your retry wrapper and re-send the exact pair on each attempt.\n - Generate a fresh key per logical operation (one trade = one key).\n- For genuinely retryable bodies that legitimately change between attempts (e.g., updated timestamps), generate a fresh key per attempt \u2014 that\'s the contract.\n\n## Related codes\n\n- [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md) \u2014 analogous conflict, but on the body-level `clientRequestId` field rather than the HTTP header\n- [`IDEMPOTENCY_KEY_EXPIRED`](./IDEMPOTENCY_KEY_EXPIRED.md) \u2014 key valid earlier, now past TTL\n',
41
59
  "IDEMPOTENCY_KEY_EXPIRED": '# `IDEMPOTENCY_KEY_EXPIRED`\n\n**HTTP status:** 410 \xB7 **Title:** "Idempotency key expired"\n\n## When it fires\n\nThe `Idempotency-Key` you sent matches a row in the idempotency store but that row is past its 24-hour TTL.\n\n## Why it happens\n\n- A retry that took longer than 24 hours to fire (e.g., a job that stalled in a queue and retried days later).\n- An offline-first client that buffered the request locally and only got online after the TTL.\n\nThe 410 (rather than re-executing or returning the cached response) is intentional: by 24 h, the original response is gone and the system gives you a clear "this key is no longer valid" signal so you can decide whether the operation is still desired.\n\n## How to fix\n\n- Generate a fresh `Idempotency-Key` for the retry.\n- Before retrying, check whether the original operation actually completed \u2014 query `GET /v1/trades/:id` (using a `clientRequestId` you stored, if applicable) so you don\'t double-execute.\n- For long-tail retries, prefer the body-level `clientRequestId` field over the header \u2014 it has the same conflict semantics but no TTL.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md), [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md)\n',
42
60
  "IDEMPOTENCY_KEY_FORMAT_INVALID": "# `IDEMPOTENCY_KEY_FORMAT_INVALID`\n\n**HTTP status:** 400 \xB7 **Title:** \"Idempotency key format invalid\"\n\n## When it fires\n\n`Idempotency-Key` header contains a character outside the allowed set: `[A-Za-z0-9_\\-:.]`.\n\n## Why it happens\n\n- The key contained whitespace, a slash, or a non-ASCII character.\n- Control characters (newlines, NULs) sneaked in from a copy-paste.\n- An emoji or Unicode separator accidentally landed in the key.\n\nThe strict allowlist exists because the value flows through structured logs, Redis keys, and PG rows \u2014 non-printable / control characters could poison log shippers or downstream systems that key on the value.\n\n## How to fix\n\n- Stick to UUIDs (`crypto.randomUUID()` in Node, `uuidgen` in shell) \u2014 they're always conforming.\n- If you must derive the key from external input, sanitise: `key.replace(/[^A-Za-z0-9_\\-:.]/g, '')` then check it's still unique.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_TOO_LONG`](./IDEMPOTENCY_KEY_TOO_LONG.md), [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md)\n",
43
61
  "IDEMPOTENCY_KEY_TOO_LONG": '# `IDEMPOTENCY_KEY_TOO_LONG`\n\n**HTTP status:** 400 \xB7 **Title:** "Idempotency key too long"\n\n## When it fires\n\n`Idempotency-Key` header value exceeds the 255-character cap.\n\n## Why it happens\n\n- You concatenated several identifiers into the key (user id + market id + timestamp + nonce) and the result blew past 255 chars.\n- A library generated an unusually long token (e.g., a JWT instead of a UUID).\n\n## How to fix\n\n- Use a UUID v4 (`uuidgen`, `crypto.randomUUID()`) or a ULID \u2014 both are 26\u201336 chars and unique enough, well under the 255 cap.\n- If you need to embed semantic information, hash it: `sha256(your-blob).slice(0, 32)` instead of the raw concatenation.\n- Acceptable character set: `[A-Za-z0-9_\\-:.]` \u2014 see [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md).\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md) \u2014 disallowed characters\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) \u2014 same key, different body\n',
44
- "INSUFFICIENT_BALANCE": "# `INSUFFICIENT_BALANCE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Insufficient balance\"\n\n## When it fires\n\nThe actor's smart-account USDC balance is below the requested `amount`.\n\n## Why it happens\n\n- You haven't funded the account yet.\n- A previous trade consumed more than expected (gas + slippage) and left a sub-threshold balance.\n- The auto-funding pipeline is backed up \u2014 common during very high-throughput windows.\n- Org account: trade is being charged to the user but the org policy expected the org to pay (or vice versa). Check `feePayerType`.\n\n## How to fix\n\n- Check the current balance via `GET /v1/portfolio` \u2014 `usdcBalance` field.\n- For users: fund via the webapp's onramp flow (or transfer USDC into the smart account directly).\n- For orgs: the org admin tops up the org wallet.\n- For staging/sandbox: use the testnet faucet flow.\n\n## Related codes\n\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md), [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md)\n",
62
+ "IDEMPOTENCY_REQUEST_IN_PROGRESS": "# `IDEMPOTENCY_REQUEST_IN_PROGRESS`\n\n**HTTP status:** 409 \xB7 **Title:** \"Idempotency request in progress\"\n\n## When it fires\n\nAnother request carrying the same `Idempotency-Key` is still being processed. The server claimed the key when that request arrived and has no response to replay yet, so it refuses to run the route a second time.\n\nThis is the concurrent sibling of [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md): the key is the same and the body is the same, but the first attempt has not finished.\n\n## Why it happens\n\n- Most common: an HTTP client that retries on its **own** timeout while the original request is still running on the server. The server is usually slower than the client's patience, not stuck.\n- A worker pool that fans the same logical operation out to two workers sharing one key.\n- A previous attempt that died mid-flight. The claim it left behind is honoured for a short window and then becomes available again automatically \u2014 no manual cleanup, and no 24-hour lock-out.\n\n## How to fix\n\n- **Retry with the same key.** Once the first attempt settles you receive its response verbatim, with `Idempotent-Replay: true`. That is the point of the key \u2014 do not generate a fresh one, which would execute the operation a second time.\n- Back off before retrying (a second or two is usually enough) rather than retrying immediately in a tight loop.\n- Raise your client's request timeout above the route's normal latency so the retry is not racing a request that was always going to succeed.\n- If you genuinely want two independent operations, give them two different keys.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) \u2014 same key, **different** body\n- [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md) \u2014 the body-level `clientRequestId` equivalent\n",
63
+ "INSUFFICIENT_BALANCE": "# `INSUFFICIENT_BALANCE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Insufficient balance\"\n\n## When it fires\n\nThe actor lacks the balance the operation needs \u2014 the exact meaning depends on the endpoint:\n\n- **`POST /v1/trades`** \u2014 the smart account's USDC balance is below the requested `amount` (the rest of this page).\n- **`POST /v1/redemptions`** \u2014 the smart account holds **zero** of the outcome token you are trying to claim. The authoritative on-chain `balanceOf` returned 0, so there is nothing to redeem (e.g. the tokens were already moved or redeemed, or the position was never held). For redemptions, ignore the USDC-funding advice below \u2014 check that the account actually holds the outcome token via `GET /v1/portfolio/positions`.\n\n## Why it happens\n\n- You haven't funded the account yet.\n- A previous trade consumed more than expected (gas + slippage) and left a sub-threshold balance.\n- The auto-funding pipeline is backed up \u2014 common during very high-throughput windows.\n- Org account: trade is being charged to the user but the org policy expected the org to pay (or vice versa). Check `feePayerType`.\n\n## How to fix\n\n- Check the current balance via `GET /v1/portfolio` \u2014 `usdcBalance` field.\n- For users: fund via the webapp's onramp flow (or transfer USDC into the smart account directly).\n- For orgs: the org admin tops up the org wallet.\n- For staging/sandbox: use the testnet faucet flow.\n\n## Related codes\n\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md), [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md)\n",
45
64
  "INSUFFICIENT_SCOPE": "# `INSUFFICIENT_SCOPE`\n\n**HTTP status:** 403 \xB7 **Title:** \"Insufficient scope\"\n\n## When it fires\n\nThe key authenticated successfully but lacks one of the scopes the route requires.\n\n## Why it happens\n\n- The key was issued with a narrower scope set than the route needs (e.g., a `markets:read` key calling `POST /v1/trades` which requires `trades:write`).\n- A new endpoint was added that requires a scope your existing key doesn't carry.\n\n## How to fix\n\n- Look up the route's required scopes in `apps/public-api/README.md` \u2192 Authentication \u2192 Scopes table.\n- Issue a new key with the needed scopes (or revoke + re-issue with a broader scope set).\n- Principle of least privilege: don't add scopes you don't actually need \u2014 narrow keys reduce blast radius if leaked.\n\n| Scope | Routes |\n| ----------------- | ----------------------------------------------------- |\n| `markets:read` | `GET /v1/markets*`, `GET /v1/markets/:id/predictions` |\n| `markets:quote` | `GET /v1/markets/:id/quote` |\n| `trades:read` | `GET /v1/trades(/:id)` |\n| `trades:write` | `POST /v1/trades`, `POST /v1/trades/:id/confirm` |\n| `portfolio:read` | `GET /v1/portfolio*` |\n| `webhooks:manage` | webhook URL/secret rotation, replay endpoint |\n| `auth:manage` | self-service key CRUD |\n\n## Related codes\n\n- [`IP_NOT_ALLOWED`](./IP_NOT_ALLOWED.md) \u2014 also 403, but driven by IP allowlist rather than scope\n",
46
65
  "INTERNAL_ERROR": "# `INTERNAL_ERROR`\n\n**HTTP status:** 500 \xB7 **Title:** \"Internal Server Error\"\n\n## When it fires\n\nThe error handler caught something it didn't recognise. The actual cause is logged + Sentry-captured server-side; nothing leaks to the caller.\n\nThis is the catch-all \u2014 any other code in this catalogue is a structured, anticipated failure. `INTERNAL_ERROR` means we hit something we hadn't categorised.\n\n## Why it happens\n\n- A genuinely unexpected condition (the most common reason this fires).\n- A new code path that hasn't been instrumented with a typed error yet \u2014 please report it so we can add a proper code.\n\n## How to fix\n\n- **Always include `requestId` in any support ticket.** It's our handle into the trace, the logs, and the Sentry capture.\n- Retry once \u2014 if it was a transient bug, you may get past it.\n- If it's reproducible from a specific request, tell us the exact request shape (with secrets redacted) and we'll trace it.\n- The TS SDK exposes `requestId` on `KashServerError`; raw HTTP callers should pull it from the `requestId` field on the problem response.\n\n## Related codes\n\nEvery other code in this catalogue is preferable \u2014 if you see `INTERNAL_ERROR` consistently for one operation, the right fix is for us to add a new typed code, not for you to work around it.\n",
47
66
  "IP_NOT_ALLOWED": "# `IP_NOT_ALLOWED`\n\n**HTTP status:** 403 \xB7 **Title:** \"IP not allowed\"\n\n## When it fires\n\nThe key has a non-empty `ip_allowlist` configured AND the request's source IP isn't in it.\n\nEmpty allowlist (the default) means any IP is allowed \u2014 this code only fires when an allowlist is configured AND the caller's IP doesn't match.\n\n## Why it happens\n\n- The key is locked to a specific egress IP (common for MM and enterprise tier keys) and the call originated from elsewhere \u2014 your laptop, a CI runner, a proxy with a different egress.\n- The egress IP changed (cloud provider rotated NAT gateway, your home ISP's dynamic IP rolled).\n\n## How to fix\n\n- Find the request's IP in the `requestId`'s Loki logs \u2014 search `requestId=\"<value>\"`, the structured log carries the source IP.\n- If the new IP is legitimate, add it to the key's allowlist via Settings \u2192 API Keys \u2192 Edit (or `kash-admin api-keys` ops command).\n- If it's not, this code is a successful detection of an exfiltration attempt \u2014 investigate.\n\n## Related codes\n\n- [`INSUFFICIENT_SCOPE`](./INSUFFICIENT_SCOPE.md) \u2014 also 403, scope rather than IP\n",
48
- "MARKET_NOT_FOUND": "# `MARKET_NOT_FOUND`\n\n**HTTP status:** 404 \xB7 **Title:** \"Market not found\"\n\n## When it fires\n\nThe `marketId` (UUID) you passed doesn't match any market.\n\n## Why it happens\n\n- Typo in the market id.\n- Cross-environment confusion: a staging market id sent to production, or vice versa. Markets do not exist across environments.\n- The market was never created \u2014 you may have a placeholder id from documentation rather than a real one.\n\n## How to fix\n\n- Look up real market ids via `GET /v1/markets?status=active` (filterable by status).\n- Cache the id once you've found it; markets are immutable in their identity.\n- If you suspect the market should exist (e.g., you saw it in the webapp), confirm you're hitting the same environment.\n\n## Related codes\n\n- [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md) \u2014 market exists but is FROZEN/RESOLVED\n",
67
+ "MARKET_NOT_DISPUTABLE": '# `MARKET_NOT_DISPUTABLE`\n\n**HTTP status:** 422 \xB7 **Title:** "Market not disputable"\n\n## When it fires\n\n`POST /v1/markets/:id/disputes` was called for a market that has no resolution proposal in a disputable state. A market is disputable exactly while its resolution proposal\'s `settlementStatus` is `proposed` \u2014 i.e. until the proposal is **actually finalized (or cancelled) on-chain**, not merely until the nominal 45-minute window (`finalizableAt`) elapses. The `detail` field says which case you hit:\n\n- **"no pending proposal"** \u2014 no council verdict has been proposed on-chain yet; there is nothing to dispute.\n- **"already finalized" / "already disputed" / "already cancelled"** \u2014 the proposal exists but has moved past `proposed`, so the dispute right has ended.\n\nThe dispute write is atomically gated on the live settlement state: even if the market\'s `resolution` object showed `proposed` when you checked, a filing that races an in-flight finalization is rejected with this code rather than recorded against a settled market.\n\n## Why it happens\n\n- The market is still trading \u2014 resolution hasn\'t been proposed yet.\n- The proposal was already finalized (or cancelled) before your request landed \u2014 possibly between your read of the market state and your POST.\n- The proposal already carries an on-chain dispute (`settlementStatus: disputed`).\n\n## How to fix\n\n- Check the market\'s current settlement state via `GET /v1/markets/:id` \u2014 the `resolution` object carries `settlementStatus` and `finalizableAt` (`null` when no proposal is pending).\n- If `settlementStatus` is `proposed`, retry \u2014 you may have raced a state transition that has since resolved. Note that `finalizableAt` passing does **not** by itself end the dispute right: while `settlementStatus` remains `proposed` (for example when finalization is delayed), filings are still accepted.\n- Once the proposal is finalized, the outcome is final; there is no late-dispute path through the API.\n\n## Related codes\n\n- [`DISPUTE_ALREADY_FILED`](./DISPUTE_ALREADY_FILED.md), [`RESOURCE_NOT_FOUND`](./RESOURCE_NOT_FOUND.md)\n',
68
+ "MARKET_NOT_FOUND": "# `MARKET_NOT_FOUND`\n\n**HTTP status:** 404 \xB7 **Title:** \"Market not found\"\n\n## When it fires\n\nThe `marketId` (UUID) you passed doesn't match any market.\n\n## Why it happens\n\n- Typo in the market id.\n- Cross-environment confusion: a staging market id sent to production, or vice versa. Markets do not exist across environments.\n- The market was never created \u2014 you may have a placeholder id from documentation rather than a real one.\n- **Redemptions only:** the market exists but has no on-chain id yet (it hasn't been created on-chain), so it cannot be redeemed against. `POST /v1/redemptions` reports this as `MARKET_NOT_FOUND` \u2014 wait until the market is live on-chain.\n\n## How to fix\n\n- Look up real market ids via `GET /v1/markets?status=active` (filterable by status).\n- Cache the id once you've found it; markets are immutable in their identity.\n- If you suspect the market should exist (e.g., you saw it in the webapp), confirm you're hitting the same environment.\n\n## Related codes\n\n- [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md) \u2014 market exists but is FROZEN/RESOLVED\n",
49
69
  "MARKET_NOT_TRADEABLE": "# `MARKET_NOT_TRADEABLE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Market not tradeable\"\n\n## When it fires\n\nThe market exists but is in a state that doesn't accept trades \u2014 typically `FROZEN` (admin paused trading) or `RESOLVED` (outcome decided, only redemptions allowed).\n\n## Why it happens\n\n- Market reached its `resolve_time` and froze automatically.\n- An admin froze the market manually (e.g., pending an oracle dispute).\n- The market was resolved between your quote call and your trade call.\n\n## How to fix\n\n- Check the market's current `status` via `GET /v1/markets/:id` before retrying.\n- If the market was just resolved, your existing positions are still redeemable (handled automatically by the payout pipeline) \u2014 you don't need to do anything.\n- For freeze-then-resume scenarios, retry once the market reopens; subscribe to the `market.resumed` webhook (when streaming lands) to know exactly when.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md)\n",
70
+ "ORGANIZATION_INACTIVE": "# `ORGANIZATION_INACTIVE`\n\n**HTTP status:** 403 \xB7 **Title:** \"Organization inactive\"\n\n## When it fires\n\nThe API key is valid, not revoked and not expired, but the organization it belongs to is\n`suspended` or `cancelled`.\n\n## Why it happens\n\n- The organization was suspended (an abuse or admin action).\n- The organization's subscription was cancelled.\n\nThese are the same statuses the Partner Portal refuses on every organization route.\n`trial` and `active` organizations are unaffected.\n\n## How to fix\n\n- Contact support to restore the organization. The key starts working again as soon as the\n organization's status is `trial` or `active`; it does not need to be reissued.\n\n## Related codes\n\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) \u2014 the key itself was withdrawn\n- [`INSUFFICIENT_SCOPE`](./INSUFFICIENT_SCOPE.md) \u2014 also 403, scope rather than organization\n",
50
71
  "OUTCOME_INDEX_INVALID": '# `OUTCOME_INDEX_INVALID`\n\n**HTTP status:** 400 \xB7 **Title:** "Outcome index invalid"\n\n## When it fires\n\n`outcomeIndex` is greater than or equal to the market\'s outcome count, or negative.\n\n## Why it happens\n\n- You hard-coded `outcomeIndex: 1` (assuming a binary market) and pointed at a multi-outcome market.\n- Off-by-one: the field is **zero-indexed** \u2014 first outcome is `0`, not `1`.\n- A bug computed the index from a label without bounds-checking.\n\n## How to fix\n\n- Look up the market via `GET /v1/markets/:id` and check `outcomes.length` \u2014 valid range is `[0, length - 1]`.\n- Map outcome labels to indices once when you fetch the market, then use the index throughout your code.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md), [`VALIDATION_FAILED`](./VALIDATION_FAILED.md)\n',
72
+ "POSITION_NOT_CLAIMABLE": "# `POSITION_NOT_CLAIMABLE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Position not claimable\"\n\n## When it fires\n\n`POST /v1/redemptions` was called for a `(marketId, outcomeIndex)` that is not currently claimable for your key's user. Claimability is decided by the same authority the webapp uses (the `claimable_rewards` view), so an accepted claim is one the payout pipeline can always fulfil.\n\n## Why it happens\n\n- **The outcome did not win.** On a RESOLVED market, only the winning outcome(s) pay out; losing-outcome tokens keep a non-zero on-chain balance but are worthless.\n- **No refund for that outcome.** On a cancelled market, only outcomes with a non-zero cancel price are refundable.\n- **The market is not in self-service mode.** It resolved before self-service redemption was enabled and was paid out automatically.\n- **Already redeemed.** The position has already been paid out (on-chain) or has an in-flight payout.\n- **A previous claim failed.** An earlier claim for this exact position ended in `status='failed'`. It still occupies the one request slot, so a new claim cannot be created through the API until an operator clears it \u2014 the `detail` says so explicitly.\n\n## How to fix\n\n- Redeem the **winning** outcome index. On a cancelled market, redeem an outcome you actually hold that carries a refund.\n- If the market was auto-paid, your USDC is already settled \u2014 check `GET /v1/portfolio`.\n- If you have an **in-flight** (non-failed) claim, a repeat returns `200` with that original request rather than this error.\n- If a previous claim **failed**, contact support \u2014 the stuck request must be cleared before a new one can be made.\n\n## Related codes\n\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md), [`REDEMPTIONS_NOT_ENABLED`](./REDEMPTIONS_NOT_ENABLED.md)\n",
51
73
  "RATE_LIMIT_EXCEEDED": "# `RATE_LIMIT_EXCEEDED`\n\n**HTTP status:** 429 \xB7 **Title:** \"Too Many Requests\"\n\n## When it fires\n\nPer-user (authenticated) or per-IP (anonymous reads) rate limit was exceeded. Limits are enforced as a sliding window in Redis.\n\nFor authenticated requests, the limit is **tier-differentiated** \u2014 60 req/min on `free`, 300 req/min on `developer`, custom (admin-set) on `enterprise` and `mm`. See [`docs/api-tiers.md`](../api-tiers.md) for the full matrix.\n\n## Response headers\n\nEvery 429 response carries:\n\n- `Retry-After: <seconds>` \u2014 wait at least this long before the next request\n- `X-RateLimit-Limit: <n>` \u2014 the limit that applied\n- `X-RateLimit-Remaining: 0` \u2014 confirms you're capped\n- `X-RateLimit-Reset: <unix-timestamp>` \u2014 when the window resets\n\n## Why it happens\n\n- Burst traffic spike (e.g., a polling loop without backoff).\n- A retry storm \u2014 every consumer retried at the same instant after a transient failure.\n- Genuine sustained traffic above your tier's quota.\n\n## How to fix\n\n- Honour `Retry-After`. The TS SDK does this automatically (`KashRateLimitError.retryAfterSeconds`).\n- Add jitter to retry timing so multiple consumers don't synchronise.\n- For polling, switch to webhooks \u2014 every poll is a wasted request.\n- If you consistently bump against the limit during normal operation, upgrade your tier \u2014 see [`docs/api-tiers.md`](../api-tiers.md) for the upgrade paths. The 60 \u2192 300 req/min jump from `free` \u2192 `developer` is 5\xD7, and `enterprise`/`mm` have no application-level cap.\n\n## Related codes\n\n- [`RATE_LIMIT_UNAVAILABLE`](./RATE_LIMIT_UNAVAILABLE.md) \u2014 503; the rate-limit subsystem itself is temporarily unavailable. Distinct from `EXCEEDED`: `UNAVAILABLE` means we couldn't check your quota; `EXCEEDED` means we checked and you're over.\n- [`WEBHOOK_REPLAY_LIMIT_REACHED`](./WEBHOOK_REPLAY_LIMIT_REACHED.md) \u2014 also 429, but specific to the webhook replay endpoint's amplification cap\n",
52
74
  "RATE_LIMIT_UNAVAILABLE": "# `RATE_LIMIT_UNAVAILABLE`\n\n**HTTP status:** 503 \xB7 **Title:** \"Rate limit subsystem unavailable\" \xB7 **Retry-After:** 1s\n\n## When it fires\n\nThe rate-limit subsystem (Redis-backed token bucket / sliding window) is temporarily unavailable, and the per-task circuit breaker is still in its CLOSED state \u2014 i.e., this is a transient Redis blip, not a sustained outage.\n\nThe API fails CLOSED here because your rate limit IS your per-tier quota (a paid product feature). Serving traffic without enforcing the cap during a blip would silently leak quota to free-tier callers. The safe-for-business default is to refuse the request, surface a typed retryable error, and let your client retry.\n\nThis is the **transient blip** counterpart to the sustained-outage [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 same family, different policy: a transient blip in the limiter is fail-CLOSED (we know the limit must apply but can't check), while a sustained outage causes the circuit breaker to open and the API to fail-OPEN (serve traffic without quota enforcement, paged to ops).\n\n## Why it happens\n\n- A momentary ElastiCache Redis network blip \u2014 typically clears in `<100ms`.\n- Pool acquisition contention under burst \u2014 a Lua call queued for a free connection past the per-call timeout.\n- A momentary failover during a Redis cluster maintenance window.\n\nIf Redis stays unhealthy past the circuit breaker's failure threshold (10 consecutive failures), the breaker OPENs and requests start passing through fail-OPEN instead of returning 503. Ops gets paged on `kash_public_api_rate_limit_redis_circuit_state == 2`.\n\n## How to fix\n\n- **Honour `Retry-After: 1`.** The TS SDK's default retry policy already does this \u2014 it auto-retries 5xx responses with exponential backoff, so most blips are invisible to your application code. If you've disabled SDK retries (`maxRetries: 0`), wire your own backoff loop.\n- **Don't treat this as quota exhaustion.** Distinct from [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) (429): `EXCEEDED` is \"you're over your tier's cap\"; `UNAVAILABLE` is \"we couldn't check, so we won't risk leaking your cap.\" Your dashboards / billing UI should NOT surface `RATE_LIMIT_UNAVAILABLE` as a quota event.\n- If you see a sustained rate of these in your client logs (>1/min over several minutes), that's the threshold where Kash ops should already be paged \u2014 file a bug at https://github.com/KashDAO/sdk-typescript/issues with the requestId if you want a status check.\n\n## Response headers\n\n- `Retry-After: 1` \u2014 Stripe-style short retry; SDK respects it automatically.\n- `X-API-Version`, `X-Request-Id` \u2014 standard cross-cutting headers.\n- The `X-RateLimit-*` family is NOT emitted on a 503 (we don't have a quota answer to attach).\n\n## Related codes\n\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) \u2014 429, you went over your quota.\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 503, infrastructure-level dependency outage outside the rate-limit path.\n",
75
+ "REDEMPTIONS_NOT_ENABLED": '# `REDEMPTIONS_NOT_ENABLED`\n\n**HTTP status:** 404 \xB7 **Title:** "Redemptions not enabled"\n\n## When it fires\n\n`POST /v1/redemptions` is called while self-service redemption is not live on the platform \u2014 the `redemption.self-service` feature flag is disabled, so resolved markets are paid out automatically (bulk fan-out) rather than by user-initiated claim.\n\n## Why it happens\n\n- Self-service redemption has not been rolled out to this environment yet (the default). Markets latch to `automated` payout at resolution and are paid without a claim.\n- The feature was rolled back platform-wide.\n\nThis is distinct from the `api-redemptions-create` **kill switch** (a `503` ops lever that halts the endpoint temporarily): `REDEMPTIONS_NOT_ENABLED` means the capability is not enabled at all.\n\n## How to fix\n\n- Nothing to do as an integrator \u2014 your winnings are still paid out automatically once the market resolves. Poll `GET /v1/portfolio` for the settled balance.\n- If you believe self-service redemption should be enabled for your environment, contact support.\n\n## Related codes\n\n- [`POSITION_NOT_CLAIMABLE`](./POSITION_NOT_CLAIMABLE.md)\n',
53
76
  "REQUEST_SIGNATURE_INVALID": '# `REQUEST_SIGNATURE_INVALID`\n\n**HTTP status:** 401 \xB7 **Title:** "Request signature invalid"\n\n## When it fires\n\n`X-Kash-Signature` was present but did not verify against the API key plaintext over the canonical signing input `${ts}.${method}.${path}.${body}`.\n\nSigning is **opt-in**: requests without `X-Kash-Signature` skip this check entirely. Once present, it MUST verify.\n\n## Why it happens\n\n- The body was modified in transit (a transparent proxy that re-encoded JSON, an HTTP client that renormalised whitespace).\n- The timestamp is outside the \xB15 min tolerance (clock skew).\n- The signature was computed over the wrong input \u2014 common mistake: signing the URL instead of the path-only, or omitting the leading slash on `path`.\n- The wrong secret was used (e.g., the `webhook_secret` instead of the API key plaintext).\n\n## How to fix\n\n- Canonical input format: `${unixMillis}.${UPPERCASE_METHOD}.${pathWithLeadingSlash}.${rawBody}` (raw body bytes, not re-serialised JSON).\n- Key the HMAC with the API key plaintext (the same value as `X-API-Key`).\n- Header format: `X-Kash-Signature: t=<unixMillis>,v1=<hexHmacSha256>`.\n- Sync your clock \u2014 NTP. If you\'re consistently 6+ minutes off, fix the host clock.\n- Check the `signatureReason` extension field on the problem response \u2014 it pinpoints which check failed (`stale`, `mismatch`, `malformed`).\n\nSee `apps/public-api/README.md` \xA7 Optional request body signing for the full reference.\n\n## Related codes\n\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) / [`API_KEY_INVALID`](./API_KEY_INVALID.md) \u2014 earlier failure points\n',
54
77
  "REQUEST_TIMEOUT": '# `REQUEST_TIMEOUT`\n\n**HTTP status:** 504 \xB7 **Title:** "Gateway timeout" \xB7 **Retry-After:** 5s\n\n## When it fires\n\nThe per-request server timeout fired (default 30 s). Bounded by `bodyLimit` + Fastify timeout config to keep slow upstreams from blocking healthy traffic.\n\n## Why it happens\n\n- An unusually slow downstream call (Postgres lock contention, RPC latency spike, etc.).\n- A request that does too much work in one call \u2014 most reads complete in <100 ms.\n- Network path degradation between you and our edge (rare but possible).\n\n## How to fix\n\n- Retry once with backoff. Most timeouts are transient.\n- If you\'re consistently timing out on the same endpoint, narrow the request \u2014 pass `limit` parameters, fetch one resource at a time.\n- For long-running operations, use webhooks instead of waiting on the response.\n\n## Related codes\n\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\n',
55
78
  "RESOURCE_NOT_FOUND": "# `RESOURCE_NOT_FOUND`\n\n**HTTP status:** 404 \xB7 **Title:** \"Not Found\"\n\n## When it fires\n\nA request for a specific resource (trade, key, webhook event, etc.) didn't find a matching row that the caller is authorised to see.\n\nThis is the generic catch-all 404. Specific resource types may have their own dedicated codes (e.g., [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md)) where there's value in distinguishing the resource type to the caller.\n\n## Why it happens\n\n- The id is wrong (typo, cross-environment confusion).\n- The resource exists but belongs to a different user \u2014 we return 404 (not 403) deliberately, so attackers can't probe for the existence of resources they don't own.\n- The resource was deleted.\n\n## How to fix\n\n- Re-check the id (capitalisation, hyphens, environment).\n- If the resource should exist but you can't see it, confirm you're authenticated as the owner.\n- For webhook events: events are retained for 90 days; older events return 404.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md) \u2014 specific case for markets\n",
@@ -162,4 +185,4 @@ function formatAction(action) {
162
185
  export {
163
186
  explainCommand
164
187
  };
165
- //# sourceMappingURL=explain-3SPMWDYX.js.map
188
+ //# sourceMappingURL=explain-HIDEZVLV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/explain.ts","../src/api-error-bundle.generated.ts"],"sourcesContent":["/**\n * `kash explain <code>` — translate an error `code` into a structured\n * description, recovery steps, and a docs URL.\n *\n * Designed to be called by AI agents that hit an error with\n * `--json --quiet`, parse the `code` from the envelope, and want\n * deterministic, machine-readable next steps. The same data feeds\n * the human-mode error suggestion.\n *\n * `kash explain` (no argument) lists every code the CLI emits — useful\n * for agents at startup that want to learn the catalog up front.\n */\n\nimport { Command } from 'commander';\n\nimport { API_ERROR_DOCS } from '../api-error-bundle.generated.js';\nimport { ERROR_CATALOG, lookupErrorCode } from '../error-catalog.js';\nimport { CliError, type CliErrorAction } from '../errors.js';\nimport { createTable, truncate } from '../utils/formatting.js';\nimport { readGlobals } from '../utils/global-options.js';\nimport { print, printJson, style } from '../utils/output.js';\n\nconst API_DOCS_URL_BASE = 'https://docs.kash.bot/developer-docs/api-errors';\n\n/**\n * Fallback for API server-side codes that aren't in the hand-curated\n * CLI ERROR_CATALOG. Returns the markdown body bundled at build time\n * from `docs/api-errors/<CODE>.md` so `kash explain MARKET_NOT_TRADEABLE`\n * works the same as `kash explain RATE_LIMITED` even though the latter\n * has rich CliError metadata and the former is server-only.\n */\nfunction lookupApiErrorBody(code: string): string | undefined {\n return API_ERROR_DOCS[code];\n}\n\nexport const explainCommand = new Command('explain')\n .description('Look up one or more error codes and their recommended recovery steps.')\n .argument('[codes...]', 'error codes to explain (omit to list every known code)')\n .addHelpText(\n 'after',\n `\nExamples:\n $ kash explain # list every known error code\n $ kash explain RATE_LIMITED\n $ kash explain RATE_LIMITED NOT_FOUND --json # bulk lookup for AI agents\n`\n )\n .action((codes: readonly string[], _opts, cmd: Command) => {\n const globals = readGlobals(cmd);\n\n if (codes.length === 0) {\n if (globals.json) {\n printJson({ codes: ERROR_CATALOG });\n return;\n }\n const table = createTable(['Code', 'Summary', 'Recoverable']);\n for (const entry of ERROR_CATALOG) {\n table.push([\n entry.code,\n truncate(entry.summary, 60),\n entry.recoverable ? style.success('yes') : style.dim('no'),\n ]);\n }\n print(table.toString());\n return;\n }\n\n // Resolve every code first so we fail before printing anything if\n // any are unknown — agents iterating a list shouldn't get a\n // half-finished response.\n type Resolved =\n | { readonly source: 'cli'; readonly entry: ReturnType<typeof lookupErrorCode> & object }\n | { readonly source: 'api'; readonly code: string; readonly body: string };\n const entries: Resolved[] = codes.map((code) => {\n const entry = lookupErrorCode(code);\n if (entry) return { source: 'cli', entry };\n // Fall back to the bundled docs/api-errors/<CODE>.md content so\n // server-side API codes (MARKET_NOT_TRADEABLE, INSUFFICIENT_BALANCE,\n // CONFIRMATION_TOKEN_USED, …) work even though they don't have\n // hand-curated CLI metadata. Agents calling `--json` get a\n // distinct `source: 'api'` envelope so they can differentiate.\n const body = lookupApiErrorBody(code);\n if (body) return { source: 'api', code, body };\n throw new CliError(`Unknown error code: ${code}`, {\n code: 'INVALID_INPUT',\n recoverable: true,\n suggestion: 'Run `kash explain` (no argument) to list every known code.',\n });\n });\n\n if (globals.json) {\n // Single code: emit the entry directly (back-compat with the\n // historical single-arg shape).\n // Multiple codes: emit a `codes` array so consumers can branch.\n const payload = entries.map((r) =>\n r.source === 'cli'\n ? r.entry\n : {\n code: r.code,\n source: 'api' as const,\n docsUrl: `${API_DOCS_URL_BASE}/${r.code}`,\n body: r.body,\n }\n );\n printJson(payload.length === 1 ? payload[0] : { codes: payload });\n return;\n }\n\n for (const [i, resolved] of entries.entries()) {\n if (i > 0) print('');\n print('');\n if (resolved.source === 'api') {\n // Render the bundled markdown verbatim — it's already formatted\n // for human reading (per-code page template).\n print(resolved.body);\n print(style.dim(`Docs: ${API_DOCS_URL_BASE}/${resolved.code}`));\n continue;\n }\n const { entry } = resolved;\n print(`${style.bold(entry.code)}`);\n print(` ${style.dim('Summary ')} ${entry.summary}`);\n print(\n ` ${style.dim('Recoverable')} ${entry.recoverable ? style.success('yes') : style.dim('no')}`\n );\n if (entry.docsUrl) {\n print(` ${style.dim('Docs ')} ${entry.docsUrl}`);\n }\n print('');\n print(entry.description);\n if (entry.actions.length > 0) {\n print('');\n print(style.bold('Recovery actions:'));\n for (const action of entry.actions) {\n print(` - ${formatAction(action)}`);\n }\n }\n }\n });\n\nfunction formatAction(action: CliErrorAction): string {\n switch (action.type) {\n case 'run_command':\n return `${style.cyan(action.command)} — ${action.description}`;\n case 'set_env':\n return `${style.cyan(action.variable)} (env var) — ${action.description}`;\n case 'wait_and_retry':\n return `${style.cyan(`wait ${String(Math.round(action.delayMs / 1000))}s`)} — ${action.description}`;\n case 'open_url':\n return `${style.cyan(action.url)} — ${action.description}`;\n case 'check_input':\n return `${style.cyan(action.field)} (input field) — ${action.description}`;\n }\n}\n","/**\n * AUTO-GENERATED by scripts/build-api-error-bundle.ts. DO NOT EDIT.\n *\n * Per-code markdown bodies extracted from docs/api-errors/<CODE>.md at\n * build time so `kash explain <CODE>` can render the full docs page\n * offline. Re-run `pnpm gen:api-error-bundle` (or `pnpm build`,\n * which runs it as a prebuild step) to refresh.\n *\n * Generated at 2026-10-02T18:34:37.754Z; 61 codes covered.\n */\n\nexport const API_ERROR_DOCS: Readonly<Record<string, string>> = Object.freeze({\n \"ACTIVE_KEY_LIMIT_REACHED\": \"# `ACTIVE_KEY_LIMIT_REACHED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Active key limit reached\\\"\\n\\n## When it fires\\n\\nThe user has hit `MAX_ACTIVE_KEYS_PER_USER` (5) and `POST /api/account/api-keys` in the webapp (or `kash-admin api-keys issue`) was called to issue another. The cap is enforced both at the application layer and via the `trg_api_keys_enforce_max_active_per_user` BEFORE INSERT trigger (which closes a TOCTOU window that the app-level check alone leaves open).\\n\\n## Why it happens\\n\\n- A genuine ceiling — most users only need 2-3 keys (one per environment, maybe a CI key). The cap exists to prevent runaway issuance from compromising audit hygiene.\\n- A bot that issues a fresh key per run instead of reusing one (anti-pattern).\\n\\n## How to fix\\n\\n- Revoke an existing key first — Settings → API Keys → Revoke (or `kash-admin api-keys revoke <id>`).\\n- Reuse keys across environments where appropriate: one `kash_live_…` for production code, one `kash_test_…` for everything else.\\n- For mm/enterprise tiers, the cap is higher — contact support if your usage genuinely needs more.\\n\\n## Related codes\\n\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — what happens after revocation if you keep using the old key\\n\",\n \"AMOUNT_TOO_LARGE\": \"# `AMOUNT_TOO_LARGE`\\n\\n**HTTP status:** 400 · **Title:** \\\"Amount too large\\\"\\n\\n## When it fires\\n\\n`amount` exceeds `MAX_USDC_PER_TRADE`. This is a defence-in-depth check distinct from your key's per-trade and daily spending limits — it catches obvious mistakes regardless of your tier.\\n\\n## Why it happens\\n\\nThe most common cause is a **decimal-place mistake**: passing `1000000000000000000` (\\\"1 USDC in 18-decimal atomic units\\\") instead of `1` (\\\"1 USDC in human units\\\"). The public API takes amounts in **human units** (the same string a person would type), not 18-decimal atomic units.\\n\\n## How to fix\\n\\n- Pass `amount` as a string of the human-unit USDC value: `\\\"100\\\"`, `\\\"0.50\\\"`, `\\\"2500\\\"`.\\n- If you have an atomic value, divide by 10^6 (USDC is 6 decimals on-chain, but our API normalises to \\\"USDC the user thinks about\\\").\\n- For sustained high-value flows above `MAX_USDC_PER_TRADE`, request a tier upgrade — your key's `per_trade_limit_usdc` can be raised, but the absolute cap is a platform-level safety net.\\n\\n## Related codes\\n\\n- [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md) — your key's per-trade or daily limit, separate from the platform cap\\n- [`VALIDATION_FAILED`](./VALIDATION_FAILED.md) — generic validation failure\\n\",\n \"API_KEY_EXPIRED\": \"# `API_KEY_EXPIRED`\\n\\n**HTTP status:** 401 · **Title:** \\\"API key expired\\\"\\n\\n## When it fires\\n\\nThe key has a non-null `expires_at` and the current time is past it.\\n\\n## Why it happens\\n\\n- You requested a time-limited key at issuance (recommended for CI keys, contractor access, demos).\\n- An operator set an expiry on a long-lived key during a security review.\\n- The key is unchanged; only the wall clock moved.\\n\\n## How to fix\\n\\n- Issue a new key via Settings → API Keys (self-service, free/developer tiers) or `kash-admin api-keys issue` (mm/enterprise tiers).\\n- For automated rotation, schedule a rotation job that issues + activates the new key 24 h before the old one expires.\\n- The new key will have a different `id` and prefix; update every consumer.\\n\\n## Related codes\\n\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — explicit operator action, distinct from time-based expiry\\n\",\n \"API_KEY_INVALID\": \"# `API_KEY_INVALID`\\n\\n**HTTP status:** 401 · **Title:** \\\"Authentication required\\\"\\n\\n## When it fires\\n\\nThe `X-API-Key` header is well-formed but no row in the API key store matches its prefix or HMAC.\\n\\n## Why it happens\\n\\n- The key was deleted (not just revoked — see [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) for that distinct case).\\n- A character was modified in transit or storage (e.g., copy-paste truncation that still happened to land on the right length).\\n- Wrong environment — a `kash_test_…` key sent to production, or vice versa. Production never accepts test keys.\\n- The HMAC pepper has been rotated and the rotation grace period has elapsed for an old issuance.\\n\\n## How to fix\\n\\n- Compare the stored value to what the webapp Settings → API Keys page shows for the prefix (`kash_live_xxxx…`).\\n- If the prefix doesn't match anything in your account, the key was never yours or has been deleted — issue a new one.\\n- If the prefix matches but auth still fails, contact support with the prefix and your `X-Request-ID` from a recent failed call.\\n\\n## Related codes\\n\\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md), [`API_KEY_MALFORMED`](./API_KEY_MALFORMED.md) — earlier failure points\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — key existed but was explicitly revoked\\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) — key existed but is past `expires_at`\\n\",\n \"API_KEY_MALFORMED\": \"# `API_KEY_MALFORMED`\\n\\n**HTTP status:** 401 · **Title:** \\\"Authentication required\\\"\\n\\n## When it fires\\n\\nThe `X-API-Key` header is present but does not match the expected `kash_(live|test)_<32 alphanumeric>` shape.\\n\\n## Why it happens\\n\\n- A leading or trailing whitespace character (e.g., from a copy-paste with a trailing newline).\\n- The wrong env var was substituted (e.g., a Privy token or a bare UUID instead of a Kash API key).\\n- Truncation by a logging or proxy layer that capped header values.\\n\\n## How to fix\\n\\n- The format is `kash_<env>_<32 alphanumeric>` (base62 — `A-Z`, `a-z`, `0-9`, no dashes or underscores after the prefix). The `<env>` segment is `live` or `test`.\\n- `live` keys hit production; `test` keys hit staging. Don't mix.\\n- If you stored the key in an env var, double-check the substitution: `echo $KASH_API_KEY | head -c 16` should print `kash_live_…` or `kash_test_…`.\\n\\n## Related codes\\n\\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) — no header at all\\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) — well-formed but unknown\\n\",\n \"API_KEY_MISSING\": \"# `API_KEY_MISSING`\\n\\n**HTTP status:** 401 · **Title:** \\\"Authentication required\\\"\\n\\n## When it fires\\n\\nA route that requires authentication received a request with no `X-API-Key` header.\\n\\n## Why it happens\\n\\n- The request was sent without the header (most common — first integration).\\n- A reverse proxy or HTTP client stripped the header.\\n- The wrong base URL — the documented endpoints live under `/v1/*`; some other route may not require auth.\\n\\n## How to fix\\n\\n```http\\nGET /v1/portfolio HTTP/1.1\\nHost: api.kash.bot\\nX-API-Key: kash_live_<your-key-here>\\n```\\n\\n- Pass the key on every authenticated request. Keys are issued via the webapp Settings → API Keys page or `kash-admin api-keys issue`.\\n- The plaintext is shown once at issuance. If you've lost it, revoke and re-issue.\\n\\n## Related codes\\n\\n- [`API_KEY_MALFORMED`](./API_KEY_MALFORMED.md) — header present but doesn't match the `kash_(live|test)_*` shape\\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) — header well-formed but no row matches\\n\",\n \"API_KEY_REVOKED\": \"# `API_KEY_REVOKED`\\n\\n**HTTP status:** 401 · **Title:** \\\"API key revoked\\\"\\n\\n## When it fires\\n\\nThe key is well-formed and recognised, but its `revoked_at` column is set. The auth middleware fails closed.\\n\\n## Why it happens\\n\\n- A user (you or a teammate) explicitly revoked the key via Settings → API Keys → Revoke.\\n- An operator revoked the key via `kash-admin api-keys revoke <id>` (e.g., suspected leak).\\n- A security automation revoked it (e.g., per-key velocity anomaly tripped a kill switch).\\n\\n## How to fix\\n\\n- If revocation was unintentional, ask the operator who ran it (the audit log records who/when/why).\\n- If revocation was intentional and you need to keep working, **issue a new key**. Revoked keys cannot be un-revoked — that's the security property.\\n- Update every place the old key was stored (env vars, secret stores, CI configs) before the new key goes live.\\n\\n## Related codes\\n\\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) — key was deleted, not revoked\\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) — key reached its `expires_at` (no operator action)\\n\",\n \"API_TRADE_PROCESSING_HALTED\": \"# `API_TRADE_PROCESSING_HALTED`\\n\\n**HTTP status:** 503 · **Title:** \\\"Service unavailable\\\" · **Retry-After:** 300s\\n\\n## When it fires\\n\\nThe coarse `API_TRADE_PROCESSING` kill switch is active. Ops uses this to halt all trade-write traffic platform-wide during incidents (e.g., RPC outage, oracle failure, security investigation).\\n\\nRead endpoints (markets, portfolio, trade lookup) stay available throughout — only the write path is blocked.\\n\\n## Why it happens\\n\\n- Active incident under operator response.\\n- Pre-deploy lockdown for a high-risk migration.\\n- A security-driven freeze in response to anomaly detection.\\n\\n## How to fix\\n\\n- Honour `Retry-After: 300` and back off. Do NOT poll faster — the kill switch is binary, polling won't unblock you sooner.\\n- Subscribe to the status page or our incident webhook to know when service resumes.\\n- In the meantime, your read paths still work — surface a \\\"trading temporarily paused\\\" banner in your UI rather than failing silently.\\n\\n## Related codes\\n\\n- [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) — single-route kill switch (more granular than the coarse halt)\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — specific dependency rather than platform-wide\\n\",\n \"API_VERSION_UNSUPPORTED\": \"# `API_VERSION_UNSUPPORTED`\\n\\n**HTTP status:** 410 · **Title:** \\\"API version unsupported\\\"\\n\\n## When it fires\\n\\nYour request sent an `X-Kash-Api-Version` header whose value is not in\\n`SUPPORTED_PUBLIC_API_VERSIONS`. The runtime accepts only\\ndate-string values that the API has actively published as a supported\\ncontract version. Unrecognized values — typos, future-dated guesses,\\nversions long past their sunset window — return this error.\\n\\n## Why 410?\\n\\n`410 Gone` is the right semantic: the version was either once\\naccepted (an old date past its 12-month deprecation window) or never\\nhas been (a typo, a future date). Either way, sending the request\\nagain with the same value will not succeed. `400` would imply the\\nclient could fix syntax and retry without changing the value — which\\nis not the case here. `406 Not Acceptable` is closer but is reserved\\nfor content-type negotiation.\\n\\n## Why it happens\\n\\n- **Typo in the pin** — sent `2026-04-30` when the canonical version\\n is `2026-04-29`.\\n- **Pinned to a sunsetted version** — the date you pinned against\\n passed its 12-month `Sunset` window and was removed from the\\n supported set.\\n- **Pinned to a future version** — your client was upgraded with a\\n version pin that the production API hasn't shipped yet (canary\\n drift between client release and server deploy).\\n- **Multi-valued header confusion** — a load balancer or proxy\\n appended a second `X-Kash-Api-Version` value (`,`-joined) and the\\n runtime correctly refuses to silently pick one.\\n\\n## How to recover\\n\\nThe response body's `metadata.supported` field carries the full list\\nof accepted versions, in canonical-default-first order:\\n\\n```json\\n{\\n \\\"type\\\": \\\"https://docs.kash.bot/developer-docs/api-errors/API_VERSION_UNSUPPORTED\\\",\\n \\\"title\\\": \\\"API version unsupported\\\",\\n \\\"status\\\": 410,\\n \\\"code\\\": \\\"API_VERSION_UNSUPPORTED\\\",\\n \\\"detail\\\": \\\"Requested API version '2099-01-01' is not supported. Send no header to use the current default (2026-04-29), or pick a value from the 'supported' list.\\\",\\n \\\"instance\\\": \\\"/v1/markets\\\",\\n \\\"requestId\\\": \\\"01HQGY8K2N3X4Z5C6D7E8F9G0H\\\",\\n \\\"requested\\\": \\\"2099-01-01\\\",\\n \\\"supported\\\": [\\\"2026-04-29\\\"],\\n \\\"current\\\": \\\"2026-04-29\\\"\\n}\\n```\\n\\nPick a value from `supported` and resend the request. The simplest\\nrecovery is to **drop the header** entirely and let the runtime\\ndefault to `current` — that's the recommended consumer mode unless\\nyou have a specific reason to pin.\\n\\n## Migration policy\\n\\nWhen the API ships a new version, the previous version stays in\\n`SUPPORTED_PUBLIC_API_VERSIONS` for **12 months** (per AD-15 / RFC\\n8594). During that window, every response from the deprecated\\nversion carries `Sunset: <date>` and `Deprecation: true` headers so\\nyour monitoring can flag the impending expiry well before it hits.\\n\\nIf you're seeing this error on a date that is older than 12 months\\nsince its `Sunset` was advertised, that's working as intended — it's\\ntime to update the pin.\\n\\n## Best practice\\n\\nDon't pin if you don't have to. The default contract (no header)\\nruns against the current `PUBLIC_API_VERSION`, and we promise\\nadditive, non-breaking changes within a date version. Pin only when\\nyou're locking down a release for compliance or contract-test\\nreasons.\\n\\nIf you do pin, follow the registry: read\\n`https://api.kash.bot/v1/openapi.json` (or the live OpenAPI route)\\nand let your client library auto-select the highest version it\\nrecognises.\\n\\n## See also\\n\\n- `apps/public-api/src/plugins/api-version.ts` — the negotiation\\n plugin source.\\n- `packages/constants/src/public-api-version.ts` — the canonical\\n registry.\\n- `apps/public-api/openapi/index.json` — the snapshot index, listing\\n every published version.\\n- `docs/runbooks/hmac-algorithm-rotation.md` — example of a version\\n bump driven by a security event.\\n\",\n \"CHAIN_NOT_SUPPORTED\": \"# `CHAIN_NOT_SUPPORTED`\\n\\n**HTTP status:** 400 · **Title:** \\\"Chain not supported\\\"\\n\\n## When it fires\\n\\nThe request targets a chain the API does not support. This happens in two ways:\\n\\n1. **A route that performs an on-chain read** (`POST /v1/redemptions`,\\n `GET /v1/markets/{id}/quote`) resolved a **market** on a chain the API does not\\n support for on-chain reads.\\n2. **`POST /v1/competitions/{competitionId}/claim`**, which performs **no on-chain\\n read**: the competition runs on a chain whose escrow adapter is not implemented\\n in this environment (for example the `evm` / `solana` adapters), so the prize\\n claim cannot be recorded.\\n\\n## Why it happens\\n\\n- The market (case 1) or the competition (case 2) is on a chain this environment\\n does not support.\\n\\nYour request is well-formed — this is a chain-support condition, not a client\\ninput error, so it is a permanent `400` (retrying will not help). (For the\\non-chain-read routes, a _supported_ chain missing a contract address is a\\nserver-config fault and returns `500 INTERNAL_ERROR`, not this code.)\\n\\n## How to fix\\n\\n- **Trade / redemption / quote routes:** confirm the market's chain is one of the\\n supported chains (the `detail` lists them).\\n- **Competition claim route:** confirm the competition runs on a chain this\\n environment supports for prize claims. There is no on-chain read to check here —\\n the chain simply has no escrow adapter yet.\\n- If you believe the chain should be supported, contact support — it is a server\\n configuration gap, not something the caller can correct.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md)\\n\",\n \"CLIENT_REQUEST_ID_CONFLICT\": \"# `CLIENT_REQUEST_ID_CONFLICT`\\n\\n**HTTP status:** 409 · **Title:** \\\"Client request id conflict\\\"\\n\\n## When it fires\\n\\nThe same `(user_id, clientRequestId)` body pair was used for an earlier trade with a **different body**. The trade row is unique on `(user_id, clientRequestId)` once you opt in.\\n\\n## Why it happens\\n\\n- Power-user mistake: `clientRequestId` is a body field with stricter conflict semantics than the HTTP `Idempotency-Key` header. Most callers should use the header; opt into `clientRequestId` only if you specifically want trade-row-scoped dedup.\\n- A bug regenerated the body but kept the `clientRequestId` constant.\\n\\n## How to fix\\n\\n- For most use cases, drop `clientRequestId` and use only `Idempotency-Key`. See `apps/public-api/README.md` § Idempotency.\\n- If you need `clientRequestId`'s row-scoped dedup, generate a fresh value per logical operation.\\n- The conflicting trade row is referenced in the `detail` field — fetch it via `GET /v1/trades/:id` to see what was previously submitted.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) — same idea, header-level\\n\",\n \"COMPETITION_ALREADY_ENTERED\": \"# `COMPETITION_ALREADY_ENTERED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Already entered this competition\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/join` was called for a group (or user) that already has an entry in this competition.\\n\\n## Why it happens\\n\\nEntries are unique per competition — one per group, one per user. The existing entry may still be in `funding` (not yet fully paid), which is exactly when a caller is most likely to try joining again.\\n\\n## Why a second entry is refused rather than created\\n\\nThe entry fee is POOLED. A second entry would split the group's contributions across two pools, and neither might reach the minimum entry fee — so the group would pay twice and compete in neither.\\n\\n## How to fix\\n\\n- Fund the EXISTING entry: `POST /v1/competitions/{competitionId}/contribute` with that entry's `entrantId`.\\n- A withdrawn or rejected entry does not block a fresh one; any other state does.\\n\\n## Related codes\\n\\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) — the entry exists but is not accepting money.\\n- [`COMPETITION_NOT_GROUP_OWNER`](./COMPETITION_NOT_GROUP_OWNER.md) — the caller may not create the entry.\\n\",\n \"COMPETITION_ALREADY_IN_ANOTHER_ENTRY\": \"# `COMPETITION_ALREADY_IN_ANOTHER_ENTRY`\\n\\n**HTTP status:** 409 · **Title:** \\\"Already in another entry\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/contribute` was called by someone who is already funding a **different** entry in the same competition.\\n\\n## Why it happens\\n\\nOne member funds at most one entry per competition. `contribute_to_competition` refuses when the caller already has a `competition_member_contributions` row against another `entrant_id` in that competition.\\n\\n## Why it is refused rather than accepted\\n\\nContribution decides the payout split, and standings are attributed per entrant. A member funding two entries in one competition would hold a share of two competing prize pools, and their trading would be attributable to both — so the competition could not be scored coherently.\\n\\n## How to fix\\n\\n- Contribute to the entry you already belong to. `GET /v1/competitions/{competitionId}` lists your entry.\\n- If you meant to move to a different entry, leave the first one before funding another; a contribution already made is not transferable between entries.\\n\\n## Note for operators\\n\\nThis used to share `COMPETITION_NOT_ENTRY_MEMBER`'s code, because both refusals were raised as SQLSTATE `P0002`. That answered \\\"You are not a member of this entry\\\", which is **false** in this case — the caller _is_ a member, of another entry — and gave them nothing to act on. The refusal now raises `23505` and has its own code, so the two are distinguishable both in logs and to the caller.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) — the caller does not belong to the entry at all.\\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) — the entry or competition is not accepting money.\\n\",\n \"COMPETITION_CONTRIBUTION_BELOW_MINIMUM\": \"# `COMPETITION_CONTRIBUTION_BELOW_MINIMUM`\\n\\n**HTTP status:** 400 · **Title:** \\\"Contribution below minimum ticket\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/contribute` was called with an `amountAtomic` below the competition's minimum ticket, or with an amount that is not a positive integer.\\n\\n## Why it happens\\n\\nA competition may set `min_ticket_atomic` — the smallest amount any single member may contribute. It exists so that one member cannot buy a whole group in while the rest ride free: every member who is going to share in the winnings must put in at least the ticket.\\n\\nThe error message names the actual minimum for that competition.\\n\\n## How to fix\\n\\n- Read the competition's minimum ticket and contribute at least that much.\\n- Send `amountAtomic` as an atomic USDC decimal STRING (6 decimals — `\\\"40000000\\\"` is 40 USDC), never a JSON number. A number silently loses precision above 2^53, and this value decides how a real prize is split.\\n\\n## Related codes\\n\\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) — the entry or competition is not accepting money.\\n- [`VALIDATION_FAILED`](./VALIDATION_FAILED.md) — the body failed schema validation before reaching the contribution logic.\\n\",\n \"COMPETITION_CONTRIBUTION_REF_CONFLICT\": \"# `COMPETITION_CONTRIBUTION_REF_CONFLICT`\\n\\n**HTTP status:** 409 · **Title:** \\\"Contribution reference already used for a different amount\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/contribute` was called with an `externalRef` that this member has already used for this entry, but with a **different** `amountAtomic`.\\n\\n## Why it happens\\n\\n`externalRef` is your idempotency handle for a contribution — normally the on-chain transfer's identifier. Sending the same ref twice is safe and expected: a retry after a dropped connection re-sends the same ref, and the second call is a no-op that returns the member's unchanged running total. That is the behaviour you want, and it is preserved.\\n\\nSending the same ref with a _different_ amount is a different thing entirely. It cannot be a replay of the first request, so one of the two is wrong. Crediting the new amount would double-count against a pot that must reconcile with escrow; silently ignoring it would be worse, because the response would say `200` and report a total that does not include the money you believe you just sent. Under pooled funding that total decides how a real prize is split, so a member who thinks they contributed twice as much would expect twice the share.\\n\\nThe API refuses instead, and the message names both amounts — the one already recorded and the one requested — so you can tell which of your two requests actually stands.\\n\\n## How to fix\\n\\n- **If the first request was the correct one**, no action is needed: it is already recorded. Read the member's total back from the contribute response or the entry, and continue.\\n- **If you meant to contribute an additional amount**, send it under a **new** `externalRef`. Contributions accumulate, so a second ref adds to the member's total rather than replacing it.\\n- **If you reused a ref by mistake** (for example a hardcoded or non-unique value in a retry loop), make `externalRef` unique per contribution. It only has to be unique per member per entry, so the same on-chain identifier used by two _different_ members is fine.\\n\\n## Related codes\\n\\n- [`COMPETITION_CONTRIBUTION_BELOW_MINIMUM`](./COMPETITION_CONTRIBUTION_BELOW_MINIMUM.md) — the amount is under the competition's minimum ticket.\\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) — the entry or competition is not accepting money at all.\\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) — you are not a member of the entry you are funding.\\n\",\n \"COMPETITION_ENTRY_NOT_FUNDABLE\": \"# `COMPETITION_ENTRY_NOT_FUNDABLE`\\n\\n**HTTP status:** 409 · **Title:** \\\"Competition entry not fundable\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/contribute` was called for a competition or an entry that is not accepting money right now.\\n\\n## Why it happens\\n\\n- The competition is not an escrow competition. Only `onchain_escrow` competitions pool an entry fee; `sponsored`, `offchain_airdrop` and `none` competitions have nothing to fund.\\n- The competition is outside its funding window — before `submission_opens_at`, or at/after `join_deadline`.\\n- The competition has left `published`/`registration` (it has started, been cancelled, or already settled).\\n- The entry is no longer accepting contributions: it has been withdrawn, rejected, disqualified or refunded. An entry in `funding` or `joined` accepts them; anything else does not.\\n\\nNote that a fully-funded entry still accepts contributions — a member may top up a `joined` entry, which increases their share of any prize. It is the competition's window, not the funding threshold, that closes this door.\\n\\n## How to fix\\n\\n- Check the competition's state and `join_deadline` before contributing.\\n- If the entry was refunded or withdrawn, create a new entry and fund that instead.\\n\\nThis is a state conflict, not an authorization failure, and retrying will not help until the state changes.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) — the caller is not part of this entry.\\n- [`COMPETITION_CONTRIBUTION_BELOW_MINIMUM`](./COMPETITION_CONTRIBUTION_BELOW_MINIMUM.md) — the amount is under the minimum ticket.\\n\",\n \"COMPETITION_NOTHING_TO_REFUND\": \"# `COMPETITION_NOTHING_TO_REFUND`\\n\\n**HTTP status:** 404 · **Title:** \\\"No refundable contribution\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/refund` on a **cancelled** competition where the authenticated caller has no outstanding refundable contribution.\\n\\n## Why it happens\\n\\nTwo different situations produce it, and they are deliberately the same answer to you:\\n\\n1. **You never contributed** to any entry in this competition.\\n2. **You have already been refunded.** The amount is resolved as your contribution _net of refunds already paid to you_, so once you have been paid there is nothing left to resolve.\\n\\nThe second case is what makes the endpoint safe to retry: a repeated call after a successful refund finds nothing to do and returns this code, rather than paying you a second time. It is not an error condition so much as a terminal state.\\n\\nNote this is scoped to **you**. In a pooled group entry each member is refunded their own contribution independently — another member still being owed money does not make your already-settled refund outstanding again.\\n\\n## How to fix\\n\\n- If you expected a refund, check that the contribution was recorded against the account whose API key you are using. Contributions are attributed to the authenticated caller at the time they are made.\\n- If you have already received the refund, no action is needed — this is the expected response to a repeat call.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_CANCELLED`](./COMPETITION_NOT_CANCELLED.md) — the competition is not cancelled, so nothing is refundable yet.\\n- [`COMPETITION_REFUND_FAILED`](./COMPETITION_REFUND_FAILED.md) — a refund was resolved but the write refused it.\\n\",\n \"COMPETITION_NOT_CANCELLED\": \"# `COMPETITION_NOT_CANCELLED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Competition is not cancelled\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/refund` was called for a competition that is not in the `cancelled` state.\\n\\n## Why it happens\\n\\nA refund returns a contributor's own stake, and a stake is only owed back once the competition has been cancelled. Before that:\\n\\n- A **running** competition still legitimately holds the stake — that is what entering paid for.\\n- A **settled** competition pays out through the prize path, not this one. Use [`POST /v1/competitions/{id}/claim`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md).\\n\\nThe state gate lives in the resolver, not just in the route, so this endpoint cannot become a general withdrawal hatch on a live competition.\\n\\n## How to fix\\n\\n- Read the competition's `state`. If it is `running`, `registration` or `published`, there is nothing to refund yet.\\n- If it is `settled` or `paid_out`, claim the prize instead.\\n- Competitions are cancelled automatically when the entrant threshold is unmet at the join deadline, or by an admin. Once that happens the refund becomes available with no further action from you.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOTHING_TO_REFUND`](./COMPETITION_NOTHING_TO_REFUND.md) — the competition IS cancelled, but you have nothing outstanding.\\n- [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md) — the mirror-image refusal on the claim path.\\n\",\n \"COMPETITION_NOT_ENTRY_MEMBER\": \"# `COMPETITION_NOT_ENTRY_MEMBER`\\n\\n**HTTP status:** 403 · **Title:** \\\"Not a member of this entry\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/contribute` was called by someone who does not belong to the entry they tried to fund.\\n\\n## Why it happens\\n\\n- For a **group** entry, the caller is not in `group_members` for that group.\\n- For an **individual** entry, the caller is not the entrant.\\n\\nThe member is always the AUTHENTICATED caller — it is never read from the request body — so this cannot be worked around by naming a different user.\\n\\n## Why it is refused rather than accepted\\n\\nContribution decides the PAYOUT SPLIT. A winning group's prize is divided in proportion to what each member contributed, so allowing a stranger to pay in would buy them a share of that group's prize.\\n\\n## How to fix\\n\\n- Join the group before contributing to its entry.\\n- Confirm the `entrantId` in the request body is the entry you meant — funding another group's entry by mistake produces this error.\\n\\n## Related codes\\n\\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) — the entry or competition is not accepting money.\\n\",\n \"COMPETITION_NOT_FOUND\": \"# `COMPETITION_NOT_FOUND`\\n\\n**HTTP status:** 404 · **Title:** \\\"Competition not found\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/claim` was called with a `competitionId` that does not correspond to any competition.\\n\\n## Why it happens\\n\\n- The `competitionId` path parameter is a well-formed UUID but names no competition (typo, wrong environment, or a draft that was deleted).\\n\\n## How to fix\\n\\n- Verify the `competitionId`. It must be the UUID of a real competition you entered and that has settled.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md), [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md)\\n\",\n \"COMPETITION_NOT_GROUP_OWNER\": \"# `COMPETITION_NOT_GROUP_OWNER`\\n\\n**HTTP status:** 403 · **Title:** \\\"Not the group owner\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/join` was called to enter a group the caller does not own, or to enter an individual entry on someone else's behalf.\\n\\n## Why it happens\\n\\n- **Group entry:** only a member with the `owner` role may enter the group into a competition. A plain member cannot.\\n- **Individual entry:** the entrant must be the caller themselves.\\n\\n## Why entering and funding differ\\n\\nEntering is an act of the group; funding is an act of a member. Any member may pay into an existing entry (see [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md)), but only the owner may commit the group to a competition in the first place — the entry occupies one of the competition's limited slots and binds the whole roster.\\n\\n## How to fix\\n\\n- Ask a group owner to create the entry, then contribute to it.\\n- For an individual entry, call as the entrant.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_ENTRY_MEMBER`](./COMPETITION_NOT_ENTRY_MEMBER.md) — about funding an entry, not creating one.\\n- [`COMPETITION_ALREADY_ENTERED`](./COMPETITION_ALREADY_ENTERED.md) — an entry already exists.\\n\",\n \"COMPETITION_NOT_SETTLED\": \"# `COMPETITION_NOT_SETTLED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Competition not settled\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/claim` was called for a competition that has not reached a claimable state. Prizes can only be claimed once winners have been declared.\\n\\n## Why it happens\\n\\n- The competition is still `published`, `registration`, `running`, or `scoring_frozen` — winners have not been declared yet.\\n- The competition was `cancelled`; there is no prize to claim (paid entrants are refunded through the refund path, not this one).\\n\\n## How to fix\\n\\n- Wait until the competition is `settled` (or `paid_out`). The keeper declares winners automatically once scoring is frozen and the pot is funded.\\n- Poll the competition's state before attempting a claim.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_FOUND`](./COMPETITION_NOT_FOUND.md), [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md)\\n\",\n \"COMPETITION_PRIZE_AUTO_DISTRIBUTED\": \"# `COMPETITION_PRIZE_AUTO_DISTRIBUTED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Competition prize auto-distributed\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/claim` was called for a competition whose\\nprizes are **distributed automatically** — settlement mode `offchain_airdrop` or\\n`none`. Only escrow/sponsored competitions (`onchain_escrow`, `sponsored`) are\\npull-claimed through this endpoint.\\n\\n## Why it happens\\n\\n- The competition settles in an auto-distribution mode, so there is no prize to\\n pull-claim: winnings arrive through the automatic distribution path rather than\\n a caller-initiated claim.\\n\\nThis is a state conflict on the competition, not an authorization failure about\\nthe caller (that would be `403 COMPETITION_PRIZE_NOT_CLAIMABLE`), so it is a\\npermanent `409` — retrying will not help.\\n\\n## How to fix\\n\\n- Nothing to do on the claim endpoint. Your prize, if any, is distributed\\n automatically for this competition; check your balance / payout history rather\\n than re-claiming.\\n\\n## Related codes\\n\\n- [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md), [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md)\\n\",\n \"COMPETITION_PRIZE_CLAIM_FAILED\": \"# `COMPETITION_PRIZE_CLAIM_FAILED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Competition prize claim failed\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/claim` authorised the claim and dispatched it to the escrow ledger, but the underlying payout landed (or remains) in a `failed` state. No prize was disbursed.\\n\\n## Why it happens\\n\\n- A prior payout attempt for this exact `(competition, entrant, kind, round)` recorded a terminal `failed` state and still occupies the idempotency slot. It cannot be re-attempted through the API.\\n\\n## How to fix\\n\\n- Contact support. An operator must clear the failed payout row before a fresh claim can succeed. Retrying the request will not change the outcome.\\n\\n## Related codes\\n\\n- [`COMPETITION_PRIZE_NOT_CLAIMABLE`](./COMPETITION_PRIZE_NOT_CLAIMABLE.md), [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md)\\n\",\n \"COMPETITION_PRIZE_NOT_CLAIMABLE\": \"# `COMPETITION_PRIZE_NOT_CLAIMABLE`\\n\\n**HTTP status:** 403 · **Title:** \\\"Competition prize not claimable\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/claim` was called and the caller cannot\\nclaim in the competition's active settlement, for one of two **user-specific**\\nreasons. This code deliberately does not distinguish them so a caller cannot\\nenumerate another entrant's standing.\\n\\n## Why it happens\\n\\n**1. The caller owns no winning entrant.**\\n\\n- The authenticated user did not win the competition (their entrant is not in the\\n settlement's winner set).\\n- The winning entrant is a **group** entrant and the authenticated user is not\\n that group's owner. Only the group owner may claim a group's prize.\\n- A re-settlement superseded a prior round in which the user won; only the active\\n (non-superseded) settlement's winners are payable.\\n\\n**2. The caller owns MORE THAN ONE winning entrant** in the active settlement (a\\n`user` entry plus an owned `group`, or two owned groups). The endpoint refuses\\nrather than pay an arbitrary one. An operator records the payouts by hand; the\\nprize is not lost, it just cannot be pulled through the API. See the\\n`kash-{env}-competition-multiple-owned-winners` alarm and its runbook.\\n\\n## How to fix\\n\\n- Confirm you entered the competition and placed in a paying position.\\n- If the winning entrant is a group, the claim must be made with an API key\\n belonging to the group's owner.\\n- If you own several winning entries, contact support — your prizes are recorded\\n manually and the claim endpoint cannot disburse them automatically.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOT_SETTLED`](./COMPETITION_NOT_SETTLED.md), [`COMPETITION_PRIZE_AUTO_DISTRIBUTED`](./COMPETITION_PRIZE_AUTO_DISTRIBUTED.md), [`COMPETITION_PRIZE_CLAIM_FAILED`](./COMPETITION_PRIZE_CLAIM_FAILED.md)\\n\",\n \"COMPETITION_REFUND_FAILED\": \"# `COMPETITION_REFUND_FAILED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Refund could not be recorded\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/refund` resolved an outstanding amount for you, but the payout write then refused to record it.\\n\\n## Why it happens\\n\\nThe refund amount is checked **twice, independently**: once when reading what you are owed, and again inside the write, which bounds the payout to your own recorded contribution. This code means those two disagreed.\\n\\nThat should not happen, and it is reported rather than retried on purpose. The write's bound is the authority — it is what guarantees a member of a pooled entry cannot draw down more than they put in — so when the read and the write disagree the safe action is to pay nothing and say so. Silently retrying could turn a bookkeeping disagreement into a double payment.\\n\\nThe realistic cause is a concurrent refund for the same contribution landing between the two steps.\\n\\n## How to fix\\n\\n- **Retry once.** If a concurrent refund completed, the retry resolves nothing outstanding and returns [`COMPETITION_NOTHING_TO_REFUND`](./COMPETITION_NOTHING_TO_REFUND.md), which means you have been paid.\\n- If it persists, do not keep retrying — the amounts genuinely disagree and an operator needs to reconcile the contribution and payout rows. The response detail carries the amounts the write rejected.\\n\\n## Related codes\\n\\n- [`COMPETITION_NOTHING_TO_REFUND`](./COMPETITION_NOTHING_TO_REFUND.md) — nothing outstanding, including after a successful refund.\\n- [`COMPETITION_PRIZE_CLAIM_FAILED`](./COMPETITION_PRIZE_CLAIM_FAILED.md) — the same shape on the prize-claim path.\\n\",\n \"COMPETITION_SPONSOR_NO_SMART_ACCOUNT\": \"# `COMPETITION_SPONSOR_NO_SMART_ACCOUNT`\\n\\n**HTTP status:** 409 · **Title:** \\\"Organization has no smart account\\\"\\n\\n## When it fires\\n\\n`POST /v1/competitions/{competitionId}/sponsor` was called by an organization that has no `smart_account_address`, so there is no address to sponsor from.\\n\\n## Why the address is not taken from the request\\n\\nA prefund is reclaimable: if the competition is cancelled, `sponsor_reclaim` pays the money back to **whatever address the prefund names**. The attribution therefore decides who can withdraw it, so the address is derived server-side from the caller's organization rather than accepted in the body.\\n\\n## How to fix\\n\\n- Provision the organization's smart account, then retry.\\n- Confirm the API key belongs to the organization you intend to sponsor from — the sponsor is always the key's organization.\\n\\n## Related codes\\n\\n- [`COMPETITION_ENTRY_NOT_FUNDABLE`](./COMPETITION_ENTRY_NOT_FUNDABLE.md) — the competition is not accepting money.\\n\",\n \"CONFIRMATION_EXPIRED\": \"# `CONFIRMATION_EXPIRED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Confirmation expired\\\"\\n\\n## When it fires\\n\\nThe confirmation token is past its `confirmation_expires_at`. High-value trades have a short confirmation window (60 seconds by default; configurable per-tier).\\n\\n## Why it happens\\n\\n- A human-in-the-loop confirmation flow took longer than the window allows.\\n- A queue/worker latency between `POST /v1/trades` returning the token and the consumer calling confirm.\\n\\n## How to fix\\n\\n- Tighten the consumer's confirm call — ideally fire it immediately after receiving the token.\\n- For human-in-the-loop flows that need longer, request a per-key window extension (mm tier customers who pipeline trades through human review get longer windows).\\n- The trade is automatically cancelled when the window expires — re-create it to retry.\\n\\n## Related codes\\n\\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md)\\n\",\n \"CONFIRMATION_TOKEN_INVALID\": \"# `CONFIRMATION_TOKEN_INVALID`\\n\\n**HTTP status:** 409 · **Title:** \\\"Confirmation token invalid\\\"\\n\\n## When it fires\\n\\nThe token you sent in `POST /v1/trades/:id/confirm` doesn't match the stored hash for that trade.\\n\\n## Why it happens\\n\\n- Wrong trade id — the token is one-time and tied to a specific trade.\\n- Token was truncated or modified in transit/storage.\\n- Replay against a different trade than the one that issued the token.\\n\\nThe token plaintext is **never persisted server-side** — only the HMAC. We compare your candidate via `timingSafeEqual` to prevent per-byte timing attacks.\\n\\n## How to fix\\n\\n- Re-confirm that the trade id in the URL matches the trade id from the create response.\\n- Capture the token verbatim from `confirmation.token` in the create response — the SDK does this automatically; raw HTTP callers must preserve case and length (43 chars, base64url).\\n- If you've lost the token (only shown once in the create response), the trade can't be confirmed — wait for it to auto-expire and re-create.\\n\\n## Related codes\\n\\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md), [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md)\\n\",\n \"CONFIRMATION_TOKEN_USED\": \"# `CONFIRMATION_TOKEN_USED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Confirmation token used\\\"\\n\\n## When it fires\\n\\nThe token has already been redeemed. Confirmation tokens are **one-time use** — the first successful confirm clears the stored hash so a replay can't re-fire the trade.\\n\\n## Why it happens\\n\\n- A retry layer that doesn't check the trade's status before re-confirming (most common).\\n- Two parallel confirmation attempts from the same consumer (race condition in the caller).\\n- A double-click in a human-in-the-loop UI.\\n\\n## How to fix\\n\\n- Check the trade's status via `GET /v1/trades/:id` — if it's anything past `pending_confirmation`, the confirm already succeeded.\\n- Make your retry loop idempotent: poll trade status first, only confirm if `status === 'pending_confirmation'`.\\n- For UI flows: disable the confirm button immediately on click to prevent double-submission.\\n\\n## Related codes\\n\\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md) — same outcome via different state-check path\\n\",\n \"DEPENDENCY_UNAVAILABLE\": \"# `DEPENDENCY_UNAVAILABLE`\\n\\n**HTTP status:** 503 · **Title:** \\\"Service unavailable\\\" · **Retry-After:** 30s\\n\\n## When it fires\\n\\nA required external dependency (Postgres, Secrets Manager, Redis, blockchain RPC) is unreachable, and the route can't degrade further.\\n\\nDistinct from [`INTERNAL_ERROR`](./INTERNAL_ERROR.md) (which is \\\"we don't know what happened\\\") and [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) (which is \\\"ops disabled this on purpose\\\").\\n\\n## Why it happens\\n\\n- Transient infrastructure blip (most common — clears in seconds).\\n- Sustained dependency outage under operator response.\\n- A rare cascade where one dependency degraded and another can't compensate.\\n\\n## How to fix\\n\\n- Honour `Retry-After: 30` and back off with jitter. Most occurrences resolve within one retry.\\n- If retries keep failing past 5 minutes, check the status page.\\n- The TS SDK handles this automatically (`KashServerError`, retried with backoff up to its retry budget).\\n\\n## Related codes\\n\\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) — ops-driven, not infra-driven\\n- [`REQUEST_TIMEOUT`](./REQUEST_TIMEOUT.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\\n\",\n \"DISPUTE_ALREADY_FILED\": \"# `DISPUTE_ALREADY_FILED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Dispute already filed\\\"\\n\\n## When it fires\\n\\n`POST /v1/markets/:id/disputes` was called for a market whose current resolution proposal you have already disputed. Each user may file exactly one dispute per proposal — the `(resolution_request_id, user_id)` unique constraint enforces this at the database level, so concurrent retries resolve to a single row. If your dispute is upheld and the market is proposed again, the new proposal can be disputed.\\n\\n## Why it happens\\n\\n- Your client retried a filing whose first attempt actually succeeded (e.g., the response was lost in flight).\\n- Two processes sharing the same API key raced to file for the same market.\\n\\n## How to fix\\n\\n- Treat this as success-shaped: your dispute exists and is blocking finalization until an operator reviews it. This holds even when your first attempt returned a 503 after recording the dispute — on this 409 the API re-stages the dispute-filed notification for a still-open dispute, so nothing is lost by stopping your retries here.\\n- Fetch the existing dispute — including its review outcome (`status`, `reviewedAt`, `reviewNote`) — via `GET /v1/markets/:id/disputes`.\\n- There is no amend path; the original `reason` stands. If you have material new evidence, contact support.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_DISPUTABLE`](./MARKET_NOT_DISPUTABLE.md), [`RESOURCE_NOT_FOUND`](./RESOURCE_NOT_FOUND.md)\\n\",\n \"IDEMPOTENCY_KEY_CONFLICT\": \"# `IDEMPOTENCY_KEY_CONFLICT`\\n\\n**HTTP status:** 409 · **Title:** \\\"Idempotency key conflict\\\"\\n\\n## When it fires\\n\\nThe same `Idempotency-Key` was used for two requests with **different bodies**. The server refuses to either re-execute (would violate idempotency) or replay the cached response (would mislead the caller about which request \\\"won\\\").\\n\\n## Why it happens\\n\\n- Most common: a retry layer regenerated the body on each attempt instead of capturing it once and replaying.\\n- A bug in the caller that mutates the body in-place between the request being prepared and the request being sent.\\n- Two distinct logical operations accidentally sharing a key (e.g., a global key generator that didn't account for concurrency).\\n\\n## How to fix\\n\\n- Pin the request body before generating the idempotency key. Either:\\n - Capture body + key together in your retry wrapper and re-send the exact pair on each attempt.\\n - Generate a fresh key per logical operation (one trade = one key).\\n- For genuinely retryable bodies that legitimately change between attempts (e.g., updated timestamps), generate a fresh key per attempt — that's the contract.\\n\\n## Related codes\\n\\n- [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md) — analogous conflict, but on the body-level `clientRequestId` field rather than the HTTP header\\n- [`IDEMPOTENCY_KEY_EXPIRED`](./IDEMPOTENCY_KEY_EXPIRED.md) — key valid earlier, now past TTL\\n\",\n \"IDEMPOTENCY_KEY_EXPIRED\": \"# `IDEMPOTENCY_KEY_EXPIRED`\\n\\n**HTTP status:** 410 · **Title:** \\\"Idempotency key expired\\\"\\n\\n## When it fires\\n\\nThe `Idempotency-Key` you sent matches a row in the idempotency store but that row is past its 24-hour TTL.\\n\\n## Why it happens\\n\\n- A retry that took longer than 24 hours to fire (e.g., a job that stalled in a queue and retried days later).\\n- An offline-first client that buffered the request locally and only got online after the TTL.\\n\\nThe 410 (rather than re-executing or returning the cached response) is intentional: by 24 h, the original response is gone and the system gives you a clear \\\"this key is no longer valid\\\" signal so you can decide whether the operation is still desired.\\n\\n## How to fix\\n\\n- Generate a fresh `Idempotency-Key` for the retry.\\n- Before retrying, check whether the original operation actually completed — query `GET /v1/trades/:id` (using a `clientRequestId` you stored, if applicable) so you don't double-execute.\\n- For long-tail retries, prefer the body-level `clientRequestId` field over the header — it has the same conflict semantics but no TTL.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md), [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md)\\n\",\n \"IDEMPOTENCY_KEY_FORMAT_INVALID\": \"# `IDEMPOTENCY_KEY_FORMAT_INVALID`\\n\\n**HTTP status:** 400 · **Title:** \\\"Idempotency key format invalid\\\"\\n\\n## When it fires\\n\\n`Idempotency-Key` header contains a character outside the allowed set: `[A-Za-z0-9_\\\\-:.]`.\\n\\n## Why it happens\\n\\n- The key contained whitespace, a slash, or a non-ASCII character.\\n- Control characters (newlines, NULs) sneaked in from a copy-paste.\\n- An emoji or Unicode separator accidentally landed in the key.\\n\\nThe strict allowlist exists because the value flows through structured logs, Redis keys, and PG rows — non-printable / control characters could poison log shippers or downstream systems that key on the value.\\n\\n## How to fix\\n\\n- Stick to UUIDs (`crypto.randomUUID()` in Node, `uuidgen` in shell) — they're always conforming.\\n- If you must derive the key from external input, sanitise: `key.replace(/[^A-Za-z0-9_\\\\-:.]/g, '')` then check it's still unique.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_TOO_LONG`](./IDEMPOTENCY_KEY_TOO_LONG.md), [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md)\\n\",\n \"IDEMPOTENCY_KEY_TOO_LONG\": \"# `IDEMPOTENCY_KEY_TOO_LONG`\\n\\n**HTTP status:** 400 · **Title:** \\\"Idempotency key too long\\\"\\n\\n## When it fires\\n\\n`Idempotency-Key` header value exceeds the 255-character cap.\\n\\n## Why it happens\\n\\n- You concatenated several identifiers into the key (user id + market id + timestamp + nonce) and the result blew past 255 chars.\\n- A library generated an unusually long token (e.g., a JWT instead of a UUID).\\n\\n## How to fix\\n\\n- Use a UUID v4 (`uuidgen`, `crypto.randomUUID()`) or a ULID — both are 26–36 chars and unique enough, well under the 255 cap.\\n- If you need to embed semantic information, hash it: `sha256(your-blob).slice(0, 32)` instead of the raw concatenation.\\n- Acceptable character set: `[A-Za-z0-9_\\\\-:.]` — see [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md).\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md) — disallowed characters\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) — same key, different body\\n\",\n \"IDEMPOTENCY_REQUEST_IN_PROGRESS\": \"# `IDEMPOTENCY_REQUEST_IN_PROGRESS`\\n\\n**HTTP status:** 409 · **Title:** \\\"Idempotency request in progress\\\"\\n\\n## When it fires\\n\\nAnother request carrying the same `Idempotency-Key` is still being processed. The server claimed the key when that request arrived and has no response to replay yet, so it refuses to run the route a second time.\\n\\nThis is the concurrent sibling of [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md): the key is the same and the body is the same, but the first attempt has not finished.\\n\\n## Why it happens\\n\\n- Most common: an HTTP client that retries on its **own** timeout while the original request is still running on the server. The server is usually slower than the client's patience, not stuck.\\n- A worker pool that fans the same logical operation out to two workers sharing one key.\\n- A previous attempt that died mid-flight. The claim it left behind is honoured for a short window and then becomes available again automatically — no manual cleanup, and no 24-hour lock-out.\\n\\n## How to fix\\n\\n- **Retry with the same key.** Once the first attempt settles you receive its response verbatim, with `Idempotent-Replay: true`. That is the point of the key — do not generate a fresh one, which would execute the operation a second time.\\n- Back off before retrying (a second or two is usually enough) rather than retrying immediately in a tight loop.\\n- Raise your client's request timeout above the route's normal latency so the retry is not racing a request that was always going to succeed.\\n- If you genuinely want two independent operations, give them two different keys.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) — same key, **different** body\\n- [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md) — the body-level `clientRequestId` equivalent\\n\",\n \"INSUFFICIENT_BALANCE\": \"# `INSUFFICIENT_BALANCE`\\n\\n**HTTP status:** 409 · **Title:** \\\"Insufficient balance\\\"\\n\\n## When it fires\\n\\nThe actor lacks the balance the operation needs — the exact meaning depends on the endpoint:\\n\\n- **`POST /v1/trades`** — the smart account's USDC balance is below the requested `amount` (the rest of this page).\\n- **`POST /v1/redemptions`** — the smart account holds **zero** of the outcome token you are trying to claim. The authoritative on-chain `balanceOf` returned 0, so there is nothing to redeem (e.g. the tokens were already moved or redeemed, or the position was never held). For redemptions, ignore the USDC-funding advice below — check that the account actually holds the outcome token via `GET /v1/portfolio/positions`.\\n\\n## Why it happens\\n\\n- You haven't funded the account yet.\\n- A previous trade consumed more than expected (gas + slippage) and left a sub-threshold balance.\\n- The auto-funding pipeline is backed up — common during very high-throughput windows.\\n- Org account: trade is being charged to the user but the org policy expected the org to pay (or vice versa). Check `feePayerType`.\\n\\n## How to fix\\n\\n- Check the current balance via `GET /v1/portfolio` — `usdcBalance` field.\\n- For users: fund via the webapp's onramp flow (or transfer USDC into the smart account directly).\\n- For orgs: the org admin tops up the org wallet.\\n- For staging/sandbox: use the testnet faucet flow.\\n\\n## Related codes\\n\\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md), [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md)\\n\",\n \"INSUFFICIENT_SCOPE\": \"# `INSUFFICIENT_SCOPE`\\n\\n**HTTP status:** 403 · **Title:** \\\"Insufficient scope\\\"\\n\\n## When it fires\\n\\nThe key authenticated successfully but lacks one of the scopes the route requires.\\n\\n## Why it happens\\n\\n- The key was issued with a narrower scope set than the route needs (e.g., a `markets:read` key calling `POST /v1/trades` which requires `trades:write`).\\n- A new endpoint was added that requires a scope your existing key doesn't carry.\\n\\n## How to fix\\n\\n- Look up the route's required scopes in `apps/public-api/README.md` → Authentication → Scopes table.\\n- Issue a new key with the needed scopes (or revoke + re-issue with a broader scope set).\\n- Principle of least privilege: don't add scopes you don't actually need — narrow keys reduce blast radius if leaked.\\n\\n| Scope | Routes |\\n| ----------------- | ----------------------------------------------------- |\\n| `markets:read` | `GET /v1/markets*`, `GET /v1/markets/:id/predictions` |\\n| `markets:quote` | `GET /v1/markets/:id/quote` |\\n| `trades:read` | `GET /v1/trades(/:id)` |\\n| `trades:write` | `POST /v1/trades`, `POST /v1/trades/:id/confirm` |\\n| `portfolio:read` | `GET /v1/portfolio*` |\\n| `webhooks:manage` | webhook URL/secret rotation, replay endpoint |\\n| `auth:manage` | self-service key CRUD |\\n\\n## Related codes\\n\\n- [`IP_NOT_ALLOWED`](./IP_NOT_ALLOWED.md) — also 403, but driven by IP allowlist rather than scope\\n\",\n \"INTERNAL_ERROR\": \"# `INTERNAL_ERROR`\\n\\n**HTTP status:** 500 · **Title:** \\\"Internal Server Error\\\"\\n\\n## When it fires\\n\\nThe error handler caught something it didn't recognise. The actual cause is logged + Sentry-captured server-side; nothing leaks to the caller.\\n\\nThis is the catch-all — any other code in this catalogue is a structured, anticipated failure. `INTERNAL_ERROR` means we hit something we hadn't categorised.\\n\\n## Why it happens\\n\\n- A genuinely unexpected condition (the most common reason this fires).\\n- A new code path that hasn't been instrumented with a typed error yet — please report it so we can add a proper code.\\n\\n## How to fix\\n\\n- **Always include `requestId` in any support ticket.** It's our handle into the trace, the logs, and the Sentry capture.\\n- Retry once — if it was a transient bug, you may get past it.\\n- If it's reproducible from a specific request, tell us the exact request shape (with secrets redacted) and we'll trace it.\\n- The TS SDK exposes `requestId` on `KashServerError`; raw HTTP callers should pull it from the `requestId` field on the problem response.\\n\\n## Related codes\\n\\nEvery other code in this catalogue is preferable — if you see `INTERNAL_ERROR` consistently for one operation, the right fix is for us to add a new typed code, not for you to work around it.\\n\",\n \"IP_NOT_ALLOWED\": \"# `IP_NOT_ALLOWED`\\n\\n**HTTP status:** 403 · **Title:** \\\"IP not allowed\\\"\\n\\n## When it fires\\n\\nThe key has a non-empty `ip_allowlist` configured AND the request's source IP isn't in it.\\n\\nEmpty allowlist (the default) means any IP is allowed — this code only fires when an allowlist is configured AND the caller's IP doesn't match.\\n\\n## Why it happens\\n\\n- The key is locked to a specific egress IP (common for MM and enterprise tier keys) and the call originated from elsewhere — your laptop, a CI runner, a proxy with a different egress.\\n- The egress IP changed (cloud provider rotated NAT gateway, your home ISP's dynamic IP rolled).\\n\\n## How to fix\\n\\n- Find the request's IP in the `requestId`'s Loki logs — search `requestId=\\\"<value>\\\"`, the structured log carries the source IP.\\n- If the new IP is legitimate, add it to the key's allowlist via Settings → API Keys → Edit (or `kash-admin api-keys` ops command).\\n- If it's not, this code is a successful detection of an exfiltration attempt — investigate.\\n\\n## Related codes\\n\\n- [`INSUFFICIENT_SCOPE`](./INSUFFICIENT_SCOPE.md) — also 403, scope rather than IP\\n\",\n \"MARKET_NOT_DISPUTABLE\": \"# `MARKET_NOT_DISPUTABLE`\\n\\n**HTTP status:** 422 · **Title:** \\\"Market not disputable\\\"\\n\\n## When it fires\\n\\n`POST /v1/markets/:id/disputes` was called for a market that has no resolution proposal in a disputable state. A market is disputable exactly while its resolution proposal's `settlementStatus` is `proposed` — i.e. until the proposal is **actually finalized (or cancelled) on-chain**, not merely until the nominal 45-minute window (`finalizableAt`) elapses. The `detail` field says which case you hit:\\n\\n- **\\\"no pending proposal\\\"** — no council verdict has been proposed on-chain yet; there is nothing to dispute.\\n- **\\\"already finalized\\\" / \\\"already disputed\\\" / \\\"already cancelled\\\"** — the proposal exists but has moved past `proposed`, so the dispute right has ended.\\n\\nThe dispute write is atomically gated on the live settlement state: even if the market's `resolution` object showed `proposed` when you checked, a filing that races an in-flight finalization is rejected with this code rather than recorded against a settled market.\\n\\n## Why it happens\\n\\n- The market is still trading — resolution hasn't been proposed yet.\\n- The proposal was already finalized (or cancelled) before your request landed — possibly between your read of the market state and your POST.\\n- The proposal already carries an on-chain dispute (`settlementStatus: disputed`).\\n\\n## How to fix\\n\\n- Check the market's current settlement state via `GET /v1/markets/:id` — the `resolution` object carries `settlementStatus` and `finalizableAt` (`null` when no proposal is pending).\\n- If `settlementStatus` is `proposed`, retry — you may have raced a state transition that has since resolved. Note that `finalizableAt` passing does **not** by itself end the dispute right: while `settlementStatus` remains `proposed` (for example when finalization is delayed), filings are still accepted.\\n- Once the proposal is finalized, the outcome is final; there is no late-dispute path through the API.\\n\\n## Related codes\\n\\n- [`DISPUTE_ALREADY_FILED`](./DISPUTE_ALREADY_FILED.md), [`RESOURCE_NOT_FOUND`](./RESOURCE_NOT_FOUND.md)\\n\",\n \"MARKET_NOT_FOUND\": \"# `MARKET_NOT_FOUND`\\n\\n**HTTP status:** 404 · **Title:** \\\"Market not found\\\"\\n\\n## When it fires\\n\\nThe `marketId` (UUID) you passed doesn't match any market.\\n\\n## Why it happens\\n\\n- Typo in the market id.\\n- Cross-environment confusion: a staging market id sent to production, or vice versa. Markets do not exist across environments.\\n- The market was never created — you may have a placeholder id from documentation rather than a real one.\\n- **Redemptions only:** the market exists but has no on-chain id yet (it hasn't been created on-chain), so it cannot be redeemed against. `POST /v1/redemptions` reports this as `MARKET_NOT_FOUND` — wait until the market is live on-chain.\\n\\n## How to fix\\n\\n- Look up real market ids via `GET /v1/markets?status=active` (filterable by status).\\n- Cache the id once you've found it; markets are immutable in their identity.\\n- If you suspect the market should exist (e.g., you saw it in the webapp), confirm you're hitting the same environment.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md) — market exists but is FROZEN/RESOLVED\\n\",\n \"MARKET_NOT_TRADEABLE\": \"# `MARKET_NOT_TRADEABLE`\\n\\n**HTTP status:** 409 · **Title:** \\\"Market not tradeable\\\"\\n\\n## When it fires\\n\\nThe market exists but is in a state that doesn't accept trades — typically `FROZEN` (admin paused trading) or `RESOLVED` (outcome decided, only redemptions allowed).\\n\\n## Why it happens\\n\\n- Market reached its `resolve_time` and froze automatically.\\n- An admin froze the market manually (e.g., pending an oracle dispute).\\n- The market was resolved between your quote call and your trade call.\\n\\n## How to fix\\n\\n- Check the market's current `status` via `GET /v1/markets/:id` before retrying.\\n- If the market was just resolved, your existing positions are still redeemable (handled automatically by the payout pipeline) — you don't need to do anything.\\n- For freeze-then-resume scenarios, retry once the market reopens; subscribe to the `market.resumed` webhook (when streaming lands) to know exactly when.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md)\\n\",\n \"ORGANIZATION_INACTIVE\": \"# `ORGANIZATION_INACTIVE`\\n\\n**HTTP status:** 403 · **Title:** \\\"Organization inactive\\\"\\n\\n## When it fires\\n\\nThe API key is valid, not revoked and not expired, but the organization it belongs to is\\n`suspended` or `cancelled`.\\n\\n## Why it happens\\n\\n- The organization was suspended (an abuse or admin action).\\n- The organization's subscription was cancelled.\\n\\nThese are the same statuses the Partner Portal refuses on every organization route.\\n`trial` and `active` organizations are unaffected.\\n\\n## How to fix\\n\\n- Contact support to restore the organization. The key starts working again as soon as the\\n organization's status is `trial` or `active`; it does not need to be reissued.\\n\\n## Related codes\\n\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — the key itself was withdrawn\\n- [`INSUFFICIENT_SCOPE`](./INSUFFICIENT_SCOPE.md) — also 403, scope rather than organization\\n\",\n \"OUTCOME_INDEX_INVALID\": \"# `OUTCOME_INDEX_INVALID`\\n\\n**HTTP status:** 400 · **Title:** \\\"Outcome index invalid\\\"\\n\\n## When it fires\\n\\n`outcomeIndex` is greater than or equal to the market's outcome count, or negative.\\n\\n## Why it happens\\n\\n- You hard-coded `outcomeIndex: 1` (assuming a binary market) and pointed at a multi-outcome market.\\n- Off-by-one: the field is **zero-indexed** — first outcome is `0`, not `1`.\\n- A bug computed the index from a label without bounds-checking.\\n\\n## How to fix\\n\\n- Look up the market via `GET /v1/markets/:id` and check `outcomes.length` — valid range is `[0, length - 1]`.\\n- Map outcome labels to indices once when you fetch the market, then use the index throughout your code.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md), [`VALIDATION_FAILED`](./VALIDATION_FAILED.md)\\n\",\n \"POSITION_NOT_CLAIMABLE\": \"# `POSITION_NOT_CLAIMABLE`\\n\\n**HTTP status:** 409 · **Title:** \\\"Position not claimable\\\"\\n\\n## When it fires\\n\\n`POST /v1/redemptions` was called for a `(marketId, outcomeIndex)` that is not currently claimable for your key's user. Claimability is decided by the same authority the webapp uses (the `claimable_rewards` view), so an accepted claim is one the payout pipeline can always fulfil.\\n\\n## Why it happens\\n\\n- **The outcome did not win.** On a RESOLVED market, only the winning outcome(s) pay out; losing-outcome tokens keep a non-zero on-chain balance but are worthless.\\n- **No refund for that outcome.** On a cancelled market, only outcomes with a non-zero cancel price are refundable.\\n- **The market is not in self-service mode.** It resolved before self-service redemption was enabled and was paid out automatically.\\n- **Already redeemed.** The position has already been paid out (on-chain) or has an in-flight payout.\\n- **A previous claim failed.** An earlier claim for this exact position ended in `status='failed'`. It still occupies the one request slot, so a new claim cannot be created through the API until an operator clears it — the `detail` says so explicitly.\\n\\n## How to fix\\n\\n- Redeem the **winning** outcome index. On a cancelled market, redeem an outcome you actually hold that carries a refund.\\n- If the market was auto-paid, your USDC is already settled — check `GET /v1/portfolio`.\\n- If you have an **in-flight** (non-failed) claim, a repeat returns `200` with that original request rather than this error.\\n- If a previous claim **failed**, contact support — the stuck request must be cleared before a new one can be made.\\n\\n## Related codes\\n\\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md), [`REDEMPTIONS_NOT_ENABLED`](./REDEMPTIONS_NOT_ENABLED.md)\\n\",\n \"RATE_LIMIT_EXCEEDED\": \"# `RATE_LIMIT_EXCEEDED`\\n\\n**HTTP status:** 429 · **Title:** \\\"Too Many Requests\\\"\\n\\n## When it fires\\n\\nPer-user (authenticated) or per-IP (anonymous reads) rate limit was exceeded. Limits are enforced as a sliding window in Redis.\\n\\nFor authenticated requests, the limit is **tier-differentiated** — 60 req/min on `free`, 300 req/min on `developer`, custom (admin-set) on `enterprise` and `mm`. See [`docs/api-tiers.md`](../api-tiers.md) for the full matrix.\\n\\n## Response headers\\n\\nEvery 429 response carries:\\n\\n- `Retry-After: <seconds>` — wait at least this long before the next request\\n- `X-RateLimit-Limit: <n>` — the limit that applied\\n- `X-RateLimit-Remaining: 0` — confirms you're capped\\n- `X-RateLimit-Reset: <unix-timestamp>` — when the window resets\\n\\n## Why it happens\\n\\n- Burst traffic spike (e.g., a polling loop without backoff).\\n- A retry storm — every consumer retried at the same instant after a transient failure.\\n- Genuine sustained traffic above your tier's quota.\\n\\n## How to fix\\n\\n- Honour `Retry-After`. The TS SDK does this automatically (`KashRateLimitError.retryAfterSeconds`).\\n- Add jitter to retry timing so multiple consumers don't synchronise.\\n- For polling, switch to webhooks — every poll is a wasted request.\\n- If you consistently bump against the limit during normal operation, upgrade your tier — see [`docs/api-tiers.md`](../api-tiers.md) for the upgrade paths. The 60 → 300 req/min jump from `free` → `developer` is 5×, and `enterprise`/`mm` have no application-level cap.\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_UNAVAILABLE`](./RATE_LIMIT_UNAVAILABLE.md) — 503; the rate-limit subsystem itself is temporarily unavailable. Distinct from `EXCEEDED`: `UNAVAILABLE` means we couldn't check your quota; `EXCEEDED` means we checked and you're over.\\n- [`WEBHOOK_REPLAY_LIMIT_REACHED`](./WEBHOOK_REPLAY_LIMIT_REACHED.md) — also 429, but specific to the webhook replay endpoint's amplification cap\\n\",\n \"RATE_LIMIT_UNAVAILABLE\": \"# `RATE_LIMIT_UNAVAILABLE`\\n\\n**HTTP status:** 503 · **Title:** \\\"Rate limit subsystem unavailable\\\" · **Retry-After:** 1s\\n\\n## When it fires\\n\\nThe rate-limit subsystem (Redis-backed token bucket / sliding window) is temporarily unavailable, and the per-task circuit breaker is still in its CLOSED state — i.e., this is a transient Redis blip, not a sustained outage.\\n\\nThe API fails CLOSED here because your rate limit IS your per-tier quota (a paid product feature). Serving traffic without enforcing the cap during a blip would silently leak quota to free-tier callers. The safe-for-business default is to refuse the request, surface a typed retryable error, and let your client retry.\\n\\nThis is the **transient blip** counterpart to the sustained-outage [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — same family, different policy: a transient blip in the limiter is fail-CLOSED (we know the limit must apply but can't check), while a sustained outage causes the circuit breaker to open and the API to fail-OPEN (serve traffic without quota enforcement, paged to ops).\\n\\n## Why it happens\\n\\n- A momentary ElastiCache Redis network blip — typically clears in `<100ms`.\\n- Pool acquisition contention under burst — a Lua call queued for a free connection past the per-call timeout.\\n- A momentary failover during a Redis cluster maintenance window.\\n\\nIf Redis stays unhealthy past the circuit breaker's failure threshold (10 consecutive failures), the breaker OPENs and requests start passing through fail-OPEN instead of returning 503. Ops gets paged on `kash_public_api_rate_limit_redis_circuit_state == 2`.\\n\\n## How to fix\\n\\n- **Honour `Retry-After: 1`.** The TS SDK's default retry policy already does this — it auto-retries 5xx responses with exponential backoff, so most blips are invisible to your application code. If you've disabled SDK retries (`maxRetries: 0`), wire your own backoff loop.\\n- **Don't treat this as quota exhaustion.** Distinct from [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) (429): `EXCEEDED` is \\\"you're over your tier's cap\\\"; `UNAVAILABLE` is \\\"we couldn't check, so we won't risk leaking your cap.\\\" Your dashboards / billing UI should NOT surface `RATE_LIMIT_UNAVAILABLE` as a quota event.\\n- If you see a sustained rate of these in your client logs (>1/min over several minutes), that's the threshold where Kash ops should already be paged — file a bug at https://github.com/KashDAO/sdk-typescript/issues with the requestId if you want a status check.\\n\\n## Response headers\\n\\n- `Retry-After: 1` — Stripe-style short retry; SDK respects it automatically.\\n- `X-API-Version`, `X-Request-Id` — standard cross-cutting headers.\\n- The `X-RateLimit-*` family is NOT emitted on a 503 (we don't have a quota answer to attach).\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) — 429, you went over your quota.\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — 503, infrastructure-level dependency outage outside the rate-limit path.\\n\",\n \"REDEMPTIONS_NOT_ENABLED\": \"# `REDEMPTIONS_NOT_ENABLED`\\n\\n**HTTP status:** 404 · **Title:** \\\"Redemptions not enabled\\\"\\n\\n## When it fires\\n\\n`POST /v1/redemptions` is called while self-service redemption is not live on the platform — the `redemption.self-service` feature flag is disabled, so resolved markets are paid out automatically (bulk fan-out) rather than by user-initiated claim.\\n\\n## Why it happens\\n\\n- Self-service redemption has not been rolled out to this environment yet (the default). Markets latch to `automated` payout at resolution and are paid without a claim.\\n- The feature was rolled back platform-wide.\\n\\nThis is distinct from the `api-redemptions-create` **kill switch** (a `503` ops lever that halts the endpoint temporarily): `REDEMPTIONS_NOT_ENABLED` means the capability is not enabled at all.\\n\\n## How to fix\\n\\n- Nothing to do as an integrator — your winnings are still paid out automatically once the market resolves. Poll `GET /v1/portfolio` for the settled balance.\\n- If you believe self-service redemption should be enabled for your environment, contact support.\\n\\n## Related codes\\n\\n- [`POSITION_NOT_CLAIMABLE`](./POSITION_NOT_CLAIMABLE.md)\\n\",\n \"REQUEST_SIGNATURE_INVALID\": \"# `REQUEST_SIGNATURE_INVALID`\\n\\n**HTTP status:** 401 · **Title:** \\\"Request signature invalid\\\"\\n\\n## When it fires\\n\\n`X-Kash-Signature` was present but did not verify against the API key plaintext over the canonical signing input `${ts}.${method}.${path}.${body}`.\\n\\nSigning is **opt-in**: requests without `X-Kash-Signature` skip this check entirely. Once present, it MUST verify.\\n\\n## Why it happens\\n\\n- The body was modified in transit (a transparent proxy that re-encoded JSON, an HTTP client that renormalised whitespace).\\n- The timestamp is outside the ±5 min tolerance (clock skew).\\n- The signature was computed over the wrong input — common mistake: signing the URL instead of the path-only, or omitting the leading slash on `path`.\\n- The wrong secret was used (e.g., the `webhook_secret` instead of the API key plaintext).\\n\\n## How to fix\\n\\n- Canonical input format: `${unixMillis}.${UPPERCASE_METHOD}.${pathWithLeadingSlash}.${rawBody}` (raw body bytes, not re-serialised JSON).\\n- Key the HMAC with the API key plaintext (the same value as `X-API-Key`).\\n- Header format: `X-Kash-Signature: t=<unixMillis>,v1=<hexHmacSha256>`.\\n- Sync your clock — NTP. If you're consistently 6+ minutes off, fix the host clock.\\n- Check the `signatureReason` extension field on the problem response — it pinpoints which check failed (`stale`, `mismatch`, `malformed`).\\n\\nSee `apps/public-api/README.md` § Optional request body signing for the full reference.\\n\\n## Related codes\\n\\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) / [`API_KEY_INVALID`](./API_KEY_INVALID.md) — earlier failure points\\n\",\n \"REQUEST_TIMEOUT\": \"# `REQUEST_TIMEOUT`\\n\\n**HTTP status:** 504 · **Title:** \\\"Gateway timeout\\\" · **Retry-After:** 5s\\n\\n## When it fires\\n\\nThe per-request server timeout fired (default 30 s). Bounded by `bodyLimit` + Fastify timeout config to keep slow upstreams from blocking healthy traffic.\\n\\n## Why it happens\\n\\n- An unusually slow downstream call (Postgres lock contention, RPC latency spike, etc.).\\n- A request that does too much work in one call — most reads complete in <100 ms.\\n- Network path degradation between you and our edge (rare but possible).\\n\\n## How to fix\\n\\n- Retry once with backoff. Most timeouts are transient.\\n- If you're consistently timing out on the same endpoint, narrow the request — pass `limit` parameters, fetch one resource at a time.\\n- For long-running operations, use webhooks instead of waiting on the response.\\n\\n## Related codes\\n\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\\n\",\n \"RESOURCE_NOT_FOUND\": \"# `RESOURCE_NOT_FOUND`\\n\\n**HTTP status:** 404 · **Title:** \\\"Not Found\\\"\\n\\n## When it fires\\n\\nA request for a specific resource (trade, key, webhook event, etc.) didn't find a matching row that the caller is authorised to see.\\n\\nThis is the generic catch-all 404. Specific resource types may have their own dedicated codes (e.g., [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md)) where there's value in distinguishing the resource type to the caller.\\n\\n## Why it happens\\n\\n- The id is wrong (typo, cross-environment confusion).\\n- The resource exists but belongs to a different user — we return 404 (not 403) deliberately, so attackers can't probe for the existence of resources they don't own.\\n- The resource was deleted.\\n\\n## How to fix\\n\\n- Re-check the id (capitalisation, hyphens, environment).\\n- If the resource should exist but you can't see it, confirm you're authenticated as the owner.\\n- For webhook events: events are retained for 90 days; older events return 404.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md) — specific case for markets\\n\",\n \"ROUTE_DISABLED\": \"# `ROUTE_DISABLED`\\n\\n**HTTP status:** 503 · **Title:** \\\"Route disabled\\\" · **Retry-After:** 60s\\n\\n## When it fires\\n\\nA single route is disabled by ops via a per-route kill switch. Distinct from the coarse [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) so SDK consumers can branch on \\\"my entire trading capability is offline\\\" vs \\\"just one endpoint.\\\"\\n\\nThe Problem detail's `extensions.flag` field carries the kill-switch name (e.g., `\\\"api-quotes-read\\\"`) so you can tell exactly which route is gated.\\n\\n## Why it happens\\n\\n- Targeted rollback after a route-specific bug shipped.\\n- Pre-deploy lockdown of one endpoint while the rest of the API stays live.\\n- Capacity-driven shedding (rare — usually we'd raise rate limits first).\\n\\n## How to fix\\n\\n- Honour `Retry-After`. The flag is binary; polling won't help.\\n- Read the `extensions.flag` field — if it's an endpoint you don't actually need, ignore it; other endpoints are fine.\\n- For mission-critical endpoints, contact support to understand the ETA.\\n\\n## Related codes\\n\\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) — coarser kill switch covering all trade writes\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — distinct: this is \\\"ops chose to disable\\\", that's \\\"infra is broken\\\"\\n\",\n \"SMART_ACCOUNT_NOT_PROVISIONED\": \"# `SMART_ACCOUNT_NOT_PROVISIONED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Smart account not provisioned\\\"\\n\\n## When it fires\\n\\nThe actor (user or organization) doesn't have a smart account row yet, so trades can't execute on-chain.\\n\\n## Why it happens\\n\\n- New account that hasn't completed the wallet-creation step in the webapp.\\n- An organization that's enabled the API but hasn't provisioned its own org-level smart account.\\n- The smart-account worker is backlogged — provisioning is async.\\n\\n## How to fix\\n\\n- For users: complete the wallet setup flow in the webapp (Settings → Wallet). Smart account provisioning is async — typically a few seconds.\\n- For orgs: an admin needs to provision the org smart account via the org settings page.\\n- If you provisioned recently and still see this error, retry after 30 s. If it persists past a minute, contact support with your `requestId`.\\n\\n## Related codes\\n\\n- [`WALLET_DELEGATION_NOT_ENABLED`](./WALLET_DELEGATION_NOT_ENABLED.md) — smart account exists but Privy delegation is off\\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md) — smart account provisioned but no USDC\\n\",\n \"SPENDING_LIMIT_EXCEEDED\": \"# `SPENDING_LIMIT_EXCEEDED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Spending limit exceeded\\\"\\n\\n## When it fires\\n\\nThe trade would exceed one of the API key's spending caps:\\n\\n- `per_trade_limit_usdc` — single-trade maximum\\n- `daily_spend_limit_usdc` — rolling 24-hour cumulative maximum across all trades\\n\\nThe `detail` field specifies which cap fired and by how much.\\n\\n## Why it happens\\n\\n- A bot iteration that computed a larger order than expected (e.g., signal scaling factor was off).\\n- Cumulative drift over a busy day finally pushed a moderate trade over the daily cap.\\n- The cap was lowered (by you or an admin) since you last designed your sizing logic.\\n\\n## How to fix\\n\\n- Inspect your key's caps in Settings → API Keys → Edit.\\n- For per-trade overruns: split the trade into smaller chunks under the cap.\\n- For daily overruns: wait for the rolling window to clear, or upgrade your tier (see \\\"How to raise the cap\\\" below).\\n- Programmatically: every key's caps are returned in the issuance response — cache them so your client knows the headroom.\\n\\n## How to raise the cap\\n\\nThe cap is set by your API key's tier. See [`docs/api-tiers.md`](../api-tiers.md) for the full tier matrix and rationale, then:\\n\\n- **`free` → `developer`** (10× headroom — $1k → $10k daily, $500 → $2.5k per-trade): email [support@kash.bot](mailto:support@kash.bot) for early access. Self-serve via Stripe is planned ([`docs/todo/public-surface/PUBLIC_API_TIER_BILLING.md`](../todo/public-surface/PUBLIC_API_TIER_BILLING.md)).\\n- **`developer` → `enterprise`** ($100k daily, $25k per-trade, custom rate limit): [contact sales](mailto:sales@kash.bot) — pricing is contractual.\\n- **MM-tier counterparty terms** ($1M daily, $50k per-trade): [contact partnerships](mailto:partnerships@kash.bot) — KYB and ToS attestation required; this is a relationship, not a price tier.\\n\\nThe caps are not punitive — they exist for blast-radius and AMM-stability reasons documented in [`docs/api-tiers.md`](../api-tiers.md). When you outgrow your tier, the cap is the signal to upgrade, not a problem to work around.\\n\\n## Related codes\\n\\n- [`AMOUNT_TOO_LARGE`](./AMOUNT_TOO_LARGE.md) — platform-wide cap, separate from your key's caps\\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md) — wallet balance too low (different failure surface)\\n\",\n \"TRADE_NOT_AWAITING_CONFIRMATION\": \"# `TRADE_NOT_AWAITING_CONFIRMATION`\\n\\n**HTTP status:** 409 · **Title:** \\\"Trade not awaiting confirmation\\\"\\n\\n## When it fires\\n\\n`POST /v1/trades/:id/confirm` was called for a trade that's not in `pending_confirmation` status.\\n\\n## Why it happens\\n\\n- The trade was already confirmed (you'll see this if you accidentally call confirm twice) — see also [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md).\\n- The trade was below your tier's high-value threshold and never required confirmation in the first place.\\n- The trade has progressed past confirmation into `pending` / `executing` / a terminal state.\\n- The confirmation window expired and the trade was auto-cancelled — see [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md).\\n\\n## How to fix\\n\\n- Check the trade's current `status` via `GET /v1/trades/:id`. The lifecycle is: `pending_confirmation` → `pending` → `executing` → `completed`/`failed`.\\n- If the trade is `completed`, there's nothing more to do.\\n- If the trade is `cancelled`, re-create it and confirm within the new window.\\n\\n## Related codes\\n\\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md)\\n\",\n \"VALIDATION_FAILED\": \"# `VALIDATION_FAILED`\\n\\n**HTTP status:** 400 · **Title:** \\\"Validation failed\\\"\\n\\n## When it fires\\n\\nThe request body failed Zod schema parse. One or more fields are missing, malformed, or out of range.\\n\\n## Why it happens\\n\\n- Required field is missing or `null`.\\n- Field has the wrong type (e.g., `amount` sent as a number instead of a string — we use string for arbitrary-precision USDC amounts).\\n- Field violates a constraint (UUID format, enum value, min/max length).\\n- An unknown extra field was sent — we accept extras (`.passthrough()`) so this is rarely the cause; if it is, you'll see it called out in `detail`.\\n\\n## How to fix\\n\\n- The `detail` field carries the path of the first offending field (e.g., `body.outcomeIndex must be a non-negative integer`).\\n- Cross-reference with the OpenAPI spec at `https://api.kash.bot/v1/openapi.json` — every endpoint's request body is fully typed there.\\n- If you're using the TS SDK, you'd have caught this client-side via `KashValidationError` before the request went out.\\n\\n## Example\\n\\n```json\\n{\\n \\\"type\\\": \\\"https://docs.kash.bot/developer-docs/api-errors/VALIDATION_FAILED\\\",\\n \\\"title\\\": \\\"Validation failed\\\",\\n \\\"status\\\": 400,\\n \\\"code\\\": \\\"VALIDATION_FAILED\\\",\\n \\\"detail\\\": \\\"body.amount must be a string matching ^[0-9]+(\\\\\\\\.[0-9]+)?$\\\",\\n \\\"instance\\\": \\\"/v1/trades\\\",\\n \\\"requestId\\\": \\\"01HX-...\\\"\\n}\\n```\\n\\n## Related codes\\n\\n- [`AMOUNT_TOO_LARGE`](./AMOUNT_TOO_LARGE.md) — specific case for `amount` exceeding the per-trade cap\\n- [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md) — specific case for invalid `outcomeIndex`\\n\",\n \"WALLET_DELEGATION_NOT_ENABLED\": \"# `WALLET_DELEGATION_NOT_ENABLED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Wallet delegation not enabled\\\"\\n\\n## When it fires\\n\\nA smart account exists for the actor, but Privy server-side delegation is disabled. The platform can't sign user-operations on behalf of the user, so trades can't execute.\\n\\nWe surface this at the API layer as a preflight check rather than failing at execution — better UX for the consumer.\\n\\n## Why it happens\\n\\n- The user revoked delegation in Privy after onboarding.\\n- A Privy app config change disabled delegation for a class of users.\\n- An incomplete onboarding flow that skipped the delegation grant step.\\n\\n## How to fix\\n\\n- The user must re-grant delegation via the webapp (Settings → Wallet → Re-enable). One-click; uses the existing Privy session.\\n- For orgs, the org admin re-grants on behalf of the org wallet.\\n- If the issue is platform-wide (multiple users), check the Privy app config or the status page.\\n\\n## Related codes\\n\\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md) — earlier failure point\\n\",\n \"WEBHOOK_REPLAY_LIMIT_REACHED\": \"# `WEBHOOK_REPLAY_LIMIT_REACHED`\\n\\n**HTTP status:** 429 · **Title:** \\\"Replay limit reached\\\"\\n\\n## When it fires\\n\\n`POST /v1/webhooks/events/{id}/redeliver` was called for an event that has already been replayed up to its cap (default 5).\\n\\n## Why it happens\\n\\n- A bug in the consumer kept calling the redeliver endpoint in a loop instead of fixing the underlying delivery problem.\\n- An automation re-replayed without checking past attempts.\\n- A genuinely persistent need to replay an event many times — uncommon and usually signals an upstream issue.\\n\\nThe cap exists to stop a compromised key from looping the replay endpoint to flood its own customer endpoint or our delivery worker.\\n\\n## How to fix\\n\\n- Stop calling the redeliver endpoint for this event id. The cap is per-event, so other events still replay normally.\\n- Fix the underlying delivery failure first — check the event's delivery status via `GET /v1/trades/:id` (the `webhookDelivery` field) or via `kash-admin webhooks trace --event-id <id>` (operator command).\\n- If you legitimately need more replays for one event (e.g., recovering from a long endpoint outage), contact support — we can clear the counter manually.\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) — general per-key rate limit\\n\",\n \"WEBHOOK_SECRET_ROTATION_COOLDOWN\": \"# `WEBHOOK_SECRET_ROTATION_COOLDOWN`\\n\\n**HTTP status:** 429 · **Title:** \\\"Rotation cooldown active\\\"\\n\\n## When it fires\\n\\n`POST /v1/auth/api-keys/me/webhook-secret/rotate` was called within the 60-second cooldown that follows a previous successful rotation.\\n\\n## Why the cooldown exists\\n\\nThis is the most important error to read carefully — the cooldown is not a rate-limit, it is a **rollback-safety** guard.\\n\\nEvery successful rotation moves the previous secret into `webhook_secret_previous` so operations can roll back within 7 days if the rotation breaks the customer's verifier. If you rotate twice in quick succession, the second rotation overwrites that rollback slot with the FIRST rotation's brand-new secret — a secret you may have never actually received in your response (e.g., the first POST timed out, your HTTP client retried, and the new plaintext from the first attempt was lost in flight). Rolling back later would restore a secret no verifier was ever configured for.\\n\\nThe cooldown forces this dangerous case into a visible 429 instead of silently corrupting the rollback guarantee.\\n\\n## How to fix\\n\\n- **If you successfully captured the new secret from the previous rotation:** wait for the `Retry-After` window to expire and call again. The cooldown is per-key, so other keys are unaffected.\\n- **If you did NOT capture the new secret from the previous rotation** (network timeout, lost response, dropped connection): **do not retry**. Contact support so we can rotate via an operator path that preserves the rollback chain. Re-rotating yourself would replace `webhook_secret_previous` with the secret you never received, breaking the only recovery path.\\n\\n## Response headers\\n\\n- `Retry-After: <seconds>` — how long to wait before re-attempting (computed from the prior rotation's timestamp).\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) — generic per-user/per-IP rate limit (volume-based, not safety-based)\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — Postgres or downstream temporarily unreachable; safe to retry\\n\",\n});\n\nexport type ApiErrorCode = keyof typeof API_ERROR_DOCS;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAaA,SAAS,eAAe;;;ACFjB,IAAM,iBAAmD,OAAO,OAAO;AAAA,EAC5E,4BAA4B;AAAA,EAC5B,oBAAoB;AAAA,EACpB,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,+BAA+B;AAAA,EAC/B,2BAA2B;AAAA,EAC3B,uBAAuB;AAAA,EACvB,8BAA8B;AAAA,EAC9B,+BAA+B;AAAA,EAC/B,wCAAwC;AAAA,EACxC,0CAA0C;AAAA,EAC1C,yCAAyC;AAAA,EACzC,kCAAkC;AAAA,EAClC,iCAAiC;AAAA,EACjC,6BAA6B;AAAA,EAC7B,gCAAgC;AAAA,EAChC,yBAAyB;AAAA,EACzB,+BAA+B;AAAA,EAC/B,2BAA2B;AAAA,EAC3B,sCAAsC;AAAA,EACtC,kCAAkC;AAAA,EAClC,mCAAmC;AAAA,EACnC,6BAA6B;AAAA,EAC7B,wCAAwC;AAAA,EACxC,wBAAwB;AAAA,EACxB,8BAA8B;AAAA,EAC9B,2BAA2B;AAAA,EAC3B,0BAA0B;AAAA,EAC1B,yBAAyB;AAAA,EACzB,4BAA4B;AAAA,EAC5B,2BAA2B;AAAA,EAC3B,kCAAkC;AAAA,EAClC,4BAA4B;AAAA,EAC5B,mCAAmC;AAAA,EACnC,wBAAwB;AAAA,EACxB,sBAAsB;AAAA,EACtB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,yBAAyB;AAAA,EACzB,oBAAoB;AAAA,EACpB,wBAAwB;AAAA,EACxB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,0BAA0B;AAAA,EAC1B,uBAAuB;AAAA,EACvB,0BAA0B;AAAA,EAC1B,2BAA2B;AAAA,EAC3B,6BAA6B;AAAA,EAC7B,mBAAmB;AAAA,EACnB,sBAAsB;AAAA,EACtB,kBAAkB;AAAA,EAClB,iCAAiC;AAAA,EACjC,2BAA2B;AAAA,EAC3B,mCAAmC;AAAA,EACnC,qBAAqB;AAAA,EACrB,iCAAiC;AAAA,EACjC,gCAAgC;AAAA,EAChC,oCAAoC;AACtC,CAAC;;;ADnDD,IAAM,oBAAoB;AAS1B,SAAS,mBAAmB,MAAkC;AAC5D,SAAO,eAAe,IAAI;AAC5B;AAEO,IAAM,iBAAiB,IAAI,QAAQ,SAAS,EAChD,YAAY,uEAAuE,EACnF,SAAS,cAAc,wDAAwD,EAC/E;AAAA,EACC;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAMF,EACC,OAAO,CAAC,OAA0B,OAAO,QAAiB;AACzD,QAAM,UAAU,YAAY,GAAG;AAE/B,MAAI,MAAM,WAAW,GAAG;AACtB,QAAI,QAAQ,MAAM;AAChB,gBAAU,EAAE,OAAO,cAAc,CAAC;AAClC;AAAA,IACF;AACA,UAAM,QAAQ,YAAY,CAAC,QAAQ,WAAW,aAAa,CAAC;AAC5D,eAAW,SAAS,eAAe;AACjC,YAAM,KAAK;AAAA,QACT,MAAM;AAAA,QACN,SAAS,MAAM,SAAS,EAAE;AAAA,QAC1B,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,MAAM,IAAI,IAAI;AAAA,MAC3D,CAAC;AAAA,IACH;AACA,UAAM,MAAM,SAAS,CAAC;AACtB;AAAA,EACF;AAQA,QAAM,UAAsB,MAAM,IAAI,CAAC,SAAS;AAC9C,UAAM,QAAQ,gBAAgB,IAAI;AAClC,QAAI,MAAO,QAAO,EAAE,QAAQ,OAAO,MAAM;AAMzC,UAAM,OAAO,mBAAmB,IAAI;AACpC,QAAI,KAAM,QAAO,EAAE,QAAQ,OAAO,MAAM,KAAK;AAC7C,UAAM,IAAI,SAAS,uBAAuB,IAAI,IAAI;AAAA,MAChD,MAAM;AAAA,MACN,aAAa;AAAA,MACb,YAAY;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AAED,MAAI,QAAQ,MAAM;AAIhB,UAAM,UAAU,QAAQ;AAAA,MAAI,CAAC,MAC3B,EAAE,WAAW,QACT,EAAE,QACF;AAAA,QACE,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,SAAS,GAAG,iBAAiB,IAAI,EAAE,IAAI;AAAA,QACvC,MAAM,EAAE;AAAA,MACV;AAAA,IACN;AACA,cAAU,QAAQ,WAAW,IAAI,QAAQ,CAAC,IAAI,EAAE,OAAO,QAAQ,CAAC;AAChE;AAAA,EACF;AAEA,aAAW,CAAC,GAAG,QAAQ,KAAK,QAAQ,QAAQ,GAAG;AAC7C,QAAI,IAAI,EAAG,OAAM,EAAE;AACnB,UAAM,EAAE;AACR,QAAI,SAAS,WAAW,OAAO;AAG7B,YAAM,SAAS,IAAI;AACnB,YAAM,MAAM,IAAI,SAAS,iBAAiB,IAAI,SAAS,IAAI,EAAE,CAAC;AAC9D;AAAA,IACF;AACA,UAAM,EAAE,MAAM,IAAI;AAClB,UAAM,GAAG,MAAM,KAAK,MAAM,IAAI,CAAC,EAAE;AACjC,UAAM,KAAK,MAAM,IAAI,aAAa,CAAC,IAAI,MAAM,OAAO,EAAE;AACtD;AAAA,MACE,KAAK,MAAM,IAAI,aAAa,CAAC,IAAI,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,MAAM,IAAI,IAAI,CAAC;AAAA,IAC7F;AACA,QAAI,MAAM,SAAS;AACjB,YAAM,KAAK,MAAM,IAAI,aAAa,CAAC,IAAI,MAAM,OAAO,EAAE;AAAA,IACxD;AACA,UAAM,EAAE;AACR,UAAM,MAAM,WAAW;AACvB,QAAI,MAAM,QAAQ,SAAS,GAAG;AAC5B,YAAM,EAAE;AACR,YAAM,MAAM,KAAK,mBAAmB,CAAC;AACrC,iBAAW,UAAU,MAAM,SAAS;AAClC,cAAM,OAAO,aAAa,MAAM,CAAC,EAAE;AAAA,MACrC;AAAA,IACF;AAAA,EACF;AACF,CAAC;AAEH,SAAS,aAAa,QAAgC;AACpD,UAAQ,OAAO,MAAM;AAAA,IACnB,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,OAAO,CAAC,WAAM,OAAO,WAAW;AAAA,IAC9D,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,QAAQ,CAAC,qBAAgB,OAAO,WAAW;AAAA,IACzE,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,QAAQ,OAAO,KAAK,MAAM,OAAO,UAAU,GAAI,CAAC,CAAC,GAAG,CAAC,WAAM,OAAO,WAAW;AAAA,IACpG,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,GAAG,CAAC,WAAM,OAAO,WAAW;AAAA,IAC1D,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,KAAK,CAAC,yBAAoB,OAAO,WAAW;AAAA,EAC5E;AACF;","names":[]}
@@ -5,9 +5,9 @@ import {
5
5
  parsePositiveFloat,
6
6
  parsePositiveInt,
7
7
  readGlobals
8
- } from "./chunk-A3IH5HUV.js";
9
- import "./chunk-KADB3OKE.js";
10
- import "./chunk-X2VM6VKM.js";
8
+ } from "./chunk-AROIP4EQ.js";
9
+ import "./chunk-VFBQVUL4.js";
10
+ import "./chunk-WL7XUYPH.js";
11
11
  export {
12
12
  parseOptionalPositiveFloat,
13
13
  parseOptionalPositiveInt,
@@ -15,4 +15,4 @@ export {
15
15
  parsePositiveInt,
16
16
  readGlobals
17
17
  };
18
- //# sourceMappingURL=global-options-WFQ6CEFK.js.map
18
+ //# sourceMappingURL=global-options-YONBFB7N.js.map
@@ -1,27 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildClient
4
- } from "./chunk-QALDZHTX.js";
4
+ } from "./chunk-H2QGFDZP.js";
5
5
  import {
6
6
  readConfig
7
- } from "./chunk-N26VGVGB.js";
8
- import "./chunk-BVFDUI73.js";
9
- import "./chunk-KAM6KOFD.js";
7
+ } from "./chunk-ZSMI63N3.js";
8
+ import "./chunk-2SR77BYA.js";
9
+ import "./chunk-6QSTFU4R.js";
10
10
  import {
11
11
  readGlobals
12
- } from "./chunk-A3IH5HUV.js";
12
+ } from "./chunk-AROIP4EQ.js";
13
13
  import {
14
14
  log,
15
15
  print,
16
16
  printJson,
17
17
  style
18
- } from "./chunk-MQUJG224.js";
19
- import "./chunk-KADB3OKE.js";
18
+ } from "./chunk-2HFKULGG.js";
19
+ import "./chunk-VFBQVUL4.js";
20
20
  import {
21
21
  CliError,
22
22
  EXIT_CODES,
23
23
  toCliError
24
- } from "./chunk-X2VM6VKM.js";
24
+ } from "./chunk-WL7XUYPH.js";
25
25
 
26
26
  // src/commands/health.ts
27
27
  import { Command } from "commander";
@@ -95,4 +95,4 @@ Examples:
95
95
  export {
96
96
  healthCommand
97
97
  };
98
- //# sourceMappingURL=health-2KD6XUTJ.js.map
98
+ //# sourceMappingURL=health-LSJKP3NH.js.map