# Gutter Tokens for agents Written to be read by a language model that is acting for somebody. It covers the whole lifecycle — open an account, prove a wallet, add credit, mint a key, spend it — and marks every point where a human has to act. If you only need to make requests and you already have a key, read [Consuming the API](#6-consuming-the-api) and stop there. Everything above it is onboarding, and onboarding is the part with humans in it. Nothing here is a substitute for the [Terms](https://app.guttertokens.com/legal/terms) and the [Acceptable Use Policy](https://app.guttertokens.com/legal/aup). Where this document and those disagree, those win. --- ## Read this before you do anything else **You are acting for a person or a company. Act like it.** - **One account for the operator you serve.** Not one per task, per session, per container, or per IP address. There is no limit on accounts in the Terms and there is no need for a second one: keys are free and unlimited, spend caps are per key, and usage is already broken down per key. - **Creating accounts in bulk is the abuse case this service actually has.** Every account eventually costs a login against a budget that is *shared by every customer on the platform*, so a signup flood does not merely make junk rows — it stops real, paying customers from minting keys. It will be treated as a denial-of-service attempt, not as enthusiasm. - **Never submit personal data through the API.** Section 3 of the AUP is an outright prohibition, not a "have a lawful basis" duty. No names, contact details, government identifiers, account numbers, health data, biometrics, precise location, or anything at all about a child. Nothing of a request is retained, so nothing can be found, produced or deleted afterwards. - **Business use only, 18+, not from a comprehensively sanctioned jurisdiction.** Opening an account is a representation that all three are true. You cannot make that representation on your own behalf — your operator makes it, and you act on their instruction. - **Do not scrape the site, the dashboard, the catalogue or the docs.** AUP §5. Driving your *own* dashboard to do your operator's own business is ordinary use; enumerating anything is not. - **Do not probe or scan.** AUP §5. If you find a vulnerability while doing something legitimate, stop and report it to security@guttertokens.com. Breach can mean suspension or termination without notice, and closure forfeits any remaining balance (Terms §15). Credits are not refundable (Terms §6). Money you put in and then lose to a ban is money gone. --- ## What is human-only, in one table | Step | Who | Why | |---|---|---| | Deciding to open an account at all | **Human** | It is a contract, and a representation about age, business use and sanctions status | | Signing up with **email + password** | **Human** | The verification link goes to an inbox; no account works until it is clicked | | Signing up with a **wallet** | Agent, if it holds the key | Plain HTTP + one `personal_sign`. See §1 | | Custody of the signing key | **Human** | Whoever holds the key holds the account. A wallet-only account **cannot be recovered** if the key is lost | | Enrolling an extra wallet on an existing email account | **Human** | Requires re-entering the account password in a browser form | | Deciding how much money to send, and sending it | **Human** | It is a transfer of real funds from a wallet the human controls | | Reading the deposit address | Agent, with a management key | `GET /manage/v1/funding`, once a wallet is enrolled — or a browser session on the billing page. See §3 | | **Obtaining the first management key** | Agent, if it holds the key — or **Human**, once, for an account opened another way | Bundled into registration at §1's machine door; otherwise one wallet exchange, by a human, so the key never has to touch the agent. See §1 and §4 | | **Minting an API key** | Agent, with a management key | Plain HTTP once a `gtm_` key exists. See §4 and §5 | | Making API requests | Agent | This is the part that is designed for you | | Reading the balance | Agent, with a management key | `GET /manage/v1/funding`, or a browser session on the billing page. See §3 | | Reading usage | Agent, with a browser session | Only on the dashboard; lags about five minutes. See §7 | | Closing the account | **Human** | Email from the registered address, or a signed message. Thirty days | | A deposit over $5,000, or one that lands oddly | **Human on our side** | Parked for review; support will be in touch | --- ## 1. Opening an account Three doors. Two of them need a browser. ### The email door — **[HUMAN REQUIRED]** `POST /signup` on `https://app.guttertokens.com` with `name`, `email`, `password` (minimum 12 characters), `password_confirmation`, `terms`, an `altcha` proof (§1.1) and the CSRF token from the form. It succeeds, and then nothing works: a verification link is mailed, sign-in is refused until it is clicked, and no session is established by signup itself. Unless you can read the operator's inbox, hand this step to them. ### The wallet door — **[AGENT-CAPABLE]** This is the path designed to be walkable without a browser and without an email address. It needs one thing you may not have: an Ethereum private key. **That key is the account.** There is no password, no recovery, and no support path back in — Terms §4 says so in as many words. Get your operator to decide where the key lives before you generate one. Four requests, all on `https://app.guttertokens.com`, all carrying the same cookie jar (the session is what holds the challenge between calls): ``` 1. GET /signup → cookies + the CSRF token in the form 2. GET /altcha/challenge → the proof-of-work challenge 3. POST /login/wallet/challenge → {"message": ""} body: address=0x… 4. POST /signup/wallet → 302 to /dashboard, signed in body: signature=0x…, terms=1, altcha=, ref= ``` Every POST needs `X-CSRF-TOKEN` (or `_token` in the body) and the session cookie. Step 3's challenge is stored server-side, is single-use, and expires after **300 seconds**; the message is signed with **EIP-191 `personal_sign`** and is deliberately never parsed back, so send the signature alone. The message you will be asked to sign looks like this, and is safe — it moves nothing and authorises no transaction: ``` guttertokens.com wants you to sign in with your Ethereum account: 0xAbC… Prove you control this wallet. This does not cost anything, does not authorise any transaction, and cannot move your funds. URI: https://guttertokens.com Version: 1 Chain ID: 1 Nonce: … Issued At: … Expiration Time: … ``` Two things worth knowing: - **A wallet that already has an account is signed in, not rejected.** That is on purpose, so nobody can use this endpoint to ask "does this address bank here?". Treat `/signup/wallet` as idempotent-ish: it gets you a session either way. - **The wallet you sign up with is enrolled automatically.** You do not need §2 unless you want a *second* wallet on the account. Signing in later is the same shape without the terms: `POST /login/wallet/challenge`, then `POST /login/wallet` with the signature. ### The machine door — **[AGENT-CAPABLE]** The wallet door above still needs a browser — a cookie jar and a CSRF token. This one does not. Three requests, entirely on `https://api.guttertokens.com` (the same host you send completions to, not the app host the two doors above use), no cookies, no session, no CSRF token anywhere: ``` 1. POST /manage/v1/auth/challenge body: {"address": "0x…"} → {"message": "", "expires_at": "…", "altcha": {…}, "terms_version": "1.0", "terms_url": "https://app.guttertokens.com/legal/terms"} 2. Sign `message` with the wallet's private key (personal_sign, EIP-191). Solve `altcha` — the harder Argon2id proof of work described in §4.1, not §1.1's lighter, browser-weight one below. 3. POST /manage/v1/auth/register body: {"address": "0x…", "signature": "0x…", "altcha": "", "name": "", "terms_version": "1.0"} → 201 {"id": 42, "token": "gtm_…", "name": "", "expires_at": null} ``` `terms_version` in step 3 must be EXACTLY the value step 1 just returned — even a value that used to be correct is refused with a `422`, so read it fresh every time rather than hardcoding it. **This is the one operation on the whole platform that creates an account from nothing** — no email, no password, no browser, no human clicking a verification link. The moment it succeeds, **the wallet's private key is the only credential this account will ever have.** There is no password to reset and no support path back in if it is lost — losing that key loses the account, permanently. Decide where the key lives before you generate one. A wallet that already has an account is issued a NEW key rather than refused for THAT reason alone — an existing account is still refused if it is banned or has an unverified email carried over from before it held a wallet, with the identical `422` a bad signature gets. Short of that, step 3 is safe to run again if you are not sure whether it already succeeded — you get a working key either way, at the cost of one more key on the account to eventually revoke. **Rate limit:** 5 per hour per source address, 20 per hour per source IP. ### 1.1 The proof of work Both signup doors and the password-reset form require an ALTCHA proof. It is a plain cost function, not a bot detector, and it is meant to be solvable by a machine — that is why this section exists rather than telling you to give up. ``` GET /altcha/challenge → {"parameters":{"algorithm":"PBKDF2/SHA-256","nonce":"","salt":"", "cost":28000,"keyLength":32,"keyPrefix":"","keySignature":"", "expiresAt":},"signature":""} ``` `nonce`, `salt` and `keyPrefix` are hex — decode them to bytes before use. Starting at `counter = 0` and counting up, derive ``` key = PBKDF2-HMAC-SHA256(password = nonce_bytes + uint32_be(counter), salt = salt_bytes, iterations = cost, dklen = keyLength) ``` and stop at the first `counter` whose `key` STARTS WITH `keyPrefix`. The counter is appended to the nonce as a 4-byte BIG-ENDIAN unsigned integer; a little-endian counter finds nothing and looks exactly like a server fault. Then post ``` altcha = base64( {"challenge": , "solution": {"counter": n, "derivedKey": ""}} ) ``` `derivedKey` is the FULL `keyLength` bytes, not the `keyPrefix` you matched on. Echo `challenge` back exactly as received — it carries the signature that makes it ours. Key order and whitespace do not matter; the server re-serialises canonically before checking. The counter is drawn from 60..120, so the work is 60 to 120 derivations of 28,000 iterations each — one to two seconds on a phone, a fifth of that on a desktop, and a few hundred milliseconds in native code. That is the intended cost. Solving is single-threaded by construction: you cannot know `counter` without walking up to it. **A challenge is burned the first time it verifies**, atomically, and expires after **600 seconds**. One solution buys exactly one attempt. Solving a batch in advance to spend later is the thing this control exists to stop, and it is the clearest possible signal of intent to abuse. Fetch the challenge only when you are about to submit a form. It is not cached — every request for one costs us CPU, which is why `/altcha/challenge` carries its own limit of 60 per minute per source IP. ### 1.2 The limits on the account surface Per source IP, on `guttertokens.com` — the network address a request arrives from, not a wallet address; none of these limiters key on one. They are not generous, because nothing legitimate needs them to be — an operator opens one account, once. | Endpoint | Limit | |---|---| | Every auth endpoint, together | 30 per 60s | | `POST /signup`, `POST /signup/wallet` | 5 per 600s | | `POST /login`, the TOTP step, `POST /login/wallet` | 10 per 300s (one shared budget) | | `POST /forgot-password` | 5 per 3600s | | `POST /verify-email/resend` | 5 per 3600s | | `GET /altcha/challenge` | 60 per 60s | | Everything on the app host | 20 concurrent connections, 20 rps at the edge | A `429` here means stop, not slow down. Rotating source IPs to get around any of these is the abuse case, not a workaround. Sessions last seven days idle and thirty days absolute. Re-signing in with a wallet is four requests, so do that rather than trying to keep one alive. --- ## 2. Enrolling a wallet on an existing account — **[HUMAN REQUIRED]** Only needed if the account was made with an email address, or if you want a second wallet on it. It lives on the profile page in the dashboard, it is a two-step browser ceremony (server issues a challenge, wallet signs it), and **the account password must be re-entered** to start it — a wallet is a full sign-in credential, so attaching one from a session somebody walked away from would be an account takeover with no password ever typed. There is no way to supply that password other than to know it. An address is globally unique across the platform: one address, at most one account. --- ## 3. Adding credit There is **no payment gateway**. Two ways in, and sending money is a human's decision either way — but watching for it is not, any more. ### Watching the money — an agent's part `GET /manage/v1/funding`, with any `gtm_` key, answers three questions in one call: where to send money on every chain we watch, whether a transfer has arrived, and whether it can be spent yet. Discovering the address and watching a transfer arrive is something an agent can do entirely on its own; **deciding how much to send, and sending it, is not** — see the next subsection for why that stays a human's call regardless. ``` GET /manage/v1/funding Authorization: Bearer gtm_… → 200 { "balance_usd": 42.57, "balance_readable": true, "balance_may_lag_seconds": 60, "wallet_enrolled": true, "minimum_usd": 0.5, "auto_credit_ceiling_usd": 5000, "chains": [ { "network": "eip155:137", "name": "Polygon", "pay_to": "eip155:137:0xAbC1230000000000000000000000000000dEaD", "assets": ["eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"], "scanned_to_block": 71234567, "head_block": 71234570, "scanned_at": "2026-09-06T12:00:00Z", "healthy": true } ], "deposits": [ { "tx": "0xaaaa…", "network": "eip155:137", "asset": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "usd": 10.0, "status": "credited", "reason": null, "block_time": "2026-09-06T11:57:00Z", "seen_at": "2026-09-06T11:58:00Z", "credited_at": "2026-09-06T12:03:00Z", "explorer_url": "https://polygonscan.com/tx/0xaaaa…" } ], "x402": { "enabled": false } } ``` `pay_to` is `null` on every chain until `wallet_enrolled` is `true` — attribution on this flow is by SENDER, and an address handed out with no enrolled wallet to attribute a transfer to buys one nobody can credit. #### Polling recipe Poll `GET /manage/v1/funding` at most once a minute (the limit is 60/hour). Your transfer is either in `deposits[]` or it is not. If it is not, compare its block against the chain's `scanned_to_block`: ``` block > scanned_to_block we have not looked yet. Wait. block <= scanned_to_block we have looked and it is not there. Check the network and the receiving address, then contact support — do not resend. ``` Once it appears, `status` tells you what to do: `crediting` wait, `credited` done, `needs_review` a human is looking and `reason` says why, `ignored` it will not be credited and `reason` says why. After `credited`, allow `balance_may_lag_seconds` before expecting the balance to move. ### Sending money — **[HUMAN DECIDES AND SENDS]** Send a stablecoin ERC-20 transfer to the `pay_to` address `GET /manage/v1/funding` gave you for the chain you are using — or read the same address from the billing page in the dashboard. 1 USDC or 1 USDT becomes $1.00 of credit; there is no fee. | Chain | CAIP-2 | Tokens credited | Time to credit | |---|---|---|---| | Ethereum | `eip155:1` | USDC, USDT | ~15–20 min (we wait for `finalized`) | | Base | `eip155:8453` | USDC, USDbC, USDT | ~15–20 min | | Arbitrum One | `eip155:42161` | USDC, USDC.e, USDT | ~15–20 min | | Polygon | `eip155:137` | USDC, USDC.e, USDT | ~1 min | Four conditions, all of them unforgiving: 1. **A chain on that list.** Anything else is invisible to us and is **not recoverable**. This is the one mistake here that cannot be undone. 2. **A token on that list, as a token transfer.** Native ETH and POL are not credited — there is nothing we read that says they arrived. 3. **Sent from an address the account can sign for.** Attribution is by sender. A withdrawal straight from an exchange arrives from the exchange's wallet and cannot be attributed; write to support if that has already happened. 4. **At least $0.50.** Below that it is ignored. `pay_to` reads `null` until a wallet is enrolled (`wallet_enrolled: false`) — enrol one first (§1 or §2). Money can arrive **before** the account exists. A transfer from an address we do not know is held, and is swept in — automatically, at registration (`POST /manage/v1/auth/register`, §1's machine door) or on the next attribution pass otherwise — once somebody proves they hold that key. Send first and sign up afterwards with the same wallet and the credit will be waiting. A single transfer over **$5,000** is parked for a human to look at rather than credited automatically (`needs_review` in `GET /manage/v1/funding`, above). Plan around that if you are funding a large workload. ### Redemption code — **[HUMAN REQUIRED]** Codes are issued by us. "Have a code?" on the billing page, five attempts per hour per account. --- ## 4. Minting an API key Everything below is on `https://api.guttertokens.com` — the same hostname you send completions to. The two surfaces do not overlap: `/v1/…` is the model API and takes an `sk-` key, `/manage/v1/…` is this control surface and takes a `gtm_` key. Sending one credential to the other prefix is refused cleanly; it is not a partial success. **There is a plain HTTP API for this now.** No browser, no cookie, no CSRF token, anywhere in this section — the whole thing is walkable by an agent that holds the account's wallet key, start to finish (§1's machine door gets one in the same call that opens the account). If instead a human holds that key and the account was opened another way, the exchange just below is the one thing they still run themselves, once, before handing the agent a `gtm_` token. The lifecycle has two credentials, and mixing them up is the mistake to avoid: a **management key** (`gtm_…`) manages relay keys — mint, list, cap, revoke — and a **relay key** (`sk-…`) is what you actually call the model with. A management key cannot make an API request, and a relay key cannot mint or revoke anything. Neither can mint a *second* management key, on purpose — see §5. ### Getting a management key for an account opened another way — **[AGENT-CAPABLE, IF IT HOLDS THE KEY]** One wallet signature, on the account's own signing key, over plain HTTP — `https://api.guttertokens.com`, no cookies, no session: ``` 1. POST /manage/v1/auth/challenge body: address=0x… → {"message": "", "expires_at": "…", "altcha": {…}} 2. POST /manage/v1/auth/token body: address=0x…, signature=0x…, altcha=, name=