Skip to Content
THULI

Check a wallet and the paid API

Screen one wallet free or for 5 cents over x402, and read the answer, errors, limits and real captured examples.

Thuli screens one Ethereum wallet, free for people and paid for programs, under rulebook v2.

The free check

POST /api/check takes {"wallet": "0x..."} and returns a decision. It needs no sign-in and no payment, never buys data, and costs only model credit, capped by the ceilings below. The site serves it, and its page, /check, calls it. A wallet's result sits at /check/ followed by the lowercase address. GET /api/check/health is free and returns the scope, the rulebook and the price.

The body is exactly one key, wallet, at most 64 characters, in at most 1 KB. The address may be lowercase, uppercase, or mixed case with a correct checksum; a wrong checksum is refused as a likely typo.

Answers are cached by canonical address, chain and rulebook version, so every spelling of a wallet shares one model run. An accepted answer is kept 24 hours. A hold from a failed or refused screening is kept 5 minutes.

While one request screens a wallet, another for it gets 202 with {"pending": true}. Ask again later; a screening can take up to 120 seconds. If the server copy that was screening stops part way, that wallet keeps answering 202 for up to 330 seconds, until its place frees itself.

CeilingValue
Requests per caller in 60 seconds, cached or not20
New checks per caller per hour5
New checks running per caller1
New checks running on the service2
New checks started on the service per UTC day300

The first three refuse with 429 and the last two with 503, both with {"busy": true}. A cached answer counts only toward the first.

The paid route

x402 puts payment inside the HTTP request: the server answers 402 with terms, the client signs a USDC payment and asks again. POST /api/x402/screen costs $0.05, exactly 50000 USDC base units. A server runs on one network, set with no default: Base (eip155:8453, USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) or Base Sepolia, the test network (eip155:84532, test USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e). It has been run on both.

Realised, with real USDC: on 13 November 2026 one screening was bought for 5 cents on Base mainnet and settled through Coinbase's facilitator in this transaction. Ten more requests with the same payment header each returned the identical stored result with that same settlement, a fresh header that never settled bought nothing, and the paying wallet's balance fell by exactly 0.05 USDC. That run paid the API in the proof script's own process on one machine. It does not show a payment taken by a hosted site. That came on 14 November 2026: the live site was paid 5 cents in this transaction, ten more requests with the same header returned the identical stored result, the paying wallet's balance fell by exactly 0.05 USDC, and a fresh header that never settled was answered 503 and got no result. Both payments came from Thuli's own wallet. Since that payment the route is listed in Coinbase's public catalogue of paid APIs: on 14 November 2026 a search for thuli at https://api.cdp.coinbase.com/platform/v2/x402/discovery/search?query=thuli returned https://thuli-drop.vercel.app/api/x402/screen.

Before that, the same proof ran on Base Sepolia through the site's own route, on a local build of the site. Realised, with test USDC that has no value: one screening was bought for 5 cents and settled in this transaction. Ten copies of the same payment header were then sent, and none bought a second result.

The same day's testing found a limit of this route. Coinbase's facilitator refuses a payment it will not send, such as one from a wallet that holds too little or one with a broken signature, with a 400 answer that names no transaction. A second settle of an authorization that has already paid would be refused in that same shape, because by then its nonce is used. The answer alone does not say which happened, so the route reads it as unknown: it keeps its claim on the payment and answers {"retry": true}. A buyer whose wallet is short is told to try again and is not told why. Answering 402 there could tell a buyer who has paid that the payment was refused, which is the worse mistake. Seen live against Coinbase's facilitator on 13 November 2026, with payments that could not move money: a signed payment from a wallet holding nothing, and one with a broken signature, were each answered 503 with {"retry": true}. Telling the two refusals apart needs a read of the authorization on the chain, which this version does not make.

Send the free check's body, and the payment in PAYMENT-SIGNATURE. Terms come back in PAYMENT-REQUIRED. A payment may stay valid at most 300 seconds plus 30 seconds of clock allowance.

Errors, in the order the route checks:

