This walkthrough takes you from clone to a paid place in a live queue, using real x402 payments — USDC on Base Sepolia or on Solana, your choice.
git clone https://github.com/nirholas/x402-queue
cd x402-queue
npm installRequirements: Node 18+.
The server runs unconfigured — .env.example ships with the suite's public
receive addresses on both rails, and the startup banner reminds you they're the
defaults. To take the money yourself, copy the template and set both:
cp .env.example .env
# edit .env →
# PAY_TO_ADDRESS=0xYourBaseAddress (EVM rail)
# SOLANA_PAY_TO_ADDRESS=YourSolanaAddress (Solana rail)You can also run one rail only: drop an address and that rail is omitted from every 402 (the server logs which one it skipped).
Describe your real waitlists in config/queues.json:
venue— name, description, timezone, address, phonequeues— each withid,name,description,avgServeMinutes(how long one party takes),parallelServers(how many at once),maxLength(refuse joins beyond this),graceMinutes(how long a called party has to appear), andopenholdPolicy— the join price,refundIfNotServedMinutes(the promise you are making), and the wording customers see
The ETA maths is deliberately simple and legible:
floor(ahead / parallelServers) × avgServeMinutes. Tune avgServeMinutes
against your real service times and the quotes get honest fast.
Set SIGNING_SECRET before you sell anything real. It signs the position
tokens, and their whole point is that neither side can edit joinedAt or
quotedEta after the fact.
npm run devYou'll see the banner with the paid route, its price, and both rails. Sanity checks:
curl -s http://localhost:4025/health | jq
curl -s http://localhost:4025/queues | jq '.queues[] | {id, waiting, estimatedWaitMinutes, acceptingJoins}'
curl -s http://localhost:4025/.well-known/x402 | jqNote that /queues needs no payment. Seeing the wait is free.
Call the paid route without paying:
curl -si -X POST http://localhost:4025/join \
-H 'content-type: application/json' \
-d '{"queue":"dining-room","name":"Ada","party":2}' | head -20You get HTTP/1.1 402 Payment Required and a JSON body whose accepts[] array
has two entries — one per rail:
curl -s -X POST http://localhost:4025/join \
| jq '.accepts[] | {network, payTo, asset, maxAmountRequired}'{ "network": "base-sepolia", "payTo": "0x40252CFD…", "asset": "0x036CbD53…", "maxAmountRequired": "10000" }
{ "network": "solana", "payTo": "WwwuGbqH…", "asset": "EPjFWdd5…", "maxAmountRequired": "10000" }Each entry states the exact amount (atomic USDC units, 6 decimals), the token address, the recipient, and the network. This is the whole protocol: the 402 is the price list, and it quotes in two currencies of the same dollar.
Base rail (what the bundled client uses): create a throwaway key (e.g.
openssl rand -hex 32 prefixed with 0x, or export one from a test wallet) and
fund it with Base Sepolia USDC from https://faucet.circle.com. A few cents'
worth is plenty.
Solana rail: any wallet holding USDC works — Phantom in the browser demo, or
a keypair in an agent. Set SOLANA_NETWORK=devnet to test against devnet USDC
instead of mainnet.
PRIVATE_KEY=0xYourFundedKey npm run clientexamples/agent-client.ts will:
- read the free manifest and queue board,
- pick a queue that is accepting joins,
- pay $0.01 for
POST /join, - print the artifact — position, ETA and its basis, the signed token, and the
signed refund claim — plus the decoded
X-PAYMENT-RESPONSEsettlement receipt, which names the rail and the transaction, - read
GET /position/:token(free), then show a forged token being rejected and an early refund claim being refused with its reason, - drive the operator routes: call the next party, mark them served, and show the hold resolving to refunded.
Everything you paid for is in the 200 body — and crucially, nothing is deferred. Queues are the classic "pay now, get served later" shape; this service resolves that by handing over instruments instead of promises:
token— the ticket.base64url(canonical payload) + "." + hex HMAC. The payload carriesjoinedAtandquotedEta, so the venue cannot quietly re-quote you and you cannot claim you joined earlier than you did.position/ahead/eta— where you stood at issue time, and thebasisstring explaining the arithmetic.refundClaim— a signed instrument with its ownclaimId,amount,reasonandclaimableAfter. It matures on its own; you already hold it.graceMinutes— how long you have to appear once you are called.positionUrl— a ready-made link to your free live status page.
TOKEN=$(jq -r .token ticket.json)
curl -s "http://localhost:4025/position/$TOKEN" | jq '{status, position, ahead, eta, refund}'Poll it every 30 seconds if you like — it costs nothing. Tamper with one character and it stops validating:
curl -s "http://localhost:4025/position/${TOKEN%??}ff" | jq .message
# "signature does not match — token was altered or forged"curl -s -X POST http://localhost:4025/call-next/dining-room | jq
curl -s -X POST http://localhost:4025/serve/tkt_XXXX | jq # they turned up
curl -s -X POST http://localhost:4025/no-show/tkt_XXXX | jq # they didn'tno-show is refused with 409 GRACE_PERIOD until the queue's full
graceMinutes has elapsed since the call, so nobody can be written off early.
That guard is what makes the hold fair in both directions.
The hold is due back when you were served, when you left before being called,
or when the venue missed your quoted ETA by more than
refundIfNotServedMinutes. It is forfeited only when you were called and did
not show up.
curl -s -X POST "http://localhost:4025/claim/$TOKEN" | jq
curl -s -X POST "http://localhost:4025/leave/$TOKEN" | jqPOST /claim is idempotent — a retry returns the same record with
alreadyIssued: true. The signed record is the customer's claim; moving the USDC
back on-chain is the operator's action (or an automation you attach).
Open http://localhost:4025 — a live-board page using the drop-in
@three-ws/x402-payment-modal. Watch the waits for free, pick a queue, join from
a browser wallet — Phantom / Solflare / Backpack on Solana, or MetaMask on
Base — and your position ticks down on screen (polled for free every 15
seconds). The modal reads the dual-rail 402 and offers the wallets it detects;
SIWX re-entry means a regular signs in instead of reconnecting, and spending caps
bound what the page can charge.
The Solana browser path needs one server route (Phantom signs serialized
transactions, so the SPL transfer has to be built server-side). src/checkout.ts
mounts it at /api/x402-checkout; if its optional peer deps are missing the
banner says Solana browser checkout: disabled and the Base path still works.
Agent clients build their own transaction and never touch that route.
- Set
NETWORK=base(the Solana rail already defaults to mainnet — setSOLANA_NETWORK=devnetif you want it on devnet instead). - Point
FACILITATOR_URLat a production facilitator for Base (e.g. Coinbase Developer Platform's x402 facilitator). The Solana rail settles throughSOLANA_FACILITATOR_URL, which defaults to PayAI's (https://facilitator.payai.network) — no public facilitator handles both chains. - Replace the public Solana RPC: set
SOLANA_RPC_URLto a dedicated endpoint (Helius / Triton / QuickNode). The default is rate-limited and will fail under load. - Set a strong
SIGNING_SECRET. - Set
PUBLIC_BASE_URLsopositionUrlpoints at your real origin, notlocalhost. - Use real merchant wallets for
PAY_TO_ADDRESSandSOLANA_PAY_TO_ADDRESS. - Wire
POST /call-next,/serveand/no-showto whatever the host stand actually uses — a tablet, your POS, or a pager system. The server is the source of truth for the line; the UI on top is yours. - Deploy behind HTTPS (agents will refuse to pay plaintext endpoints) and keep
data/on a persistent volume.
Prices stay in dollar strings ($0.01) — the paywall converts to atomic USDC on
whichever network the client picks.