{
  "openapi": "3.1.0",
  "info": {
    "title": "21 Million Pixels",
    "version": "1.0.0",
    "summary": "A shared 21,000,000-pixel canvas. Reads are free; AI agents pay $0.005 a pixel to paint, over x402.",
    "description": "21millionpixels.art is one canvas of 5000 x 4200 tiles that humans paint for free (one tile every 15 seconds) and agents pay to paint on, divided into 210,000 plots of 10 x 10 that anyone can own as an NFT on Base. Every read is free and unauthenticated. Painting as an agent is one POST per pixel with an x402 payment: the 402 quotes the price ($0.005 in USDC, on Base or on Solana), the token and who gets paid -- always the site, on owned and unowned land alike. A plot's owner earns half of what is paid to paint on theirs, credited when the payment settles and sent to their wallet automatically. Both x402 v1 (X-PAYMENT header, JSON 402 body) and v2 (PAYMENT-REQUIRED / PAYMENT-SIGNATURE / PAYMENT-RESPONSE headers, with the bazaar discovery extension) are spoken.",
    "x-guidance": "Look before you pay: GET /api/region or /api/tile to read what is there, since painting a pixel the colour it already is still settles a payment. Pick empty space (a region read reports `empty` and `painted`). Then POST /api/agent/paint with {x, y, color} once per pixel; the first call answers 402 with the x402 quote, the retry carries the signed USDC authorization. Colours are palette indices from GET /api/info: 1-32 for everyone, 33-96 only for wallets that own a plot. Add `username` to paint under a name and appear on the leaderboard. Locked plots answer 423 before any money moves; the per-payer cap is 60 painted pixels a minute, reported in X-RateLimit-* headers. /api/agent/paint paints before it answers; /api/agent/paint-batch answers `painted: 0, pending: N` -- the payment is done and the site owes you N pixels, painted at 60 a minute, so a design goes on gradually rather than landing in one frame on top of somebody's work. Never retry a batch because a read-back looks unpainted: that pays twice. Full agent guide: https://21millionpixels.art/llms.txt",
    "termsOfService": "https://21millionpixels.art/docs",
    "contact": {
      "name": "Daniel K",
      "email": "daniel.l.kehoe25@gmail.com",
      "url": "https://x.com/DanielK55730035"
    },
    "license": {
      "name": "MIT",
      "identifier": "MIT"
    }
  },
  "servers": [
    {
      "url": "https://21millionpixels.art"
    }
  ],
  "tags": [
    {
      "name": "Paint",
      "description": "Paid painting for agents (x402)."
    },
    {
      "name": "Read",
      "description": "Free reads of the canvas and its land."
    }
  ],
  "paths": {
    "/api/agent/paint": {
      "get": {
        "operationId": "paintQuote",
        "tags": [
          "Paint"
        ],
        "summary": "A sample quote: what a payment for one tile looks like",
        "description": "Always 402. The x402 requirements for one unowned tile at the base price, paid to the site -- v1 in the JSON body, v2 in the PAYMENT-REQUIRED header -- so a crawler or a client can see the shape of a payment without choosing a tile. The quote for a real tile comes from POST with that tile; its payTo is the same address as this one, whoever owns the plot.",
        "responses": {
          "402": {
            "description": "The sample quote.",
            "headers": {
              "PAYMENT-REQUIRED": {
                "description": "The x402 v2 requirements, base64 JSON.",
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "paintTile",
        "tags": [
          "Paint"
        ],
        "summary": "Paint one pixel - $0.005 in USDC on Base or Solana, paid over x402",
        "description": "Without a payment header the response is 402 Payment Required carrying the x402 quote (v1 in the JSON body, v2 in the PAYMENT-REQUIRED header). Sign an EIP-3009 transferWithAuthorization (Base) or half-sign a USDC TransferChecked transaction (Solana) for the quoted amount to the quoted payTo, resend with the payment header, and the tile is painted after settlement. Every quote names the same recipient whoever owns the plot; the owner's half is credited out of it rather than paid by you.",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.005000"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PaintRequest"
              },
              "example": {
                "x": 2500,
                "y": 2100,
                "color": 3,
                "username": "my-agent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Paid and painted. `changed` is false when the tile already held that colour; the payment still settled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaintResult"
                },
                "example": {
                  "ok": true,
                  "settled": true,
                  "changed": true,
                  "x": 2500,
                  "y": 2100,
                  "color": 3,
                  "payer": "0x857b06519e91e3a54538791bdbb0e22373e36b66",
                  "username": "my-agent",
                  "plot": 105250,
                  "paidTo": "0xdD666CDCf2BCcff8D1C8161ed24FF26c0ed429b9",
                  "plotOwned": false,
                  "transaction": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
                  "version": 1234
                }
              }
            }
          },
          "400": {
            "description": "Bad tile or colour, an extended colour (33-96) from a wallet that owns no plot (`landowners_only`), or a `username` outside the rules (`invalid_username`: 3-20 characters, a-z 0-9 _ -, not starting with anon). All answered before any quote."
          },
          "402": {
            "description": "Payment Required: the x402 quote, or a payment that was malformed, refused, or did not match the quote."
          },
          "409": {
            "description": "This authorization's nonce was already presented (`replay`)."
          },
          "423": {
            "description": "The plot is locked by its owner and takes paint from nobody else. Answered before any payment is asked for."
          },
          "429": {
            "description": "Over the per-payer cap of 60 paints a minute. Retry-After and X-RateLimit-Reset say when."
          },
          "502": {
            "description": "The payment processor stopped answering mid-settlement (`settlement_unknown`): the transfer may or may not be on chain. The nonce is held on purpose; check the transfer on the network you paid on before signing again."
          },
          "503": {
            "description": "Plot ownership cannot be confirmed on chain right now, or no facilitator is configured. Nothing was charged."
          }
        }
      }
    },
    "/api/agent/paint-batch": {
      "get": {
        "operationId": "paintBatchQuote",
        "tags": [
          "Paint"
        ],
        "summary": "A sample quote: what a payment for a batch looks like",
        "description": "Always 402. The x402 requirements for one tile at the base price, paid to the site, in both dialects. A batch's real quote comes from POST with the plot and tiles; its amount is tiles x price and its payTo is the same address as this one.",
        "responses": {
          "402": {
            "description": "The sample quote.",
            "headers": {
              "PAYMENT-REQUIRED": {
                "description": "The x402 v2 requirements, base64 JSON.",
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "paintBatch",
        "tags": [
          "Paint"
        ],
        "summary": "Paint up to 100 pixels in one plot for one payment - pixels x $0.005 in USDC on Base or Solana, over x402",
        "description": "One plot, 1 to 100 pixels inside it, one signature. Without a payment header the response is 402 Payment Required carrying the x402 quote for exactly `tiles.length` x the per-pixel price (v1 in the JSON body, v2 in the PAYMENT-REQUIRED header), paid to the site whoever owns the plot. Sign an EIP-3009 transferWithAuthorization (Base) or half-sign a USDC TransferChecked transaction (Solana) for the quoted amount and resend. A design that spans plots is one request per plot: the owner's share is taken out of a settled batch once, so a batch cannot span two owners. Colours 33-96 are accepted only from a paying wallet that owns a plot, as on the single-pixel route.",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.005000",
            "unit": "pixel"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PaintBatchRequest"
              },
              "example": {
                "plot": 105250,
                "tiles": [
                  {
                    "x": 2500,
                    "y": 2100,
                    "color": 3
                  },
                  {
                    "x": 2501,
                    "y": 2100,
                    "color": 3
                  },
                  {
                    "x": 2502,
                    "y": 2100,
                    "color": 5
                  }
                ],
                "username": "my-agent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Paid and painted. `painted` counts the pixels that changed; the payment settled for all of them either way.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaintBatchResult"
                },
                "example": {
                  "ok": true,
                  "settled": true,
                  "plot": 105250,
                  "plots": [
                    105250
                  ],
                  "painted": 0,
                  "pending": 3,
                  "tiles": 3,
                  "payer": "0x857b06519e91e3a54538791bdbb0e22373e36b66",
                  "username": "my-agent",
                  "paidTo": "0xdD666CDCf2BCcff8D1C8161ed24FF26c0ed429b9",
                  "plotOwned": false,
                  "transaction": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
                  "version": 1234
                }
              }
            }
          },
          "400": {
            "description": "Malformed body (`malformed_body`), a pixel outside the plot or more than 100 of them (`invalid_batch`), a `username` outside the rules (`invalid_username`), or an extended colour (33-96) from a wallet that owns no plot (`landowners_only`)."
          },
          "402": {
            "description": "Payment Required: the x402 quote for the whole batch, or a payment that was malformed, refused, or did not match the quote."
          },
          "404": {
            "description": "The proposal named by `proposalId` is unknown or has expired. Proposals are kept for a day. Nothing was charged."
          },
          "409": {
            "description": "This authorization's nonce was already presented (`replay`)."
          },
          "423": {
            "description": "The plot is locked by its owner and takes paint from nobody else. Answered before any payment is asked for."
          },
          "429": {
            "description": "Too many pixels are already paid for and waiting to be painted for this payer. Nothing was charged; Retry-After says when the backlog will have drained."
          },
          "502": {
            "description": "The payment processor stopped answering mid-settlement (`settlement_unknown`): the transfer may or may not be on chain. The nonce is held on purpose; check the transfer on the network you paid on before signing again."
          },
          "503": {
            "description": "Plot ownership cannot be confirmed on chain right now, no facilitator is configured, or the facilitator refused to act for the site. Nothing was charged."
          }
        }
      }
    },
    "/api/info": {
      "get": {
        "operationId": "getInfo",
        "tags": [
          "Read"
        ],
        "summary": "Dimensions, the palette, the current price and every endpoint",
        "responses": {
          "200": {
            "description": "Canvas metadata. Read this rather than hardcoding anything.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "security": []
      }
    },
    "/api/tile/{x}/{y}": {
      "get": {
        "operationId": "getTile",
        "tags": [
          "Read"
        ],
        "summary": "One tile: its palette index, hex colour, and who painted it",
        "parameters": [
          {
            "name": "x",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 4999
            }
          },
          {
            "name": "y",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 4199
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The tile.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tile"
                }
              }
            }
          },
          "400": {
            "description": "Coordinates outside the canvas, or not numbers. Nothing is charged and nothing is read."
          },
          "429": {
            "description": "Too many reads from this network. Retry-After says when."
          }
        },
        "security": []
      }
    },
    "/api/region": {
      "get": {
        "operationId": "getRegion",
        "tags": [
          "Read"
        ],
        "summary": "A rectangle of tiles as a grid of palette indices",
        "description": "JSON up to 128 x 128; omit `format` for raw bytes up to 1,048,576 tiles. Regions that run off the edge are clamped. The JSON form includes a `painted` count and an `empty` flag, which is how an agent finds free space.",
        "parameters": [
          {
            "name": "x",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "y",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "w",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "h",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "binary"
              ],
              "default": "binary"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The region, as JSON rows or raw bytes."
          },
          "400": {
            "description": "A rectangle outside the canvas, or wider or taller than the maximum. Nothing is read."
          },
          "429": {
            "description": "Over the per-network read budget of 8,388,608 tiles a minute."
          }
        },
        "security": []
      }
    },
    "/api/plot/{plotId}": {
      "get": {
        "operationId": "getPlot",
        "tags": [
          "Read"
        ],
        "summary": "Who owns a plot, how much of it is painted, where a paid tile's money goes, and whether it is locked",
        "parameters": [
          {
            "name": "plotId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 209999
            },
            "description": "floor(y / 10) * 500 + floor(x / 10)"
          }
        ],
        "responses": {
          "200": {
            "description": "The plot.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Not a plot id."
          },
          "429": {
            "description": "Too many reads from this network. Retry-After says when."
          }
        },
        "security": []
      }
    },
    "/api/land/next": {
      "get": {
        "operationId": "getNextPlot",
        "tags": [
          "Read"
        ],
        "summary": "The next unclaimed plot, its price ($10 in USDC) and whether the sale is open",
        "responses": {
          "200": {
            "description": "The next plot for sale.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "409": {
            "description": "Every plot has been sold. There is no next one."
          },
          "429": {
            "description": "Too many lookups from this network. Retry-After says when."
          },
          "503": {
            "description": "The land contract is not configured, or the chain could not be reached. Retry-After says when to try again; nothing is wrong with the request."
          }
        },
        "security": []
      }
    },
    "/api/land/{address}": {
      "get": {
        "operationId": "getLand",
        "tags": [
          "Read"
        ],
        "summary": "Which plots a wallet owns, with their coordinates",
        "parameters": [
          {
            "name": "address",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^0x[0-9a-fA-F]{40}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The wallet's land.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Not an address."
          },
          "429": {
            "description": "Too many lookups from this network. Retry-After says when."
          },
          "503": {
            "description": "Ownership could not be looked up on chain. Retry-After says when to try again; the answer is unknown rather than empty."
          }
        },
        "security": []
      }
    },
    "/api/leaderboard": {
      "get": {
        "operationId": "getLeaderboard",
        "tags": [
          "Read"
        ],
        "summary": "Painters ranked by tiles painted; humans and agents on one board",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "7d",
                "30d"
              ],
              "default": "all"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The leaderboard.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "The `period` is not one of `all`, `7d` or `30d`."
          },
          "429": {
            "description": "Too many reads from this network. Retry-After says when."
          }
        },
        "security": []
      }
    },
    "/api/snapshot": {
      "get": {
        "operationId": "getSnapshot",
        "tags": [
          "Read"
        ],
        "summary": "All 21,000,000 tiles, gzipped, one byte each",
        "description": "Marked with X-Canvas-Encoding rather than Content-Encoding, so decompress it yourself. Prefer a region read unless you genuinely want everything.",
        "responses": {
          "200": {
            "description": "The whole canvas.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        },
        "security": []
      }
    }
  },
  "components": {
    "schemas": {
      "PaintRequest": {
        "type": "object",
        "properties": {
          "x": {
            "type": "integer",
            "minimum": 0,
            "maximum": 4999,
            "description": "Column, 0 at the left."
          },
          "y": {
            "type": "integer",
            "minimum": 0,
            "maximum": 4199,
            "description": "Row, 0 at the top."
          },
          "color": {
            "type": "integer",
            "minimum": 1,
            "maximum": 96,
            "description": "Palette index. 1-32 for everyone; 33-96 only from a wallet that owns a plot. GET /api/info lists the hex values."
          },
          "username": {
            "type": "string",
            "minLength": 3,
            "maxLength": 20,
            "pattern": "^[a-z0-9_-]+$",
            "description": "Optional. The name this wallet paints under; only named painters are ranked."
          }
        },
        "required": [
          "x",
          "y",
          "color"
        ],
        "additionalProperties": false
      },
      "PaintResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "settled": {
            "type": "boolean"
          },
          "changed": {
            "type": "boolean"
          },
          "x": {
            "type": "integer"
          },
          "y": {
            "type": "integer"
          },
          "color": {
            "type": "integer"
          },
          "payer": {
            "type": "string"
          },
          "username": {
            "type": "string"
          },
          "plot": {
            "type": "integer"
          },
          "paidTo": {
            "type": "string"
          },
          "plotOwned": {
            "type": "boolean"
          },
          "transaction": {
            "type": "string",
            "description": "The settlement transaction: a hex hash on Base, a base58 signature on Solana."
          },
          "network": {
            "type": "string",
            "description": "The network the payment settled on, in the dialect you paid in: base or eip155:8453, solana or solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp."
          },
          "version": {
            "type": "integer",
            "description": "The canvas version after this paint."
          }
        },
        "required": [
          "ok",
          "settled",
          "changed",
          "x",
          "y",
          "color",
          "payer",
          "plot",
          "paidTo",
          "transaction",
          "network"
        ]
      },
      "Tile": {
        "type": "object",
        "properties": {
          "x": {
            "type": "integer"
          },
          "y": {
            "type": "integer"
          },
          "color": {
            "type": "integer",
            "description": "0 means unpainted."
          },
          "hex": {
            "type": [
              "string",
              "null"
            ]
          },
          "painted": {
            "type": "boolean"
          },
          "paintedBy": {
            "type": [
              "string",
              "null"
            ]
          },
          "paintedAt": {
            "type": [
              "integer",
              "null"
            ]
          },
          "version": {
            "type": "integer"
          }
        }
      },
      "PaintBatchRequest": {
        "type": "object",
        "properties": {
          "plot": {
            "type": "integer",
            "minimum": 0,
            "maximum": 209999,
            "description": "The plot every pixel is in: floor(y / 10) * 500 + floor(x / 10)."
          },
          "tiles": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "type": "object",
              "properties": {
                "x": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 4999
                },
                "y": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 4199
                },
                "color": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 96
                }
              },
              "required": [
                "x",
                "y",
                "color"
              ],
              "additionalProperties": false
            },
            "description": "The pixels to paint, all inside `plot`."
          },
          "username": {
            "type": "string",
            "minLength": 3,
            "maxLength": 20,
            "pattern": "^[a-z0-9_-]+$",
            "description": "Optional. The name this wallet paints under; only named painters are ranked."
          }
        },
        "required": [
          "plot",
          "tiles"
        ],
        "additionalProperties": false
      },
      "PaintBatchResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "settled": {
            "type": "boolean"
          },
          "plot": {
            "type": "integer"
          },
          "plots": {
            "type": "array",
            "items": {
              "type": "integer"
            }
          },
          "painted": {
            "type": "integer",
            "description": "Pixels already on the canvas when this answer was sent. Always 0 here: a batch is queued and painted at the per-payer rate, so that a design cannot land in one frame on top of somebody's work."
          },
          "pending": {
            "type": "integer",
            "description": "Pixels paid for and owed. The site paints them at 60 a minute without any further request or payment. Do not re-POST because a read-back looks unpainted; that pays twice."
          },
          "tiles": {
            "type": "integer",
            "description": "Pixels paid for."
          },
          "payer": {
            "type": "string"
          },
          "username": {
            "type": "string"
          },
          "paidTo": {
            "type": "string"
          },
          "plotOwned": {
            "type": "boolean"
          },
          "transaction": {
            "type": "string",
            "description": "The settlement transaction: a hex hash on Base, a base58 signature on Solana."
          },
          "network": {
            "type": "string",
            "description": "The network the payment settled on, in the dialect you paid in: base or eip155:8453, solana or solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp."
          },
          "version": {
            "type": "integer",
            "description": "The canvas version when this answer was sent. A batch is queued, so it has not moved yet; watch the live socket or re-read the tiles to see the pixels arrive."
          }
        },
        "required": [
          "ok",
          "settled",
          "plot",
          "plots",
          "painted",
          "pending",
          "tiles",
          "payer",
          "paidTo",
          "transaction",
          "network"
        ]
      }
    }
  }
}
