{
  "openapi": "3.1.0",
  "info": {
    "title": "Gutter Tokens Management API",
    "version": "1.0.0",
    "summary": "Register, fund, mint, list, cap and revoke relay keys — without a browser.",
    "description": "A wallet signature and a proof of work, exchanged for a `gtm_` platform\nkey — a bearer credential an agent can hold and use on its own, with no\nsession, no cookie and no CSRF token anywhere on this surface.\n\n**This is a separate document from the one at\n<https://app.guttertokens.com/openapi.json>.** That one describes the relay —\n`POST /v1/chat/completions` and friends, `Authorization: Bearer sk-…` — where\ncompletions are served and billed. This one describes the control surface that\nmints and manages the `sk-` keys the relay accepts.\n\n**Both are served from `https://api.guttertokens.com`, on disjoint path\nprefixes: `/v1/` is the relay's, `/manage/v1/` is this one's.** They share a\nhostname and nothing else: a different credential (`gtm_` rather than `sk-`),\na different audience (whatever is minting and rotating keys, not whatever is\ncalling the model), and a different rate-limit budget. `/v1/` is OpenAI's\nversion number rather than ours, which is why this surface carries its own.\n\n**The lifecycle in order.**\n\n1. `POST /manage/v1/auth/challenge` — get a wallet nonce to sign, a proof-of-work\n   challenge to solve, and the terms version currently in force. No\n   credential required; nothing is looked up yet.\n2. Sign the nonce with an Ethereum private key (`personal_sign`, EIP-191) and\n   solve the proof of work (see that operation's own description for the\n   exact algorithm — enough detail to implement without reading ALTCHA's own\n   docs). Then either `POST /manage/v1/auth/register` — for a wallet with no\n   account yet, which creates one and returns a `gtm_` platform key in the\n   same call, and is safe to call again because an already-registered wallet\n   is issued a key rather than refused — or `POST /manage/v1/auth/token` —\n   for a wallet that already has an account, which creates nothing. **Either\n   way the returned `token` is shown exactly once, in that response, and\n   cannot be retrieved again.** Store it before doing anything else with it.\n3. `GET /manage/v1/funding` — see where to send money on every chain we\n   watch, and watch a transfer arrive and become spendable, with the `gtm_`\n   key from step 2.\n4. `POST /manage/v1/keys` — mint an `sk-` relay key with that same `gtm_`\n   key. `GET /manage/v1/keys`, `PATCH/DELETE /manage/v1/keys/{id}`,\n   `GET /manage/v1/management-keys` and `DELETE /manage/v1/management-keys/{id}`\n   are also bearer auth with it: `Authorization: Bearer gtm_…`.\n5. Call the relay — `https://api.guttertokens.com/v1/…`, a separate document\n   (see above) — with the `sk-` key from step 4.\n\n**What this surface cannot do, on purpose.** There is no route that mints a\nsecond management key from an authenticated one (no `POST\n/manage/v1/management-keys`; that path 405s), and no route that reveals a `gtm_`\nkey or an already-minted `sk-` key's secret ever again. A `gtm_` key is\nSHA-256 hashed at rest and the plaintext is never stored anywhere, including\nhere — see the wallet door's own description for why that is a genuinely\nunrecoverable credential, not merely an inconvenient one.\n\n**Errors.** One shape on every failure, whichever endpoint produced it:\n`{\"error\":{\"type\",\"code\",\"message\"}}` — see the `Error` schema. This\nincludes a route that does not exist (`404`, code `not_found`) and a method\nnot allowed on one that does (`405`, code `method_not_allowed`): every\nresponse from this host carries the same envelope, not just the ones a\nhandler wrote by hand.\n\n**Rate limits.** Stated per operation below, because they are not uniform.\n\"Source IP\" and \"source address\" are deliberately different words below for\ndifferent things: IP is the network address a request arrived from, address\nis the Ethereum wallet address in the request body — the two are\nindependent, and only `/manage/v1/auth/register` is bounded by both at once.\nThe short version: issuing a wallet challenge is capped at 20/hour per\nsource IP (it costs one Argon2id derivation to compute), registering a\nnew account is capped at 5/hour per source address AND 20/hour per source\nIP, minting or revoking a `gtm_` key is capped at 10/hour per source IP\nshared between `/manage/v1/auth/token` and `/manage/v1/auth/revoke`, every\nauthenticated call is capped at 120/minute per credential, minting an `sk-`\nrelay key is separately capped at 20/hour per account, reading live spend\nfigures (`GET /manage/v1/keys?live=1`) is capped at 30/hour per account, and\npolling `GET /manage/v1/funding` is capped at 60/hour per account — a more\ngenerous budget than a one-off write, because polling is the expected use.\nA `429` carries `Retry-After`; honour it rather than inventing a backoff.\n\nAgents: read the acceptable-use policy before automating anything here.\nCreating more than one account, minting keys in a loop, or solving proofs of\nwork in advance of needing them are all abuse, not enthusiasm — see\n<https://app.guttertokens.com/llms.txt>.\n",
    "termsOfService": "https://app.guttertokens.com/legal/terms",
    "contact": {
      "name": "Gutter Tokens support",
      "email": "support@guttertokens.com",
      "url": "https://app.guttertokens.com/support"
    }
  },
  "externalDocs": {
    "description": "The agent guide — the whole lifecycle, human steps marked as such",
    "url": "https://app.guttertokens.com/llms.txt"
  },
  "servers": [
    {
      "url": "https://api.guttertokens.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Wallet door",
      "description": "Unauthenticated: prove a wallet and a proof of work, get a `gtm_` key — or, for a wallet with no account yet, an account and a `gtm_` key together."
    },
    {
      "name": "Funding",
      "description": "Where to send money, whether it arrived, and whether it can be spent — read with a `gtm_` key."
    },
    {
      "name": "Relay keys",
      "description": "Mint, list, cap and revoke the `sk-` keys the relay accepts."
    },
    {
      "name": "Management keys",
      "description": "List and revoke `gtm_` platform keys, including the one in use."
    }
  ],
  "paths": {
    "/manage/v1/auth/challenge": {
      "post": {
        "tags": ["Wallet door"],
        "operationId": "issueWalletChallenge",
        "summary": "Issue a wallet nonce and a proof-of-work challenge",
        "security": [],
        "description": "No lookup happens here at all — not even to check the address is\nenrolled. Refusing early for an unknown address would answer \"does this\nwallet bank here?\" for any address on the chain, which is linkable to\non-chain activity in a way an email address is not. Every address gets a\nperfectly good challenge and is refused later, at `/manage/v1/auth/register`,\n`/manage/v1/auth/token`, or `/manage/v1/auth/revoke`, with the same words an\nunknown wallet, a bad signature and a banned account all get — except on\n`/manage/v1/auth/register`, where an unknown wallet is not a refusal at all;\nsee that operation's own description.\n\n**Rate limit:** 20 per hour, per source IP. Issuing one costs us a\nreal Argon2id derivation (about 17.5ms and 32MiB) to compute the proof-of-\nwork prefix, so this bounds CPU, not merely request volume.\n\n### The wallet nonce\n\n`message` is EIP-191 `personal_sign` text, laid out like an EIP-4361 (SIWE)\nmessage — same header, address, and `URI:`/`Version:`/`Chain ID:`/`Nonce:`/\n`Issued At:`/`Expiration Time:` block. It is not valid SIWE: the statement is\nhard-wrapped over two lines, which the ABNF forbids, so a strict parser will\nreject it. Sign the exact bytes; do not parse it. Send the signature to\n`/manage/v1/auth/register`, `/manage/v1/auth/token`, or `/manage/v1/auth/revoke`\n— never send the message back, it is not parsed. The\ntop-level `expires_at` on THIS response is when the NONCE expires (300\nseconds from issuance) — a separate clock from `altcha.parameters.expiresAt`\nbelow, which times out the proof of work instead. Both are single-use:\nthe nonce is consumed the instant a signature over it is checked, win or\nlose, and the proof is burned the instant it verifies.\n\n### The proof of work\n\nA plain Argon2id cost function, not a bot detector — it is meant to be\nsolvable by a machine. This section is everything needed to implement a\nsolver without reading ALTCHA's own documentation.\n\n`altcha` in the response is:\n\n```json\n{\n  \"parameters\": {\n    \"algorithm\": \"ARGON2ID\",\n    \"cost\": 1,\n    \"keyLength\": 32,\n    \"keyPrefix\": \"<32 hex chars — 16 bytes>\",\n    \"nonce\": \"<32 hex chars — 16 bytes>\",\n    \"salt\": \"<32 hex chars — 16 bytes>\",\n    \"keySignature\": \"<hex HMAC, opaque, echo back unchanged>\",\n    \"memoryCost\": 32768,\n    \"parallelism\": 1,\n    \"expiresAt\": 1234567890\n  },\n  \"signature\": \"<hex HMAC over the parameters above, opaque, echo back unchanged>\"\n}\n```\n\nTo solve it:\n\n1. Decode `nonce` and `salt` from hex to raw bytes.\n2. Starting at `counter = 0` and counting up by 1, for each `counter`:\n   - Build the password: `nonce_bytes + pack_uint32_big_endian(counter)`\n     (4 more bytes appended to the 16-byte nonce — 20 bytes total).\n   - Derive a key with Argon2id: output length `keyLength` bytes (32),\n     time cost (iterations) `cost` (1), memory cost `memoryCost` **KiB**\n     — libsodium's `crypto_pwhash`/`sodium_crypto_pwhash` takes memlimit\n     in BYTES, so multiply by 1024 (32768 KiB = 32MiB = 33554432 bytes) if\n     you are calling it directly; a library with a KiB-native memory-cost\n     parameter (argon2-cffi, most Node Argon2 bindings) takes the number\n     as published. `parallelism` is fixed at 1 to match libsodium's own\n     Argon2id, which always runs single-lane — set it explicitly if your\n     library exposes the knob, otherwise ignore it.\n   - Take the first `len(keyPrefix) / 2` bytes of the derived key (16\n     bytes today — derive the count from the hex string's length rather\n     than hardcoding 16, in case it changes) and hex-encode them.\n   - If that matches `keyPrefix` exactly, stop: this `counter` and the\n     FULL derived key (all `keyLength` bytes, hex-encoded) are the\n     solution.\n3. There is no published lower bound to start from — start at 0 regardless.\n   The server's own difficulty choice is not revealed, and in production it\n   costs on the order of 60–120 derivations (roughly 1–2 seconds on a\n   native Argon2id implementation, more on WebAssembly) — that is the\n   intended cost, not a bug in a solver that takes that long.\n4. Build the payload and base64-encode it:\n\n   ```json\n   {\n     \"challenge\": { \"parameters\": { /* the exact object received, unmodified */ }, \"signature\": \"<the exact signature received>\" },\n     \"solution\": { \"counter\": 87, \"derivedKey\": \"<64 hex chars — the full 32-byte derived key>\" }\n   }\n   ```\n\n   The whole `challenge` object — `parameters` AND `signature` — must be\n   echoed back byte-for-byte; it is itself part of what gets verified. The\n   result of `base64_encode(json_encode(payload))` is the string sent as\n   `altcha` in the request body of `/manage/v1/auth/register`, `/manage/v1/auth/token`,\n   and `/manage/v1/auth/revoke`.\n\nA challenge is single-use (verifying it once burns it, atomically) and\nembeds its own 600-second expiry in `parameters.expiresAt`. Solve it only\nwhen you are about to submit the next request — solving a batch in advance\nto spend later is read as intent to abuse, not efficiency, and the single-\nuse rule means the spares would not work anyway.\n\n### The terms version\n\n`terms_version` is the version currently in force, and `terms_url` is where\nto read it. If the next call is `POST /manage/v1/auth/register`, its\nrequired `terms_version` field must be this exact value — a stale one\n(including a formerly-correct one that has since been superseded) is a\n`422`. Fetched here rather than hardcoded so the acknowledgement is informed\nby this round trip, not by a constant an agent's author copied at some\npoint in the past.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true,
                "required": ["address"],
                "properties": {
                  "address": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "An Ethereum address, any case. Checksum is not required."
                  }
                }
              },
              "examples": {
                "default": {
                  "value": { "address": "0x1234567890abcdef1234567890abcdef12345678" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A wallet nonce to sign and a proof-of-work challenge to solve. Issued\nunconditionally — this is not evidence the address is enrolled.\n",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WalletChallenge" }
              }
            }
          },
          "422": {
            "description": "`address` is missing, too long, or not shaped like an Ethereum address.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": {
                  "error": {
                    "type": "invalid_request_error",
                    "code": "invalid_address",
                    "message": "That is not an Ethereum address."
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/auth/register": {
      "post": {
        "tags": ["Wallet door"],
        "operationId": "createAccountFromWalletSignature",
        "summary": "Create an account from a wallet signature, and mint its first gtm_ key",
        "security": [],
        "description": "**This is the one operation on the whole platform that creates an\naccount from nothing** — no email, no password, no browser. The wallet's\nprivate key becomes the account's only credential the instant this call\nsucceeds: there is no password to reset and no support path back in if it\nis lost, so decide where that key lives before generating one. This is\ngenuinely unrecoverable, not merely inconvenient — nothing about this\naccount is tied to anything but the ability to sign with that key.\n\n`terms_version` is required and must be EXACTLY the value the immediately\npreceding `POST /manage/v1/auth/challenge` returned — including a value that\nwas correct once but has since been superseded. A stale version is a `422`,\nwith the same `invalid_request` code every other validation failure on this\ndoor uses.\n\n**A wallet with no account yet gets one, rather than a refusal that would\nanswer \"does this address bank here?\" for any address on the chain.** An\nEXISTING account is a different case: it is refused (with the same `422`\ndescribed below) if it is banned or has not verified an email carried over\nfrom before it held a wallet — this door does not exempt an existing account\nfrom either check just because the caller proved the wallet. The practical\neffect for the ordinary case is that this call is safe to repeat: an agent\nthat cannot remember whether it has already registered can call this again\nand receive a working key either way, at the cost of one more key on the\naccount to track and eventually revoke.\n\nMoney already sent FROM this wallet — as the on-chain sender, to one of our\nreceiving addresses — before this call runs is claimed as part of it, on a\nbest-effort basis. Attribution on the funding side is by SENDER, not\ndestination, so a transfer sent before the account existed does not have to\nwait for the next attribution pass.\n\nThe wallet nonce and the proof of work are both spent by this call, win or\nlose, the moment they are checked — exactly like `/manage/v1/auth/token`: a\nfailed attempt over a different field (a missing `name`, a stale\n`terms_version`) does not cost either one, because the form is validated in\nfull before either is touched.\n\n**Rate limit:** 5 per hour per source address, AND 20 per hour per source\nIP — both apply. The address ceiling matches account creation everywhere\nelse on this platform: each account eventually costs a login from a budget\nshared by every customer, so signing up in bulk is treated as abuse rather\nthan enthusiasm. The IP ceiling exists because the address arrives in the\nrequest body and costs an attacker nothing to vary.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true,
                "required": ["address", "signature", "altcha", "name", "terms_version"],
                "properties": {
                  "address": { "type": "string", "maxLength": 64 },
                  "signature": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "EIP-191 `personal_sign` over the exact `message` from `/manage/v1/auth/challenge`."
                  },
                  "altcha": {
                    "type": "string",
                    "description": "The base64 proof-of-work payload — see /manage/v1/auth/challenge's description."
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "What to call the first management key this account gets."
                  },
                  "terms_version": {
                    "type": "string",
                    "description": "Must equal the `terms_version` the immediately preceding `POST /manage/v1/auth/challenge` returned. Anything else, including a version that used to be correct, is a 422."
                  },
                  "ref": {
                    "type": ["string", "null"],
                    "maxLength": 16,
                    "description": "Optional referral code."
                  }
                }
              },
              "examples": {
                "default": {
                  "value": {
                    "address": "0x1234567890abcdef1234567890abcdef12345678",
                    "signature": "0x...",
                    "altcha": "eyJjaGFsbGVuZ2Ui...",
                    "name": "ci-runner",
                    "terms_version": "1.0"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "A new account (or the existing one, if this wallet already had one) and a new gtm_ key, in the clear, for the only time it will ever appear.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ManagementKeyIssued" }
              }
            }
          },
          "422": {
            "description": "One of three things: the form failed validation (a missing or malformed\nfield, or `terms_version` not matching the version currently in force);\nthe wallet/proof exchange was refused; or the proof of work was rejected.\n\n**An unknown wallet is never a refusal cause here** — creating an account\nfor exactly that wallet is this operation's whole purpose, and it succeeds\nwith `201` instead. The wallet/proof refusal fires for only three reasons:\nan expired, already-used, or never-issued wallet nonce; a signature that\ndoes not verify against it; or an EXISTING account the sign-in gate refuses\n— banned, or (rarer, for a wallet enrolled on an account originally opened\nby email) an unverified email address. All three read identically:\n`{\"wallet\": \"That wallet did not match an account.\"}` summarised into\n`error.message` — this door never says which.\n\nA failed or already-spent proof of work is a SEPARATE refusal, under the\nkey `altcha` rather than `wallet`: `{\"altcha\": \"That verification could\nnot be checked. Request a new challenge and try again.\"}` — it never\ncarries the wallet wording above.\n",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "walletRefused": {
                    "summary": "Bad signature, dead nonce, or an existing account the sign-in gate refuses",
                    "value": {
                      "error": {
                        "type": "invalid_request_error",
                        "code": "invalid_request",
                        "message": "That wallet did not match an account."
                      }
                    }
                  },
                  "proofFailed": {
                    "summary": "The proof of work failed or was already spent",
                    "value": {
                      "error": {
                        "type": "invalid_request_error",
                        "code": "invalid_request",
                        "message": "That verification could not be checked. Request a new challenge and try again."
                      }
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/auth/token": {
      "post": {
        "tags": ["Wallet door"],
        "operationId": "exchangeWalletSignatureForManagementKey",
        "summary": "Exchange a signed wallet nonce and a solved proof for a gtm_ key",
        "security": [],
        "description": "Mints a `gtm_` platform key for the account that holds the signing wallet.\n**This surface creates no accounts** — a signature from a wallet with no\naccount is refused with the same generic message as a wrong signature, an\nexpired nonce, or an existing account the sign-in gate refuses (banned, or\nan unverified email address). There is no way to tell those apart from the\nresponse on purpose: distinguishing \"unknown wallet\" from \"wrong\nsignature\" is free information about which guess was closer, and\ndistinguishing a ban from either would let an unauthenticated caller learn\nan address is both enrolled AND banned.\n\nThe wallet nonce and the proof of work are both spent by this call,\nwin or lose, the moment they are checked — a failed attempt over a\ndifferent field (a missing `name`, say) does not cost either one, because\nthis form is validated in full BEFORE either is touched.\n\n`expires_at`, if given, must be in the future; omit it for a key that\nnever expires. **The returned `token` is shown exactly once, in this\nresponse. It cannot be retrieved again — not from this API, not from the\ndashboard, nowhere** — only its hash is ever stored. Losing it means\nrevoking it (`DELETE /manage/v1/management-keys/{id}`, or a fresh signature at\n`/manage/v1/auth/revoke`) and minting a replacement.\n\n**Rate limit:** 10 per hour per source IP, SHARED with\n`/manage/v1/auth/revoke` — both spend the same proof-of-work budget.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true,
                "required": ["address", "signature", "altcha", "name"],
                "properties": {
                  "address": { "type": "string", "maxLength": 64 },
                  "signature": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "EIP-191 `personal_sign` over the exact `message` from `/manage/v1/auth/challenge`."
                  },
                  "altcha": {
                    "type": "string",
                    "description": "The base64 proof-of-work payload — see /manage/v1/auth/challenge's description."
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "What to call this management key. Required — a key with no name is one nobody can safely revoke from a list."
                  },
                  "expires_at": {
                    "type": ["string", "null"],
                    "format": "date-time",
                    "description": "Optional. Must be in the future. Omit or send null for a key that never expires."
                  }
                }
              },
              "examples": {
                "default": {
                  "value": {
                    "address": "0x1234567890abcdef1234567890abcdef12345678",
                    "signature": "0x...",
                    "altcha": "eyJjaGFsbGVuZ2Ui...",
                    "name": "ci-runner"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "A new gtm_ key, in the clear, for the only time it will ever appear.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ManagementKeyIssued" }
              }
            }
          },
          "422": {
            "description": "The form failed validation, OR the wallet/proof exchange was refused —\nan unknown wallet, a wrong signature, an expired or already-used nonce, or\nan EXISTING account the sign-in gate refuses (banned, or an unverified\nemail address on an account that enrolled a wallet before verifying).\nAll of the latter read identically: `{\"wallet\": \"That wallet did not\nmatch an account.\"}` summarised into `error.message` — this door never\nsays which.\n\nA failed or already-spent proof of work is a SEPARATE refusal, under the\nkey `altcha` rather than `wallet`: `{\"altcha\": \"That verification could\nnot be checked. Request a new challenge and try again.\"}` — it never\ncarries the wallet wording above.\n",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "walletRefused": {
                    "summary": "Unknown wallet, bad signature, dead nonce, or an existing account the sign-in gate refuses",
                    "value": {
                      "error": {
                        "type": "invalid_request_error",
                        "code": "invalid_request",
                        "message": "That wallet did not match an account."
                      }
                    }
                  },
                  "proofFailed": {
                    "summary": "The proof of work failed or was already spent",
                    "value": {
                      "error": {
                        "type": "invalid_request_error",
                        "code": "invalid_request",
                        "message": "That verification could not be checked. Request a new challenge and try again."
                      }
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/auth/revoke": {
      "post": {
        "tags": ["Wallet door"],
        "operationId": "revokeManagementKeysByWalletSignature",
        "summary": "Revoke one gtm_ key, or every live one, with a fresh wallet signature",
        "security": [],
        "description": "The cold-recovery path: use this when the management key itself may be\nin somebody else's hands and only the wallet key is still trusted. This\nrevokes `gtm_` PLATFORM keys, never the `sk-` relay keys they manage —\nthere is no way to reach a relay key from this door at all.\n\nExactly one of `id` or `all` is required — sending neither is refused\nBEFORE the wallet signature and proof are spent, precisely so a malformed\nrequest does not burn either one for nothing. `all: true` revokes every\nlive key on the account and answers the count; `id` revokes one key BY\nid, scoped to the signing account's own keys — an id that belongs to\nnobody, to another account, or to an already-revoked key all answer\n`{\"revoked\": 0}` rather than an error, so this call is safe to retry.\n\n**Rate limit:** 10 per hour per source IP, SHARED with\n`/manage/v1/auth/token`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true,
                "required": ["address", "signature", "altcha"],
                "properties": {
                  "address": { "type": "string", "maxLength": 64 },
                  "signature": { "type": "string", "maxLength": 200 },
                  "altcha": { "type": "string" },
                  "id": {
                    "type": "integer",
                    "description": "Revoke this one management key. Required if `all` is not sent."
                  },
                  "all": {
                    "type": "boolean",
                    "description": "Revoke every live management key on the account. Required if `id` is not sent."
                  }
                }
              },
              "examples": {
                "byId": {
                  "summary": "Revoke one key",
                  "value": {
                    "address": "0x1234567890abcdef1234567890abcdef12345678",
                    "signature": "0x...",
                    "altcha": "eyJjaGFsbGVuZ2Ui...",
                    "id": 42
                  }
                },
                "lockdown": {
                  "summary": "Revoke everything on the account",
                  "value": {
                    "address": "0x1234567890abcdef1234567890abcdef12345678",
                    "signature": "0x...",
                    "altcha": "eyJjaGFsbGVuZ2Ui...",
                    "all": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "How many keys were revoked — 0 or 1 for `id`, any count for `all`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["revoked"],
                  "properties": {
                    "revoked": { "type": "integer", "minimum": 0 }
                  }
                },
                "example": { "revoked": 1 }
              }
            }
          },
          "422": {
            "description": "Neither `id` nor `all` was sent, OR the wallet/proof exchange was\nrefused — see /manage/v1/auth/token's identical 422 for the shared reasoning.\n",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/funding": {
      "get": {
        "tags": ["Funding"],
        "operationId": "showFunding",
        "summary": "Where to send money, whether it arrived, and whether it can be spent",
        "description": "One read that answers three questions: where to send money on every\nchain we watch, whether a transfer you already sent has arrived, and\nwhether it is spendable yet.\n\n`chains[].pay_to` is a CAIP-10 account, one per chain — the same address is\nvalid on all four watched chains, so a single top-level identifier would\nhave to name one of them and would mislead about the others. It is `null`\non every chain until `wallet_enrolled` is `true`: attribution on this flow\nis by SENDER, and an address handed out with no enrolled wallet to\nattribute a transfer to buys an unattributable transfer and a support\nticket, not credit.\n\n`chains[].scanned_to_block` and `.head_block` are the pair that tells\n\"your transfer is not here yet\" apart from \"we have not looked at that\nblock yet\" — see the `FundingChain` schema for exactly how to read them.\n\n`deposits` lists the caller's own transfers, newest first, up to 50 —\nincluding ones parked for review or ignored as dust, so a transfer that did\nnot become credit still gets an answer rather than silence. Nine internal\nstates collapse onto four public `status` values here; see the\n`FundingDeposit` schema.\n\nEntries with `status: pending` come first and are not the same kind of\nthing as the rest: they are a transfer seen on chain and not yet final,\nreported early because finality is most of the wait. They are NOT credit,\nthey carry no `block_time`, and they can vanish — a reorg, or a\nnotification we could not confirm, removes one. Treat a pending as\n\"something is happening, keep waiting\", never as money received, and read\nthe balance and `status: credited` for anything you are about to spend.\n\n**Rate limits:** 120 per minute per credential (the blanket ceiling on\nevery authenticated endpoint), and separately 60 per hour per account —\nkeyed on the ACCOUNT, so minting a second management key is not a way\naround it. Polling is the expected use of this endpoint, which is why it\ngets a more generous budget than a one-off write: check at most once a\nminute, not in a tight loop.\n",
        "responses": {
          "200": {
            "description": "The account's balance, every watched chain's deposit target and scan health, and the caller's own deposits.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Funding" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/keys": {
      "get": {
        "tags": ["Relay keys"],
        "operationId": "listRelayKeys",
        "summary": "List the account's relay keys",
        "description": "The mirror by default — touches nothing upstream, so polling this costs\nnothing. `?live=1` additionally reads spend, cap and last-used from the\nbilling service in one call, adding `spend_usd`, `cap_usd`,\n`remaining_usd` and `last_used_at` to every item; it is priced and rate-\nlimited separately from the plain list because it is the one variant that\nreaches past this application.\n\n`cap_usd` is the TOTAL cap the key was set to (spend + what remains), not\nwhat is left of it — a key with `cap_usd: 10` and `spend_usd: 3` has $7\nof headroom, reported as `remaining_usd`. Both are `null` for an\nunlimited (uncapped) key.\n\n**Rate limits:** 120 per minute per credential (the blanket ceiling on\nevery authenticated endpoint). `?live=1` additionally costs against a\nseparate budget of 30 per hour per account.\n",
        "parameters": [
          {
            "name": "live",
            "in": "query",
            "required": false,
            "schema": { "type": "boolean" },
            "description": "Set to 1 to add live spend, cap and last-used figures. Costs one upstream call and a separate, tighter rate limit."
          }
        ],
        "responses": {
          "200": {
            "description": "Every relay key on the account, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/RelayKey" }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": {
            "description": "`?live=1` only: the live figures could not be read right now.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": {
                  "error": {
                    "type": "api_error",
                    "code": "upstream_unavailable",
                    "message": "Live figures are not available right now. Try again shortly."
                  }
                }
              }
            }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "post": {
        "tags": ["Relay keys"],
        "operationId": "mintRelayKey",
        "summary": "Mint a relay key",
        "description": "Mints an `sk-` key for use against the relay (`https://api.guttertokens.com`\n— see the OTHER document at <https://app.guttertokens.com/openapi.json>). If\n`spend_cap_usd` is given and the cap write fails, the key is revoked\nrather than handed back uncapped — you get an error, never a live,\nuncapped key you did not ask for.\n\n**The `key` field is shown exactly once, in this response.** It can be\nread back later from the dashboard (a deliberate departure from a strict\nshow-once contract — see the agent guide), but not from this API: there\nis no reveal endpoint here.\n\n**Rate limit:** 20 per hour per account, in addition to the 120/minute\nblanket ceiling. This is the one that matters most — it is a budget\nshared with every other agent this account runs, not a per-process limit.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true,
                "required": ["name"],
                "properties": {
                  "name": { "type": "string", "maxLength": 255 },
                  "expires_at": {
                    "type": ["string", "null"],
                    "format": "date-time",
                    "description": "Must be in the future. Omit or send null for a key that never expires."
                  },
                  "spend_cap_usd": {
                    "type": ["number", "null"],
                    "exclusiveMinimum": 0,
                    "description": "A spend cap in USD, applied right after minting. Omit for no cap."
                  }
                }
              },
              "examples": {
                "default": { "value": { "name": "agent" } },
                "capped": { "value": { "name": "agent", "spend_cap_usd": 10 } }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new key, in the clear, for the only time it will ever appear here.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RelayKeyMinted" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "422": { "$ref": "#/components/responses/ValidationFailed" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": {
            "description": "Minting failed upstream, or a requested spend cap could not be applied\n(the key was revoked rather than left live and uncapped). Nothing was\ncharged either way.\n",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/keys/{id}": {
      "patch": {
        "tags": ["Relay keys"],
        "operationId": "updateRelayKey",
        "summary": "Enable, disable, or change a relay key's spend cap",
        "description": "Both fields are optional; send only what changes. `spend_cap_usd: null`\nremoves an existing cap; a positive number sets or replaces it; omitting\nthe field leaves the cap untouched.\n\n**Rate limit:** 120 per minute per credential.\n",
        "parameters": [{ "$ref": "#/components/parameters/RelayKeyId" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "enabled": { "type": "boolean" },
                  "spend_cap_usd": { "type": ["number", "null"], "exclusiveMinimum": 0 }
                }
              },
              "examples": {
                "disable": { "value": { "enabled": false } },
                "recap": { "value": { "spend_cap_usd": 5 } },
                "removeCap": { "value": { "spend_cap_usd": null } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The key as it now stands.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RelayKey" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "404": { "$ref": "#/components/responses/RelayKeyNotFound" },
          "422": {
            "description": "The change was refused — most commonly, the key is already revoked.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": {
                  "error": {
                    "type": "invalid_request_error",
                    "code": "key_action_refused",
                    "message": "That key was revoked and cannot be re-enabled."
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnavailable" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "delete": {
        "tags": ["Relay keys"],
        "operationId": "revokeRelayKey",
        "summary": "Revoke a relay key",
        "description": "Irreversible. The key stops working at the relay immediately.\n\n**Rate limit:** 120 per minute per credential.\n",
        "parameters": [{ "$ref": "#/components/parameters/RelayKeyId" }],
        "responses": {
          "200": {
            "description": "The key, now revoked.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RelayKey" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "404": { "$ref": "#/components/responses/RelayKeyNotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnavailable" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/management-keys": {
      "get": {
        "tags": ["Management keys"],
        "operationId": "listManagementKeys",
        "summary": "List the account's gtm_ keys",
        "description": "Every management key on the caller's own account, including the one this\nrequest authenticated with. Never a token or a hash — this credential is\ngenuinely show-once (see the wallet door's description); there is nothing\nhere that could reveal one even in principle.\n\n**Rate limit:** 120 per minute per credential.\n",
        "responses": {
          "200": {
            "description": "Every gtm_ key on the account, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/ManagementKey" }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/manage/v1/management-keys/{id}": {
      "delete": {
        "tags": ["Management keys"],
        "operationId": "revokeManagementKey",
        "summary": "Revoke a gtm_ key",
        "description": "Works even when `id` names the key this request authenticated with —\nrevoking your own credential is a legitimate, idempotent action, and the\nnext request with it is simply refused. There is deliberately no `POST`\non this resource anywhere: a management key that could mint a successor\nwould let a leaked one outlive its own revocation, so `POST\n/manage/v1/management-keys` is not routed at all (`405`) rather than refused by\na handler.\n\n**Rate limit:** 120 per minute per credential.\n",
        "parameters": [{ "$ref": "#/components/parameters/ManagementKeyId" }],
        "responses": {
          "200": {
            "description": "The key, now revoked.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ManagementKey" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/InvalidCredential" },
          "404": { "$ref": "#/components/responses/ManagementKeyNotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer gtm_…`. Minted at `/manage/v1/auth/token` — see that\noperation and the `Wallet door` tag. Not required on the three `/manage/v1/auth/*`\noperations themselves, which authenticate a different way (a wallet\nsignature and a proof of work).\n"
      }
    },
    "parameters": {
      "RelayKeyId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": { "type": "string", "format": "uuid" },
        "description": "The relay key's id, from `GET /manage/v1/keys` or a prior mint. A non-UUID value cannot match this route at all (404)."
      },
      "ManagementKeyId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": { "type": "integer" },
        "description": "The management key's id, from `GET /manage/v1/management-keys`. A non-numeric value cannot match this route at all (404)."
      }
    },
    "responses": {
      "InvalidCredential": {
        "description": "The bearer token is missing, malformed, unrecognised, revoked, expired, or belongs to a banned account. All of these read identically.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": {
                "type": "authentication_error",
                "code": "invalid_credential",
                "message": "That credential is not valid. Check it has not been revoked or expired."
              }
            }
          }
        }
      },
      "RelayKeyNotFound": {
        "description": "No relay key with that id exists on this account. Also returned for an id that belongs to another account, so this cannot be used to enumerate other customers' keys.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": {
                "type": "invalid_request_error",
                "code": "key_not_found",
                "message": "That key does not exist."
              }
            }
          }
        }
      },
      "ManagementKeyNotFound": {
        "description": "No management key with that id exists on this account.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": {
                "type": "invalid_request_error",
                "code": "management_key_not_found",
                "message": "That key does not exist."
              }
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "The request body failed validation.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" }
          }
        }
      },
      "UpstreamUnavailable": {
        "description": "The change could not be completed right now. Safe to retry shortly.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": {
                "type": "api_error",
                "code": "upstream_unavailable",
                "message": "That change could not be completed right now. Try again shortly."
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Back off and retry — see the operation's own description for the limit that applies.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait. Honour it rather than inventing an interval.",
            "schema": { "type": "integer", "minimum": 0 }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": {
                "type": "rate_limit_error",
                "code": "rate_limit_exceeded",
                "message": "Too many requests. Slow down and retry with backoff."
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "Something failed that none of this operation's other documented responses names — the catch-all `renderable` in bootstrap/app.php, scoped to `manage/v1/*` exactly like every other renderable on this surface. It never carries the underlying exception's own message: that is the one thing this response exists to keep out of a customer-readable body. Possible, on every operation, for the same reason a bug is always possible; not expected in normal operation.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": {
                "type": "api_error",
                "code": "internal_error",
                "message": "Something went wrong on our side. Try again shortly."
              }
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The one shape every failure on this API carries, including a route that\ndoes not exist at all (404) or a method not allowed on one that does\n(405) — not just the errors a handler wrote by hand.\n",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["message", "type"],
            "properties": {
              "message": { "type": "string" },
              "type": {
                "type": "string",
                "examples": ["authentication_error", "invalid_request_error", "rate_limit_error", "api_error"]
              },
              "code": {
                "type": "string",
                "enum": [
                  "invalid_request",
                  "invalid_address",
                  "invalid_credential",
                  "rate_limit_exceeded",
                  "key_not_found",
                  "key_action_refused",
                  "management_key_not_found",
                  "mint_failed",
                  "cap_not_applied",
                  "cap_not_applied_not_revoked",
                  "upstream_unavailable",
                  "not_found",
                  "method_not_allowed"
                ]
              }
            }
          }
        }
      },
      "WalletChallenge": {
        "type": "object",
        "required": ["message", "expires_at", "altcha", "terms_version", "terms_url"],
        "properties": {
          "message": { "type": "string", "description": "EIP-191 personal_sign text. Sign this exactly, do not parse it." },
          "expires_at": {
            "type": "string",
            "description": "When the wallet nonce above expires — 300 seconds from issuance. UTC, second precision, a literal Z.",
            "examples": ["2026-09-06T12:00:00Z"]
          },
          "altcha": { "$ref": "#/components/schemas/AltchaChallenge" },
          "terms_version": {
            "type": "string",
            "description": "The terms version currently in force. If the next call is `POST /manage/v1/auth/register`, its `terms_version` field must equal this exact value — a stale one is a 422.",
            "examples": ["1.0"]
          },
          "terms_url": {
            "type": "string",
            "description": "Where to read the terms named by `terms_version`.",
            "examples": ["https://app.guttertokens.com/legal/terms"]
          }
        }
      },
      "AltchaChallenge": {
        "type": "object",
        "required": ["parameters"],
        "properties": {
          "parameters": {
            "type": "object",
            "additionalProperties": true,
            "required": ["algorithm", "cost", "keyLength", "keyPrefix", "nonce", "salt"],
            "properties": {
              "algorithm": { "type": "string", "examples": ["ARGON2ID"] },
              "cost": { "type": "integer", "description": "Argon2id time cost (iterations)." },
              "keyLength": { "type": "integer", "description": "Bytes of output the full derived key has." },
              "keyPrefix": { "type": "string", "description": "Hex. The first bytes of the target derivation — match these to solve." },
              "nonce": { "type": "string", "description": "Hex, 16 bytes." },
              "salt": { "type": "string", "description": "Hex, 16 bytes." },
              "keySignature": { "type": "string", "description": "Opaque. Echo back unchanged." },
              "memoryCost": { "type": "integer", "description": "KiB. Multiply by 1024 for libsodium's byte memlimit." },
              "parallelism": { "type": "integer" },
              "expiresAt": { "type": "integer", "description": "Unix timestamp. When this PROOF (not the wallet nonce) expires." }
            }
          },
          "signature": { "type": "string", "description": "Opaque HMAC over parameters. Echo back unchanged." }
        }
      },
      "ManagementKeyIssued": {
        "type": "object",
        "required": ["id", "token", "name", "expires_at"],
        "properties": {
          "id": { "type": "integer" },
          "token": { "type": "string", "description": "gtm_-prefixed. Shown exactly once — this response is the only place it will ever appear.", "examples": ["gtm_..."] },
          "name": { "type": "string" },
          "expires_at": { "type": ["string", "null"], "examples": ["2026-09-06T12:00:00Z"] }
        }
      },
      "Funding": {
        "type": "object",
        "required": [
          "balance_usd",
          "balance_readable",
          "balance_may_lag_seconds",
          "wallet_enrolled",
          "minimum_usd",
          "auto_credit_ceiling_usd",
          "chains",
          "deposits",
          "x402"
        ],
        "properties": {
          "balance_usd": {
            "type": ["number", "null"],
            "description": "The account's current balance in USD, rounded to cents. Null exactly when `balance_readable` is false — never a confident zero for a balance that could not be read."
          },
          "balance_readable": {
            "type": "boolean",
            "description": "False means the balance could not be read, not that it is zero. `balance_usd` is null in that case. Do not treat a null balance as an empty account."
          },
          "balance_may_lag_seconds": {
            "type": "integer",
            "description": "Credit is not spendable the instant it is credited: the balance is cached upstream for up to this many seconds and crediting does not invalidate it. An agent that funds and immediately spends may be refused.",
            "examples": [60]
          },
          "wallet_enrolled": {
            "type": "boolean",
            "description": "Whether the account has an enrolled wallet. Every `chains[].pay_to` is null until this is true."
          },
          "minimum_usd": {
            "type": "number",
            "description": "A single transfer below this is ignored, not credited."
          },
          "auto_credit_ceiling_usd": {
            "type": "number",
            "description": "A single transfer above this is held for a human to review rather than credited automatically."
          },
          "chains": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/FundingChain" },
            "description": "Every chain we watch, in the order configured. Empty if deposits are disabled entirely."
          },
          "deposits": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/FundingDeposit" },
            "description": "The caller's own deposits, newest first, up to 50. A transfer sent before this wallet was enrolled surfaces here once it is claimed, not before."
          },
          "x402": {
            "type": "object",
            "required": ["enabled"],
            "properties": {
              "enabled": { "type": "boolean" }
            },
            "description": "Reserved. Always `{\"enabled\": false}` today — present from the first release so a client can branch on it rather than on its absence."
          }
        }
      },
      "FundingChain": {
        "type": "object",
        "required": ["network", "name", "pay_to", "assets", "scanned_to_block", "head_block", "scanned_at", "healthy"],
        "properties": {
          "network": {
            "type": "string",
            "description": "CAIP-2.",
            "examples": ["eip155:137"]
          },
          "name": { "type": "string", "examples": ["Polygon"] },
          "pay_to": {
            "type": ["string", "null"],
            "description": "A CAIP-10 account to send funds to on this chain. Null until `wallet_enrolled` (at the top level) is true — attribution on this flow is by SENDER, and an address handed out with no enrolled wallet buys an unattributable transfer.",
            "examples": ["eip155:137:0xAbC1230000000000000000000000000000dEaD"]
          },
          "assets": {
            "type": "array",
            "items": { "type": "string" },
            "description": "CAIP-19 asset ids this chain credits.",
            "examples": [["eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"]]
          },
          "scanned_to_block": {
            "type": ["integer", "null"],
            "description": "How far up this chain we have looked, against where the chain is. If your transfer's block is above `scanned_to_block`, we have not looked at it yet — wait. If it is below and the transfer is absent, something is wrong — that is when to escalate."
          },
          "head_block": {
            "type": ["integer", "null"],
            "description": "Where this chain currently is, as last observed. Compare against `scanned_to_block` — see that field's description."
          },
          "scanned_at": {
            "type": ["string", "null"],
            "description": "When `scanned_to_block` was last advanced."
          },
          "healthy": {
            "type": "boolean",
            "description": "Whether the scanner for this chain is keeping up, on the same threshold that pages an operator. False means treat this chain's `scanned_to_block` with suspicion rather than as current."
          }
        }
      },
      "FundingDeposit": {
        "type": "object",
        "required": ["tx", "network", "asset", "usd", "status", "reason", "block_time", "seen_at", "credited_at", "explorer_url"],
        "properties": {
          "tx": {
            "type": "string",
            "description": "The transaction hash. CAIP has no transaction identifier standard, so this is a bare hash rather than an invented form."
          },
          "network": { "type": "string", "description": "CAIP-2.", "examples": ["eip155:137"] },
          "asset": {
            "type": "string",
            "description": "CAIP-19.",
            "examples": ["eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"]
          },
          "usd": {
            "type": ["number", "null"],
            "description": "What this transfer is worth to the balance. While `status` is `pending` this is the transfer's GROSS on-chain value and nothing more — no minimum, ceiling or fee has been applied to it, because none of that is decided until the transfer is final — so it is what was sent, not what will be credited, and it is not a promise that anything will be. For every other status: two things are fixed once true: never null once `status` is `credited`, and never null once a redemption code has been minted for this transfer, whatever `status` says now (an upstream refusal or an undecryptable code can leave `status` at `needs_review` after that point, and the figure committed at mint time is reported unchanged). Otherwise this is a LIVE appraisal, re-evaluated on every read against the account's current minimum, ceiling and fee, not a value recorded when this transfer was first seen — null exactly when today's appraisal does not call for a credit. This applies to `crediting` too, briefly, before this transfer has been appraised at all, and it means a `needs_review` or `ignored` transfer is not permanently null: if the minimum or the ceiling moves, a transfer that was too small or too large under the old figure can start reporting an amount here without its `status` changing."
          },
          "status": {
            "type": "string",
            "enum": ["pending", "crediting", "credited", "needs_review", "ignored"],
            "description": "`pending` — Seen on chain and not yet final. Not credited, and may disappear — a reorg or a delivery we could not confirm removes it. Never treat this as money received. `crediting` — seen and on its way; wait. `credited` — done; it is in the balance. `needs_review` — a human is looking at it; `reason` says why. `ignored` — it will not be credited; `reason` says why."
          },
          "reason": {
            "type": ["string", "null"],
            "description": "Set only for `needs_review` and `ignored`; null for `pending`, `crediting` and `credited`. A short, customer-safe sentence — never the raw internal note this platform records for the same row."
          },
          "block_time": {
            "type": ["string", "null"],
            "description": "When the CHAIN recorded this transfer — the anchor to match against your own send. Distinct from `seen_at`: the gap between the two is scanner lag, not chain time. Null while `status` is `pending`, and only then: that entry comes from a webhook that carries a block number and no block timestamp, and a guessed one would erase the very distinction these two fields exist to draw."
          },
          "seen_at": {
            "type": "string",
            "description": "When we first recorded this row, or — for a `pending` entry, which is not a row — when the notification about it reached us."
          },
          "credited_at": {
            "type": ["string", "null"],
            "description": "When this transfer's credit was applied. Null until `status` is `credited`, `pending` very much included."
          },
          "explorer_url": {
            "type": ["string", "null"],
            "description": "A link to a block explorer for this transaction, when one is configured for the chain."
          }
        }
      },
      "RelayKey": {
        "type": "object",
        "required": ["id", "name", "prefix", "created_at", "expires_at", "revoked_at", "disabled_at"],
        "description": "Every timestamp on this API — here and everywhere else — is UTC, second\nprecision, with a literal Z suffix: `2026-09-06T12:00:00Z`. A null field\nmeans the event has not happened (never expires, never revoked, never\ndisabled), not that the value is unknown.\n",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "prefix": { "type": "string", "description": "The first few characters of the sk- key. Enough to tell two keys apart in a list; useless alone." },
          "created_at": { "type": "string" },
          "expires_at": { "type": ["string", "null"] },
          "revoked_at": { "type": ["string", "null"] },
          "disabled_at": { "type": ["string", "null"] },
          "spend_usd": { "type": ["number", "null"], "description": "Present only when the request carried ?live=1." },
          "cap_usd": { "type": ["number", "null"], "description": "Present only with ?live=1. The TOTAL cap (spend + remaining), null if unlimited." },
          "remaining_usd": { "type": ["number", "null"], "description": "Present only with ?live=1. Null if unlimited." },
          "last_used_at": { "type": ["string", "null"], "description": "Present only with ?live=1." }
        }
      },
      "RelayKeyMinted": {
        "allOf": [
          { "$ref": "#/components/schemas/RelayKey" },
          {
            "type": "object",
            "required": ["key"],
            "properties": {
              "key": { "type": "string", "description": "sk--prefixed. Shown exactly once in this response.", "examples": ["sk-..."] }
            }
          }
        ]
      },
      "ManagementKey": {
        "type": "object",
        "required": ["id", "name", "prefix", "created_at", "last_used_at", "expires_at", "created_via"],
        "description": "No token, no hash, and no revoked_at — this endpoint reports which keys\nexist and when they were used, not their lifecycle. A caller who revoked\na key already knows it, and revoking the key a request authenticated\nwith is proven by the NEXT request being refused, not by a flag here.\n",
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "prefix": { "type": "string", "description": "The first twelve characters, gtm_ included. Enough to tell two keys apart; useless alone." },
          "created_at": { "type": "string" },
          "last_used_at": { "type": ["string", "null"] },
          "expires_at": { "type": ["string", "null"] },
          "created_via": { "type": "string", "description": "Which door minted it.", "examples": ["wallet", "dashboard"] }
        }
      }
    }
  }
}