StatusBodyWhen
400{"error": "the client address could not be read"}caller's IP unknown
429 or 503{"busy": true}caller past a limit, or the limit store unreachable or too slow
413 or 400an error textover 1 KB, not JSON, not exactly wallet, or a bad address
503{"busy": true}service at a ceiling, or its stores unreachable or full
402{"error": "Payment required"} or {"error": "No matching payment requirements"}no payment header, or one that does not match the terms. Always JSON, and never any other words
402{}refused at settlement. The reason is in the PAYMENT-RESPONSE header: payment_not_understood for a payment whose contents cannot be read, payment_terms_mismatch for one that is not for these exact terms, payment_expired, payment_valid_too_long, settlement_receipt_invalid for a facilitator's success that the route's own check refuses, or payment_already_used for a copy of a payment that is held, or of a payment sent again for another wallet than the one it was first made for. A reason that comes from the facilitator is passed on only as a code, such as insufficient_funds; anything else it says reads settlement_failed
503{"busy": true}too little time left to settle and still screen, or the table of remembered payments is full. Nothing was charged
503{"retry": true}claim not recorded or outcome unknown, which includes any error answer from the facilitator and any answer that says pending; resend the same header for the same wallet, it never pays twice
503{"settled": true, "retry": true, "message": ...} with Retry-After: 10the payment settled and its result is not stored yet; send the same header again (next section). A request whose payment has settled never answers with payment terms
500{"error": "internal error"}internal fault. Also the answer while the facilitator cannot say what it supports; no payment is taken then
LimitValue
Paid requests in flight per caller1
Requests per caller in 60 seconds, paid or not20
Paid attempts per caller per hour that end unsettled5
Paid requests in flight on the service4
Screenings started on the service per UTC day600 (596 with a shared database)
SERV calls per screening12
Seconds per screening, then a hold120
Seconds a paid request may run240
Screenings one payment can start2
Seconds a settled payment can be redeemed86,400

A caller is its IP address, read from the host's trusted header. With DATABASE_URL set, used payments, limits and the cache live in Postgres, shared by every server copy; without it they reset on restart. GET /api/x402/health is free and returns the network, receiving address, price and scope.

One payment, one result, fetched again

The route remembers every payment by its signed header. A payment is in one of three states:

  • Held. One request has claimed the payment and is asking the facilitator to settle it. A copy of the same header meanwhile is refused with payment_already_used.
  • Settled. The money has moved. The payment is now tied to the wallet it paid for and to the settlement transaction, and its screening is running or has not stored a result yet.
  • Served. The result is stored with the payment.

A fourth marker, unsure, keeps a payment whose settlement gave no clear answer. The same header may come back and try again, one copy at a time, and it never pays twice.

Fetching a result again

For 86,400 seconds, one day, from the moment a payment settled, the same PAYMENT-SIGNATURE header sent with the same wallet in the body redeems the payment. Nothing is settled again and nothing is charged. A served payment returns its stored result exactly as stored, with status 200 and the response header thuli-stored-result: true. A settled payment with no result yet is screened at that moment. The same header sent for another wallet is refused. After the day the payment is forgotten, and the header is refused as payment_expired.

Settled, send the same payment again

Only one request screens a payment at a time. The route answers 503 with the header Retry-After: 10 and this body when the payment has settled and one of three things holds: too little time is left to screen, the screening ran out of time and the payment has a try left, or another request is screening the same payment now.

{
  "settled": true,
  "retry": true,
  "message": "The payment has settled. Send the same payment header again to get the screening it bought."
}

This answer hands out no new payment terms, and it does not count as a failed payment against the caller. Send the same header again after the wait.

At most two screenings per payment

A payment starts at most two screenings. A screening that runs out of time stores nothing, and the same header gets one more. When the second cannot finish either, or both are already used, the route stores and returns a hold written by code for a screening that ran out of time: action hold, share 0, rule A1, the reason Held for review: the screening ran out of time. and no cited transactions. A settled payment therefore always ends with a stored result.

One deadline

Every paid request answers within 240 seconds of its first line, a minute below the 300 seconds the site's host allows. The facilitator gets 15 seconds to verify a payment and 30 seconds to settle it, and a settlement left pending is asked about once more. The route settles only when enough time is left to settle and still screen. Otherwise it answers 503 with {"busy": true} and charges nothing. After settling, the screening gets the time left less 10 seconds, at most its own 120 seconds, and none starts with under 20 seconds left.

Named limits

  • If the server stops, or its database fails, in the moment between the facilitator settling a payment and the settled record being written, that payment cannot be redeemed later. The settlement is on chain, and no code returns the money, so a refund would be done by hand. When only the database write fails and the request lives, the buyer still gets the result.
  • A redeem does not check the signature again. Anyone who rebuilds the header from the settlement on chain can fetch that wallet's stored result. It buys no screening the payment did not, and the free check gives any wallet's screening to anyone anyway.
  • When a settlement's outcome is unknown and keeping the payment as unsure fails too, the payment stays held by a request that is gone, so the same header cannot try again until it expires. That takes two failures at once.

