An assistant books someone a trip, buys their parking with USDC over x402, and hands them a pass. Four HTTP calls, no account, no card, no browser.
Two unauthenticated files, at the addresses agents already check.
/.well-known/x402 is the machine-readable one — schemes,
assets, payment template. /llms.txt is prose, for a model
reading rather than parsing.
GET /api/v1/x402/events?venue=rose+bowl&date=2026-09-12
Filters on venue, date and free-text
q; limit defaults to 50 and caps at 200. Only
events the operator has opened to crypto appear. Returns the six-character
event_code, the tiers with their prices, and gate hours and
coordinates — everything after this needs only the code.
The first call is meant to fail. That is the protocol.
POST /api/v1/x402/pay/RB7X2K
{ "spot_type": "Lot H — General" }
402 Payment Required
{
"x402Version": "1",
"accepts": [{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "25000000", // $25.00, 6dp
"payTo": "0x…",
"asset": "0x833589fc…2913", // USDC
"maxTimeoutSeconds": 300,
"extra": { "name": "USD Coin", "version": "2" }
}]
}
The same payload also arrives base64 in the
X-Payment-Required header, for clients that read it there.
extra is the EIP-712 domain you must sign over — see
below, because getting it from
anywhere else is the most common way this fails.
Retry the identical request with a signed
TransferWithAuthorization in X-Payment. The
authorization is for an exact amount to an exact address — there is no
standing allowance left behind to revoke.
POST /api/v1/x402/pay/RB7X2K X-PAYMENT: base64(signed authorization) 200 OK { "success": true, "transaction": "0x…", "explorer_url": "https://basescan.org/tx/0x…", "pass_id": "8f2c…", "pass_url": "https://myspot.la/prepay?pass=8f2c…", "amount_usd": 25 }
The server verifies the signature, settles on-chain, and only then
issues the pass. The response also carries a claim_url —
hand that to whoever is driving, since signing in through it is
what makes the pass theirs. The scannable code is issued closer to the
event, so there is nothing for an agent to hold on to.
Worth separating two problems that get conflated, because only one of them is solved.
| Problem | Status |
|---|---|
| Duplication One pass, four cars |
Solved. A pass records redeemed_at on first scan and is
refused at the second. The QR rotates on a ten-minute window, so a
forwarded screenshot expires on its own. |
| Transfer Bought by one party, used by another |
Not solved — and not fully solvable, because it is the normal case. |
An agent is never the driver. Every agentic purchase is a transfer by design, so a control that blocks transfer blocks the product. The real question is narrower: what stops a secondary market.
An agent purchase no longer returns the pass itself. It returns a
claim_url: a single-use link that binds the pass to the first
person who signs in through it, with Google, Apple, or an emailed code.
After that the pass belongs to an account, and the link is spent.
200 OK { "pass_id": "8f2c…", "claim_url": "https://myspot.la/claim/…single use…" }
This is the change that matters. The agent hands the link on once, which is the legitimate flow; a reseller cannot sell the same link twice, and cannot sell it at all once it has been claimed. Only the hash of the token is stored, so a database read does not yield a working one.
The paying wallet is now recorded on the pass rather than logged and discarded, which is what any per-buyer limit will count against.
What is still true: if an agent supplies customer_email, the
pass is also reachable through the portal by that address, and a
pass_id still resolves through
GET /api/v1/prepaid/{id}. Both predate claiming and neither has
been removed, so treat a pass id as sensitive until they are.
The obvious idea is to bind the QR to the paying wallet and check a signature on redemption. It fails on the physical facts of a car park:
The binding has to be to something the driver possesses at the barrier, not to the key that paid.
| Control | State |
|---|---|
| Record the payer | Built. The paying wallet is stored on the pass and indexed by event. Attribution has to exist before enforcement, or the first thing a limit does is block an agent nobody can identify. |
| Claim step | Built. Agent purchases return a single-use claim link instead of a pass. Signing in through it binds the pass to a customer record permanently. |
| Velocity limits | Not built. A cap per wallet per event, and a rolling cap across events. Now possible, because the payer is recorded. |
| Plate binding | Not built. Captured at claim, checked at the barrier. Makes a pass worth nothing to anyone not driving that car — which is how stadiums already work. |
| Refund to payer only | Not built. Closes the cash-out route on a pass bought with a stolen key. |
Past settlement the endpoint will not return a 5xx, deliberately: an agent
reading one assumes failure and retries, and a retry after a successful
settlement is a double payment. So the contract is unambiguous — any 5xx, the
money did not move, retry is safe. A 200 carrying a
warning field means the opposite: it settled, the pass needs
manual completion, and the transaction reference in the response is what
support will ask for. Do not retry that one.
extra, never from your configSigning uses the token contract's own declared name and version, and they
differ by deployment — the same asset is USD Coin on Base mainnet and
USDC on Sepolia. We read it from the contract and pass it to you in
extra. Hardcode it and the recovered signer is wrong, which
surfaces as invalid_exact_evm_token_name_mismatch — an error that
points nowhere near the actual mistake.
Operators paste payout wallets in whatever case they were given, and strict EIP-55 clients reject a mixed-case address whose checksum does not validate. Everything is normalised to lowercase rather than risk failing every agent before it reaches us.
Send customer_email if you have it and the pass becomes
findable in the portal by a human. Omit it and the pass is still valid,
retrievable by id alone. Once the claim step above exists, this is the field
that will carry the claim.
| Code | Meaning |
|---|---|
| 400 | X-Payment was not decodable base64 JSON. |
| 402 | No payment supplied (the expected first response), or the signature failed verification, or settlement failed. The body carries the reason and a fresh set of requirements. |
| 403 | The operator has not opened this event to crypto. |
| 404 | No such event code. |
| 410 | Event closed. Distinct from 404 on purpose — the code was real, so stop retrying it rather than treating it as a typo. |
| 503 | Crypto is enabled but unconfigured, or the operator has no payout wallet. Transient from your side: worth retrying later, and worth telling the user the venue is not ready rather than that they failed. |
Point at base-sepolia, which is the default network until
X402_NETWORK says otherwise. A reference client at
backend/scripts/x402-test-client.js walks the whole path —
discover, quote, sign, pay, verify — and is the fastest way to confirm your
signing is right before you spend real USDC.