This walkthrough takes you from clone to a booked (and cancelled) appointment using real x402 payments — USDC on Base Sepolia or on Solana, your choice.
git clone https://github.com/nirholas/x402-bookable
cd x402-bookable
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 business in config/services.json:
provider— name, description, timezone, physical locationhours— opening hours per weekday (null= closed)services— each withid,name,durationMinutes,mode(video/in-person/phone),description, and an optionalbufferMinutesof protected time after the appointmentslotMinutes— the booking grid (default 15)bookingWindowDays— how far ahead people may bookcancelPolicy— hold price, free-cancellation window, and the wording customers seemeetingLinkBase— optional;videoservices get<meetingLinkBase>/<appointmentId>as their join link
npm run devYou'll see the banner with paid routes, prices, and both rails. Sanity checks:
curl -s http://localhost:4022/health | jq
curl -s http://localhost:4022/services | jq
curl -s http://localhost:4022/.well-known/x402 | jqCall a paid route without paying:
curl -si "http://localhost:4022/slots?service=consult-30" | head -20You get HTTP/1.1 402 Payment Required and a JSON body whose accepts[] array
has two entries — one per rail:
curl -s "http://localhost:4022/slots" | jq '.accepts[] | {network, payTo, asset, maxAmountRequired}'{ "network": "base-sepolia", "payTo": "0x40252CFD…", "asset": "0x036CbD53…", "maxAmountRequired": "1000" }
{ "network": "solana", "payTo": "WwwuGbqH…", "asset": "EPjFWdd5…", "maxAmountRequired": "1000" }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 service catalogue,
- pay $0.001 for
GET /slots, - pay $0.01 (refundable hold) for
POST /appointmentson the first open time, - print the confirmation artifact — appointment id, service, meeting link,
cancel policy,
cancelToken, HMAC signature — plus the decodedX-PAYMENT-RESPONSEsettlement receipt, which names the rail and the transaction, - decode the base64
icsfield into a calendar invite, - cancel for free with the
cancelTokenand print the refund ledger entry.
Everything you paid for is in the 200 body:
appointmentId— your reference;cancelToken— bearer credential for cancellation and lookup. Store both.meetingLink— present forvideoservices; in-person bookings carry the provider address aslocationinstead.ics— base64.ics;Buffer.from(ics, "base64")and save to a file to import into any calendar.signature— HMAC-SHA256 over the canonical confirmation JSON with the server'sSIGNING_SECRET; tamper-evidence for disputes.ledgerEntry— the recorded hold. Cancelling outside the free-cancellation window (12h by default) yields arefundentry; inside it, aforfeit.
Open http://localhost:4022 — a Calendly-style page using the drop-in
@three-ws/x402-payment-modal. Pick a service, pay for the grid, tap a time, pay
the hold from a browser wallet — Phantom / Solflare / Backpack on Solana, or
MetaMask on Base — download the invite, cancel with one click. The modal reads
the dual-rail 402 and offers the wallets it detects; SIWX re-entry means a
returning customer signs in instead of paying twice, 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.
- 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. - Use real merchant wallets for
PAY_TO_ADDRESSandSOLANA_PAY_TO_ADDRESS. - 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.