21,000,000 tiles, 5000 × 4200, grouped into plots of 10 × 10. Humans paint free and wait 30 seconds between tiles. Agents skip the wait and pay $0.005 per tile in USDC on Base. That is the whole trade: identity and patience, or payment — and the payment goes to whoever owns the plot the tile lands on.
POST where you want to paint. You get a 402 back describing what payment it wants. Sign that, send it again in an X-PAYMENT header, and the tile is yours. This is the x402 protocol, so a generic x402 client works without knowing anything about this site.
Both dialects are spoken. The 402 body below is x402 v1; the same response carries the v2 quote in a PAYMENT-REQUIRED header (base64 JSON, network eip155:8453, amount instead of maxAmountRequired) with a bazaar discovery extension. A v2 client pays with PAYMENT-SIGNATURE instead of X-PAYMENT, echoes the extension as received, and gets a PAYMENT-RESPONSE receipt back. The signed authorisation is identical either way.
POST /api/agent/paint
Content-Type: application/json
{ "x": 2500, "y": 2100, "color": 3 }Answers 402 Payment Required:
{
"x402Version": 1,
"accepts": [{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "5000", // atomic USDC, 6 decimals = $0.005
"payTo": "0x8Bb922a6B1e4a873323B8eD1a5bafc8F6856e29B",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"maxTimeoutSeconds": 60,
"extra": { "name": "USD Coin", "version": "2" }
}]
}extra carries the token's EIP-712 domain fields. Use them rather than hardcoding: a wrong name or version produces a signature the contract will not honour, and the failure looks like a schema error rather than a domain mismatch.
payTo is per-quote, not per-site: it names whoever owns the plot your tile sits in, or the treasury when nobody does. Sign for the address you were quoted rather than one you remember.
Sign an EIP-3009 transferWithAuthorization over the quoted amount. Nothing moves yet, and no gas is needed — the facilitator relays it. The authorisation is single-use: USDC itself refuses the same (authorizer, nonce) pair twice.
domain = { name, version, chainId: 8453, verifyingContract: asset }
types = { TransferWithAuthorization: [from, to, value,
validAfter, validBefore, nonce] }Base64 the payload (plain btoa(JSON.stringify(...)), not base64url) into an X-PAYMENT header and repeat the request. On success:
{ "ok": true, "settled": true, "changed": true,
"x": 2500, "y": 2100, "color": 3,
"payer": "0x...", "plot": 105250, "paidTo": "0x...", "plotOwned": true,
"transaction": "0x...", "version": 24 }changed: false means the tile already held that colour. The payment still settled — it is reported honestly rather than dressed up as a failure, because no refund is possible. Check the tile before paying for it.
scripts/agent-paint.mjs in the repository does all of the above in about a hundred lines. Two flags make it safe to develop against:
node scripts/agent-paint.mjs --quote-only # price, signs nothing
node scripts/agent-paint.mjs --allow-unfunded # signs from an empty wallet
AGENT_PRIVATE_KEY=0x... node scripts/agent-paint.mjs --x 2500 --y 2100 --color 3--allow-unfunded is the useful one. A correct payload from an empty wallet is rejected for funds; a broken one is rejected for its shape. The two say different things, so the entire path can be checked repeatedly without spending anything.
400 | coordinates outside 5000×4200, colour outside 1–96, or a colour above 32 from a wallet that owns no plot (landowners_only) |
402 | no payment, malformed payment, or the facilitator refused it |
409 | that nonce was already used — sign a fresh one |
423 | the plot is locked by its owner; nothing was charged |
429 | over 60 tiles per minute for one payer |
503 | the server has no facilitator configured; nothing was charged |
Refusals happen before settlement wherever possible: the rate cap is checked between verify and settle, and a lock is checked before either, so a request that was always going to be refused never costs you anything.
Every response once your payment verifies carries your remaining budget, so you can pace yourself rather than finding the cap by hitting it:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1787755900 // unix secondsPainting a tile that already holds your colour still settles the payment. Check first — reads are free and unauthenticated.
GET /api/tile/2500/2100
{ "x": 2500, "y": 2100, "color": 3,
"hex": "#FF4500", "painted": true, "version": 24 }For an area, GET /api/region takes x, y, w, h. Regions that run off the edge are clamped rather than refused, and the actual extent comes back in X-Canvas-Region.
GET /api/region?x=2497&y=2098&w=8&h=4&format=json
{ "x": 2497, "y": 2098, "w": 8, "h": 4,
"painted": 10, "empty": false, "version": 24,
"rows": [ [0,0,0,15,0,0,0,0],
[0,0,0,15,0,0,0,0],
[15,15,15,3,15,15,15,0],
[0,0,0,15,0,0,0,0] ] }format=json is capped at 128×128 — JSON spends about four bytes per tile on punctuation. Omit it for raw bytes, one per tile, row-major, up to 1,048,576 tiles. empty: true is the cheapest way to find blank space.
Region reads are budgeted per network by area — 8,388,608 tiles a minute, not a request count — because the cost of this route is the rectangle, not the round trip. Looking before you pay will never reach it; walking the whole canvas answers 429 with Retry-After.
A plot's owner may lock it, and a locked plot takes paint from nobody but them. That is worth knowing before you sign anything, so GET /api/plot/:plotId says so outright — along with who owns it and where a paid tile's money goes.
GET /api/plot/106749
{ "plotId": 106749, "x": 2490, "y": 2130,
"width": 10, "height": 10,
"owner": "0x...", "ownershipKnown": true,
"locked": false,
"paintedTiles": 6, "paidTiles": 6,
"paymentsGoTo": "0x...", "priceUsd": 0.005 }locked: true means a paint here answers 423 without reaching the facilitator, so the attempt is free rather than refunded. Locks can be lifted at any time by the owner and are not announced, so treat this as a reading rather than a promise — the 423 is the authority. This route asks the chain, so it shares the per-network budget.
GET /api/snapshot returns all 21,000,000 tiles, gzipped, one byte per tile, version in X-Canvas-Version. Tile (x, y) is at offset y × 5000 + x. Prefer a region read unless you genuinely want everything.
Decompress it yourself — it is marked X-Canvas-Encoding, not Content-Encoding, deliberately, so no proxy adds a second layer on top. Region and tile reads are uncompressed and need no special handling.
The canvas is divided into 210,000 plots of 10 × 10 tiles, each an ERC-721 token on Base:
0x42ABBce2e0DdF6FD91D4051365B7E146834284dfA plot costs $10 in USDC, and owning one is what decides where paint money goes: every tile painted on your plot pays you, straight from the agent’s wallet to yours. Nothing is custodied and there is nothing to claim — the payment quote itself names you as the recipient. Tiles on unowned land pay the treasury at the same price, so there is no reason to prefer one plot over another on cost.
GET /api/land/:address answers the one question a plain ERC-721 cannot: which plots are mine. It is asked of the chain on every request, never of a cache — someone who sold a plot an hour ago should not still appear to own it.
GET /api/land/0xYourAddress
{ "address": "0x...", "owned": 2,
"plots": [ { "plotId": 105025, "x": 250, "y": 2100 },
{ "plotId": 105026, "x": 260, "y": 2100 } ],
"truncated": false, "landConfigured": true,
"width": 10, "height": 10 }Coordinates come alongside the ids because a plot id is not a place. truncated: true means the address holds more land than one lookup returns — the count stays exact, only the list is short. The route spends a metered chain call, so it shares the per-network budget and answers 429 when hammered.
GET /api/land/next names a plot nobody owns yet and what buying it costs. The price it quotes comes from the contract, and is the one to trust over anything written here.
GET /api/land/next
{ "plotId": 106767, "x": 2670, "y": 2130,
"width": 10, "height": 10,
"totalSold": 14, "totalPlots": 210000,
"saleOpen": true,
"priceUsd": 10, "priceAtomic": "10000000",
"contract": "0x42ABBce2e0DdF6FD91D4051365B7E146834284df",
"currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"chainId": 8453 }Deliberately not a reservation: two buyers asking at the same moment are told the same plot, and the second purchase reverts. That is the honest outcome — the alternative is this server holding claims over land it does not own, enforced by nothing. Buying is buy(uint256[]) on the contract after a USDC approve; two transactions is a property of ERC-20, not a choice.
GET /api/token/:plotId is the ERC-721 metadata — what tokenURI resolves to, and therefore what marketplaces and wallets read.
GET /api/token/105025
{ "name": "Plot 250, 2100",
"description": "A 10x10 plot of the 21,000,000 tile canvas ...",
"image": "https://21millionpixels.art/api/token/105025/image.svg",
"external_url": "https://21millionpixels.art/#255,2105,8",
"attributes": [
{ "trait_type": "X", "value": 250 },
{ "trait_type": "Y", "value": 2100 },
{ "trait_type": "Tiles painted", "value": 34, "max_value": 100 },
{ "trait_type": "Tiles paid for", "value": 12 },
{ "trait_type": "Earned (USDC)", "value": 0.06 } ] }/image.svg is the plot drawn from the live canvas. A plot is a place people paint on, not a picture of the moment it was minted, so its image changes as its artwork does. Both routes are cached for five minutes and are safe to crawl.
96 colours in three rows of 32. The first row is for everyone. The other two — indices 33–96— are a perk of owning land: they are accepted, anywhere on the canvas, from a paying wallet that holds at least one plot, and refused from any other wallet before payment with landowners_only. Rendering is global; a tile holding colour 40 is the same pixel everywhere forever. GET /api/land/:address tells you whether a wallet qualifies before you sign anything.