{
    "name": "ArtWars API",
    "canvas_size": 1024,
    "palette": [
        "#FFFFFF",
        "#E4E4E4",
        "#888888",
        "#222222",
        "#FFA7D1",
        "#E50000",
        "#E59500",
        "#A06A42",
        "#E5D900",
        "#94E044",
        "#02BE01",
        "#00D3DD",
        "#0083C7",
        "#0000EA",
        "#CF6EE4",
        "#820080"
    ],
    "cooldown_seconds": 60,
    "pixel_price_units": 10,
    "creator_fee_units_per_pixel": 1,
    "funding_unit": "tenths of a US cent (1 unit = $0.001)",
    "api_version": 1,
    "docs": "https://artwars.io/api.html",
    "example_client": "https://artwars.io/examples/artwars_bot.py",
    "rate_limit": {
        "requests": 600,
        "window_seconds": 60,
        "scope": "per IP, all endpoints",
        "on_exceed": "429 rate_limited, with retry_after_seconds and a Retry-After header"
    },
    "cors": "Access-Control-Allow-Origin: * on every endpoint. Safe because auth is a bearer token, never a cookie.",
    "auth_mode": "strict",
    "blank_color_index": 0,
    "notes": [
        "Auth is a bearer token: \"Authorization: Bearer <token>\", or a token field in the body, or ?token=. Get one from wallet_auth.php (sign a challenge) or account_auth.php (Google ID token).",
        "Reads are open and need no identity. Writes need a verified session while auth_mode is strict.",
        "Two identity kinds. A Solana wallet can do everything; a Google account (principal \"acct_...\") can do everything except spend SOL \u2014 funding a Collab and the marketplace refuse it.",
        "No real payments happen: funding units and NFT prices are test stand-ins for SOL. There is no payment path in this application.",
        "Bots are explicitly welcome. The cooldown is per-PIXEL, not per-wallet \u2014 one identity may act on unlimited different pixels at once.",
        "VERSIONED as of 2026-09-11: api_version above, and an X-ArtWars-API-Version header on every response. It bumps ONLY on a breaking change (a removed or renamed field, a changed shape). Adding a field is not breaking, so read the \"fields\" array a response gives you rather than assuming a shape.",
        "Errors are {\"error\": \"<stable_string>\"} and sometimes a \"detail\". Compare on the string, never the prose.",
        "CORS (Access-Control-Allow-Origin) is set on this catalog, canvas_blob.php, campaign_map.php and stats.php only. Server-side clients are unaffected; a browser tool on another origin is limited to those four."
    ],
    "endpoints": {
        "identity": {
            "GET wallet_auth.php": "Auth state for this request: authenticated, wallet, auth_mode, and whether wallet sign-in is currently open to new identities.",
            "POST wallet_auth.php": "{action: challenge, wallet} returns a nonce and the exact message to sign; {action: verify, wallet, signature} returns a bearer token; {action: logout} revokes it. The server verifies against ITS OWN stored copy of the message, never against text you supply, so a signature made for another site cannot be replayed here.",
            "GET account_auth.php": "Google account session state, and whether Google sign-in is enabled.",
            "POST account_auth.php": "{credential} \u2014 a Google ID token, exchanged for a bearer token. Intended for a browser using Google Identity Services rather than a headless bot."
        },
        "canvas": {
            "GET canvas_blob.php": "THE FAST PATH, and how you should read the whole canvas. Returns CANVAS_SIZE^2 raw bytes, row-major, offset = y * canvas_size + x, value = palette index. One request instead of 16, ~232KB gzipped instead of ~23.6MB of JSON, and no parsing \u2014 in a browser it is `pixels.set(new Uint8Array(await res.arrayBuffer()))`. Send If-None-Match with the ETag to get a 304 when nothing has changed. The X-Canvas-Watermark header is the timestamp this data is current as of: pass it straight back to canvas.php?since= to pick up everything after it.",
            "GET canvas_blob.php?field=c|t|cid|n|os": "The other four fields, same packed format, one plane each. c = palette index (uint8, the default). t = last change, epoch seconds (uint32 LE). cid = owning campaign id, 0 = player-held (int32 LE). n = lifetime ownership changes, clamped (uint16 LE). os = held since, epoch seconds (uint32 LE). X-Canvas-Element-Bytes tells you the element width so you can pick a typed array without hardcoding it. Separate planes so you fetch only the field you are using; fetch several in parallel if you need them. Values are identical to canvas.php?fields=full for the same pixel.",
            "GET canvas.php": "Metadata + tile plan. Fetch each tile separately; there is no unbounded full-canvas read.",
            "GET canvas.php?meta=1": "Metadata only (palette, canvas size, cooldown).",
            "GET canvas.php?x0=&y0=&x1=&y1=": "Pixels in a region. Add &since=<timestamp> for a regional delta.",
            "GET canvas.php?since=<timestamp>": "Whole-canvas delta since a timestamp \u2014 the polling endpoint. Use a value the API gave you (server_time, or canvas_blob.php's X-Canvas-Watermark) and pass it back unmodified. Do not reformat it or substitute your own clock.",
            "GET canvas.php?fields=base|full": "How much per pixel. base (DEFAULT) = x,y,c \u2014 everything drawing the canvas needs. full adds t (last change, epoch seconds), cid (owning campaign, null if player-held), n (lifetime ownership changes) and os (held since, epoch seconds). Every response echoes a \"fields\" array saying what it contains. full is ~3x the bytes and ~2x the server time, so ask for it only if you use those four.",
            "POST place.php": "{x, y, color_index, wallet?} \u2014 place one pixel. 429 if that pixel is on cooldown."
        },
        "campaigns": {
            "GET campaigns_list.php": "Collabs that are ON the canvas, one flat list, newest first.",
            "GET campaign_map.php?campaign_id=": "Per-pixel blueprint-vs-canvas state (completed / wrong_color / contested / available) plus treasury and persistence stats.",
            "POST campaign_create.php": "{name, creator_name, width, height, blueprint[], anchor_x, anchor_y, orientation, ...} \u2014 create a Collab. Free, and open to a Google account as well as a wallet. blueprint is width*height palette indices, row-major. Index 0 is BLANK: not part of the artwork, never stamped, never defended, and NOT counted toward the funding target. An all-blank blueprint is refused with blueprint_is_blank. No description field.",
            "POST campaign_contribute.php": "{campaign_id, amount_units, wallet} \u2014 fund a Collab.",
            "POST campaign_update_placement.php": "Move a Collab (owner only).",
            "POST campaign_update_scaling.php": "Toggle overfunding scaling (owner only).",
            "POST campaign_update_defense_speed.php": "slow | medium | fast (owner only).",
            "POST campaign_update_acquisition.php": "instant | gradual (owner only).",
            "GET campaign_pledge.php": "Your own pledge, or ?campaign_id= for a Collab's pledger count.",
            "POST campaign_pledge.php": "{action: join|leave, campaign_id} \u2014 pledge your drawing ability to a Collab. Google accounts only; a wallet is refused with 403 account_identity_required."
        },
        "coordination": {
            "GET tasks.php?campaign_id=": "Claimable work chunks, with live completion percentages.",
            "POST tasks.php": "{action: create|claim|release|done, ...}",
            "GET chat.php?scope=global|campaign|dm": "Room history. Pass since_id for incremental polling.",
            "POST chat.php": "{scope, wallet, body, campaign_id?, with?}"
        },
        "governance": {
            "SHELVED": "Governance is switched off (2026-08-20). GET proposals.php still reads existing history; every non-GET returns 410 governance_shelved.",
            "GET proposals.php": "Proposals with live tallies. Read-only."
        },
        "nfts": {
            "GET nft.php": "Minted records. Add ?id= for image bytes and the ownership split.",
            "POST nft.php": "{action: mint_campaign_artwork, campaign_id, wallet} (owner only).",
            "GET marketplace.php?status=active": "Listings. NOT OPEN YET \u2014 built but unfinished, and removed from the site nav. Reads work; writes refuse a Google account with 403 wallet_identity_required.",
            "POST marketplace.php": "{action: list|bid|buy|cancel, ...}"
        },
        "profiles": {
            "GET profile.php?wallet=": "Public profile: Collabs created and total contributed. Also accepts ?handle= for a Google account."
        },
        "data": {
            "GET stats.php": "Activity aggregates. ?mode=rate gives minute-resolution placements.",
            "GET export.php": "Bulk CSV/JSON of events, activity, pixels or campaigns. Row-capped and date-filterable ON PURPOSE: an unbounded dump of a million-row table is a denial of service against a small box.",
            "GET campaign_insights.php?campaign_id=": "24h reclaims, losses, spend, treasury runway and contested hotspots."
        }
    },
    "background_jobs": {
        "campaign_ai_tick.php": "Defend/reclaim + gradual acquisition. Every 60s.",
        "roaming_tick.php": "Roaming relocation decisions and migration progress. Every 60s.",
        "persistence_tick.php": "Recompute Persistence Score and extinction. Hourly.",
        "snapshot_tick.php": "Mint the hourly canvas snapshot NFT. Hourly.",
        "governance_tick.php": "Close voting, apply timelocks. Every 5 minutes.",
        "marketplace_tick.php": "Settle ended auctions. Every 5 minutes."
    }
}