What comes back

A paid success is 200 with a PAYMENT-RESPONSE header. The free check returns the same fields. A result fetched again has the same body, plus the header thuli-stored-result: true.

  • wallet, checkedAt: checksummed address; ISO 8601 time.
  • scope, rulebook: always one wallet on Ethereum, no claim list and v2.
  • decision.action: full, partial, hold or exclude.
  • decision.shareBps: 0 to 10000; full 10000, partial 5000 by default, hold and exclude 0.
  • decision.rule: A1 to A9, B1 or B2.
  • decision.reason: plain text, 1 to 400 characters (a model's reason is held to 280 by the decision checks; 400 is the outer bound this reply allows), with no link, markup, hidden character or broken text, by the same rule every answer's text passes; show it as text only.
  • decision.citedTxs: up to 256 transaction hashes.
  • servRequestId: SERV's run id, or null.
  • notChecked: group, sharedDepositAddress, fundingBurst, claimPaidToOtherAddress. A lone wallet has no list to measure them against, so they are marked not checked, not counted as doubt, and a full or partial answer says only that its own history raised no hold.
  • Paid only: payment with network, payer, transaction. Free only: cached.

Real captured examples

packages/api/examples/ holds three captured answers: payment-required.json and paid-screen-response.json from the real payment on Base described above, and free-check-response.json from a local build of the site. The parts below are copied from them.

payment-required.json is what the paid route sent to that request before it was paid: the terms from PAYMENT-REQUIRED, decoded. Its one accepted payment:

{
  "scheme": "exact",
  "network": "eip155:8453",
  "amount": "50000",
  "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  "payTo": "0x96E854aBDdc5C618ca843956d1303017b586aB75",
  "maxTimeoutSeconds": 300,
  "extra": {
    "name": "USD Coin",
    "version": "2",
    "paymentFlow": "upfront"
  }
}

That asks for 50000 base units, 5 cents, of USDC on Base, paid to the payTo address. The value upfront means the payment settles before the screening runs. The capture comes from a server only its own machine could reach, so its resource.url is a local address and it carries no extensions.bazaar listing. A public site's terms name the site's own address and carry that listing, which asks the facilitator's catalogue to list the route once a payment settles.

paid-screen-response.json is the whole 200 body of the paid call that settled in the transaction linked above:

{
  "wallet": "0x041f77015fe51c51aE49a7f2F3Ff86C641fa190a",
  "checkedAt": "2026-11-13T05:11:00.552Z",
  "scope": "one wallet on Ethereum, no claim list",
  "rulebook": "v2",
  "decision": {
    "action": "partial",
    "shareBps": 5000,
    "rule": "A8",
    "reason": "A8: The EOA is 263 days old with 3 pre-snapshot activities and no recorded claimant-group, shared-funding, collector-sweep, wallet-made, or exchange links. The first funder is unknown and its claimant count is unreadable.",
    "citedTxs": []
  },
  "servRequestId": "41e77638-6926-4512-a99f-8cdb245e0c67",
  "notChecked": [
    "group",
    "sharedDepositAddress",
    "fundingBurst",
    "claimPaidToOtherAddress"
  ],
  "payment": {
    "network": "eip155:8453",
    "payer": "0x86D40838B9805FF63343B8F060EF3fC336f59E78",
    "transaction": "0xd55cd1ba2f1fcbc639a0d5d54ecf60fc9492226232f952da2d8af87bfcba47d4"
  }
}

This wallet is also in the ROBO replay, where it is held on rule A4 as one of 19 linked wallets. Screened alone, it has no list around it, so the paid route cannot see that group. It answered partial on rule A8 and listed group under notChecked. Read a full or partial answer as "its own history raised no hold", not as "it belongs to no group".

free-check-response.json is a free check of a different wallet. It has the same fields, with no payment and with "cached": false, and it came back full on rule A9.

The two-sided rule

Nothing is served without a settled payment, and a settled payment ends with a stored result after at most two screenings. The route settles first, then screens, and a second copy of one signed payment fails at settlement. A failed screening still returns a hold on rule A1 that names the cause, and a screening that ran out of time on the payment's last try returns the hold Held for review: the screening ran out of time. The named limits above and the open cases on limits are the exceptions.