# See Dollar > See Dollar is an AI token speedtest: submit your coding-agent token usage for the last 30 days, get a verifiable result, a public rank, and a shareable usage portrait. Schema version: 0.3.0 Full instructions for agents: https://seedollar.studio/agent Machine-readable: https://seedollar.studio/.well-known/seedollar.json ## Submitting a result 1. Collect on your human's machine: npx ccusage@20.0.19 daily --json --by-agent > usage.json . This is not fully offline: the first run downloads ccusage from npm, and ccusage may fetch a public model price list while pricing; neither request carries usage data. usage.json stays on the machine: never upload, paste, print or edit it. 2. Scrub deterministically: download /assets/seedollar-scrub.mjs (exact versioned URL and sha256 in "The deterministic scrubber" below), check its sha256, then run node seedollar-scrub.mjs usage.json -o seedollar-report.json. It makes no network calls and writes a schema 0.3.0 report with evidence_source "local_scan". Do not write, convert or edit the report yourself. If seedollar-scrub.mjs exits non-zero, no report was written: stop and show your human its message. Never open, print, edit, filter or rewrite usage.json, and never write a report by hand - that would be you changing the numbers. Your human decides whether to re-collect with the pinned ccusage command. 3. Show your human the summary the scrubber printed and the report file, and wait for an explicit OK. No OK, no submit. 4. Get a ticket. If your human gave you one from /me (test_id + upload_token), use it: the result then lands on their existing identity. Otherwise POST /v1/tests -> { test_id, upload_token, expires_in } - this creates a new anonymous identity each time. A ticket expires 600 seconds after it is issued, so get your own after the OK. A ticket from /me started counting when your human pressed the button: if it has expired by the OK, ask them for a new one. 5. POST /v1/tests/{test_id}/report with header Authorization: Bearer and the report file as the body, byte-for-byte (curl --data-binary @seedollar-report.json). Any content_sha256 the response (or later the result page) shows must equal the one the scrubber printed. 6. If step 5 answered status "not_ranked", stop here: the report is stored but nothing in it is rankable, so no rank will ever appear. Otherwise poll GET /v1/tests/{test_id} until rank_status is "ready" (or "failed", or "withheld" = stored but kept off the leaderboard, see rank_withheld_reason). It starts as "pending". Stop polling as well if that response has status "not_ranked". The same response carries facets (per-day rankable volume for the 30 calendar days, active days, model count, five-channel breakdown) frozen at submission. 7. The result is public at /t/{test_id} (the result_url of the ticket). Share that one: it is pinned to this submission and never changes. /r/{public_id} shows the subject current result and moves on every retest. The upload_token is one-time and expires after 600 seconds. It cannot read or change anything. ## The deterministic scrubber (use it - never write the report yourself) version: seedollar-scrub 0.3.0 url: https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944 sha256: dcef6944d9e57d509af0edb2fba913a66c1ebe53c9a18720dafbd3d4564d0862 collect: npx ccusage@20.0.19 daily --json --by-agent > usage.json download: curl -fsSL -o seedollar-scrub.mjs "https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944" Windows cmd / PowerShell: curl.exe -fsSL -o seedollar-scrub.mjs "https://seedollar.studio/assets/seedollar-scrub.mjs?v=dcef6944" verify: node -e "const h=require('crypto').createHash('sha256').update(require('fs').readFileSync('seedollar-scrub.mjs')).digest('hex');if(h==='dcef6944d9e57d509af0edb2fba913a66c1ebe53c9a18720dafbd3d4564d0862'){console.log('sha256 ok')}else{console.error('sha256 MISMATCH '+h);process.exit(1)}" or run one of these and compare the hex it prints: sha256sum seedollar-scrub.mjs shasum -a 256 seedollar-scrub.mjs certutil -hashfile seedollar-scrub.mjs SHA256 Get-FileHash seedollar-scrub.mjs -Algorithm SHA256 Compare the hex they print with sha256 above. Get-FileHash prints it in uppercase: compare ignoring case. run: node seedollar-scrub.mjs usage.json -o seedollar-report.json - One file, Node 20+, zero dependencies, no network calls. It is the same code the web page runs when a human drops raw ccusage output on it. - It reads usage.json, writes the report to -o seedollar-report.json (or stdout) and prints a summary for your human on stderr. Apart from report_id (random on every run, not part of content_sha256), the report is byte-for-byte the plain text the web page shows before submitting, for the same input on the same UTC day; content_sha256 is identical. - The summary lists everything the report holds, including ccusage's own cost estimate client_cost_usd (on every row and in totals), the random report_id and the collector / scrubber versions. Show it to your human as printed. - It never submits anything and never touches the network: submitting is a separate step that you run only after your human says OK. - If seedollar-scrub.mjs exits non-zero, no report was written: stop and show your human its message. Never open, print, edit, filter or rewrite usage.json, and never write a report by hand - that would be you changing the numbers. Your human decides whether to re-collect with the pinned ccusage command. - Do not run it if the sha256 does not match. Re-read this file (a deploy updates the file and the published sha256 together) and download again. - The ?v= in the URL is only a cache key - the server answers an old v with the current file. The sha256 is the lock. - What the sha256 protects against: a truncated download, a cached copy of another version, a copy from somewhere else. It does NOT protect against this site itself being compromised; for that the file is unminified and readable end to end. - Windows PowerShell 5.1: its > writes UTF-16, so use -o for the report. The scrubber reads a UTF-16 usage.json fine. Download with the curl.exe line (download_windows in /.well-known/seedollar.json): in PowerShell 5.1 a bare curl is Invoke-WebRequest and the plain download line fails. - This removes transcription mistakes; it is not a verification of the numbers. The server cannot tell an edited file from an untouched one if the hash was recomputed - so your human checks the summary, and the content_sha256 in the response and on the result page must equal the one the scrubber printed. ## A ticket from your human (keeps one identity) - Your human can give you a ticket for their existing identity: on /me they press "give my agent a ticket" and paste you a short text with test_id and upload_token. - Use it instead of POST /v1/tests. The result then lands on their identity (submission_path is still "agent"); calling POST /v1/tests yourself would create a second, separate identity. - It is the same kind of ticket: single use, can only submit that one test_id, and expires 600 seconds after your human pressed the button - not after you read it. Do the collect / scrub / OK steps first; if it expired (401 expired), ask your human for a new one. ## Endpoints POST /v1/tests auth: none or Authorization: Dollar (Dollar desktop app only) Start a speedtest. Returns a one-time upload_token and (for new visitors) creates an anonymous subject. - No request body required. - Response: { test_id, upload_token, expires_in, schema_version, result_url, subject_url, subject_public_id, subject_created }. Share result_url (/t/, pinned); subject_url (/r/) moves on every retest. - Browsers additionally receive an httpOnly subject cookie. Agents do not, and must not ask a user for one. - Dollar (desktop app) sends Authorization: Dollar and gets a ticket for the subject that issued that token; no cookie is set. - Agents: without a credential this creates a NEW anonymous identity every time. If your human already has a See Dollar identity, ask them for a ticket from /me (POST /v1/subjects/me/agent-ticket) and skip this call - the result then lands on their existing identity. POST /v1/tests/{test_id}/report auth: Authorization: Bearer or browser subject cookie or Authorization: Dollar (Dollar desktop app only) Submit a usage report for this test. This is the only write endpoint an agent ever needs. - Header: Authorization: Bearer . This is the path agents use. - Body: a schema 0.3.0 report object (see fields below). Send the report and nothing else. - Agents: the body is the file seedollar-scrub.mjs wrote, sent byte-for-byte (curl --data-binary @seedollar-report.json) after your human said OK. Never build, convert or edit the JSON yourself. - An accepted / unchanged response carries content_sha256: it must equal the one seedollar-scrub.mjs printed. If it does not, tell your human. - evidence_source must be "local_scan" for now. The other values in the schema are planned but cannot be stored yet; they get 400 unsupported_evidence_source. - Response (202): status is "accepted" or "unchanged" (same content resubmitted) with the rank fields, or "not_ranked" when no row in the window is rankable (stored, not on the leaderboard; nothing to poll). - The token is scoped to this test_id and expires after 600 seconds. It cannot read or modify anything. - A rejected report does NOT burn the token: re-run seedollar-scrub.mjs and resubmit its output with the same ticket within the 600 seconds (independent implementations: fix your implementation). - Do NOT put submission_path in the report. It does not exist in the schema. The server records how you got here (Bearer token => agent, browser cookie => web_upload, Dollar client token => dollar) from your credential, never from the body. - A stored report can still be kept off the leaderboard: the response then has status "accepted", rank_status "withheld", and rank_withheld_reason + rank_withheld_message (machine code + human sentence; both are null otherwise). Reasons: "duplicate_of_existing_subject" (the same content_sha256 is already the current result of another identity; the first submission counts, so do not resubmit the same file from a fresh ticket/identity), "quarantined" (outlier review pending; enters the leaderboard once cleared), "quarantine_rejected". GET /v1/tests/{test_id} auth: none Poll a test for its result. Returns rank_status = pending until the rank has been computed. - status first: "awaiting_report" = the ticket exists but no report was submitted yet; "not_ranked" = the report is stored but had no rankable rows, so there will never be a rank - STOP polling; "ready_or_pending" = a result exists, branch on rank_status. - rank_status is the ONLY field you branch on for the rank. Never infer state from rank_at_ingest being null. - rank_status is one of pending | ready | failed | withheld. Stop polling on ready, failed or withheld (withheld = stored and visible to you, not on the leaderboard; see rank_withheld_reason / rank_withheld_message). - rank_at_ingest is null while rank_status is pending, failed or withheld. The field is always present. - stale (read-only): true when window_to is more than 3 days before today (UTC). A result covers the 30 days ending on its window_to, not "the last 30 days"; it is frozen at submission and does not roll. Stale results stay on the leaderboard, marked. Submit a fresh report to refresh. - facets (frozen at submission): by_day[30], active_days, models, breakdown, rankable_channels. null for results older than facets. GET /v1/leaderboard auth: none The public 30-day token leaderboard. - Query: window (only "30d" is valid; anything else is a 400), limit (default 50, max 200), offset. - Query: public_id=<10 chars> returns just that subject’s row, including its rank. - Query: region= keeps only subjects that self-declared that region. Ranks stay global (they are not re-numbered); an unknown code is a 400 bad_region. - Ranking metric is rankable_token_volume_v1. evidence_source and submission_path are shown but never sorted on. - Each entry carries window_from / window_to (the 30 days that score covers) and stale (true when window_to is more than 3 days before today, UTC). Stale entries are NOT removed or re-ranked; they are only marked. - One report counts once: if the same content_sha256 is the current result of several identities, only the earliest submission is on the leaderboard. Withheld results (duplicate / under outlier review) are not on it. GET /v1/leaderboard/regions auth: none How many leaderboard entries self-declared each region. Aggregate only; small counts are masked. - Query: window (only "30d" is valid; anything else is a 400). - Counts the same set as /v1/leaderboard (withheld, retired and duplicate-content results are not on it), so total equals /v1/stats subjects_ranked. - Response: { window, total, regions: [{ region, count }], unknown, generated_at }. count is a number, or "lt3" when fewer than 3 entries declared that region. Numeric regions come first (count desc, then code); "lt3" regions follow in code order, so the order does not reveal 1 vs 2. - unknown = entries that declared no region: "lt10" when fewer than 10, otherwise { count, approx: true } with count rounded DOWN to a multiple of 10. - region is the self-declared ISO 3166-1 alpha-2 code set on /me. It is never inferred from IP or headers, and entries without one are never placed on a map. - Public and cacheable: Cache-Control public, max-age=60. GET /v1/stats auth: none Site-wide counters for the 30-day window. - Returns subjects_ranked and total_rankable_token_volume_v1 over the same comparison set the leaderboard uses. - total_rankable_token_volume_v1 is the sum of the results currently on the leaderboard. Each result covers the 30 days ending on its own window_to (some may be stale), so the total is NOT "tokens in the last 30 days". - Retired subjects are excluded, so these counters never double-count one person who started fresh. GET /v1/hit/stats auth: none Anonymous share-funnel counters: how many times each whitelisted share / page event happened per UTC day. - Query: days (default 30, max 90). Anything else is a 400 bad_days. - Response: { from, to, days: [{ day, counts: { : n } }], totals, events, note, generated_at }. Every day in the range is listed; a day with no events has counts {}. - Counts are clicks / page views, not people. Nothing behind them is stored: no id, IP, user agent or cookie. They are written by See Dollar pages when a visitor shares or follows a shared link; agents have nothing to write here. - Public and cacheable: Cache-Control public, max-age=60. GET /v1/subjects/me auth: browser subject cookie or Authorization: Dollar (Dollar desktop app only) Your own current result and rank. Requires the browser subject cookie (or a Dollar client token). - An upload_token is always rejected here with 401. A ticket can write one score; it can never read an identity. - Response also carries rank_delta: previous rank_at_ingest minus current, only when both revisions are ready; otherwise null. Dollar shows it as the arrow next to MY SEE DOLLAR POSITION. - Agents: use GET /v1/leaderboard?public_id= instead. It needs no credential. - current carries stale and rank_withheld_reason / rank_withheld_message like GET /v1/tests/{test_id}; leaderboard_entry is null while the current result is withheld. - Not reachable by an agent (needs a browser cookie). See the note above for the equivalent. POST /v1/subjects/me/reset auth: browser subject cookie NOT ME / START FRESH. Retires the current subject and clears the cookie. - There is no account and no recovery. Once retired, the old public_id cannot be recovered. - Already-shared result links keep working. The retired score leaves the leaderboard. - Not reachable by an agent (needs a browser cookie). See the note above for the equivalent. POST /v1/subjects/me/profile auth: browser subject cookie Set or clear your display name and self-declared region. Browser cookie only. - Body fields are independent and optional: { display_name: "Sam_L" | null, region: "SG" | null }. Absent field = unchanged; null = clear. - display_name: 3-20 chars of [A-Za-z0-9_-], globally unique (case-insensitive), reserved words rejected. Display only — it never becomes a credential and never affects ranking. - region: self-declared ISO 3166-1 alpha-2. Never inferred from IP or headers; display and filtering only. - 409 name_taken when the name is already in use. Clearing a name releases it for anyone. - Not reachable by an agent (needs a browser cookie). See the note above for the equivalent. POST /v1/subjects/me/agent-ticket auth: browser subject cookie Give my agent a ticket: issue ONE upload ticket bound to this subject, for the human to paste to their agent. Browser cookie only. - No request body. Response: { test_id, upload_token, expires_in, expires_at, schema_version, result_url, subject_url, subject_public_id }. The upload_token is shown once. - Same ticket as POST /v1/tests: single use, expires after 600 seconds, scoped to POST /v1/tests/{test_id}/report only. It cannot read the identity or issue anything. Issuing one does not cancel earlier tickets; each simply expires. - The agent submits with Authorization: Bearer . The server records submission_path "agent" and puts the result on the subject that issued the ticket, so one person keeps one identity across browser and agent. - Never creates a subject: without a valid cookie it is 401 no_credential. An upload_token or a Dollar client token is always 401 here. - Agents: you cannot call this. If you need a ticket for an existing identity, ask your human to press the button on /me and paste you the text it shows. - Not reachable by an agent (needs a browser cookie). See the note above for the equivalent. POST /v1/subjects/me/client-tokens auth: browser subject cookie Connect Dollar: issue a long-lived, revocable client token for this subject. Browser cookie only. - Body: { kind: "dollar" }. Response: { client_token, kind, created_at }. The plaintext token is shown exactly once. - Paste it into Dollar. Dollar then sends Authorization: Dollar to POST /v1/tests, POST /v1/tests/{test_id}/report and GET /v1/subjects/me. - A client token can never retire the subject or issue further tokens. Revoke at any time with /v1/subjects/me/client-tokens/revoke. - Not reachable by an agent (needs a browser cookie). See the note above for the equivalent. POST /v1/subjects/me/client-tokens/revoke auth: browser subject cookie Disconnect Dollar: revoke every client token of this subject. Browser cookie only. - Body: { kind: "dollar" }. Revoked tokens get 401 with reason "revoked" immediately. - Not reachable by an agent (needs a browser cookie). See the note above for the equivalent. GET /t/{test_id} auth: none Public page for ONE submission, pinned to the revision it created. This is the default share URL. - Immutable: it always shows the result of that submission, at the content_sha256 it was made with. - Share this one. /r/{public_id} shows the subject CURRENT result and changes every time they retest. GET /r/{public_id} auth: none Current result for a subject, by its 10-character public id. Changes on every retest. - Accepts both 7F2AC913K5 and the display form 7F2AC-913K5. - This is the "see where I rank now" entry point, NOT the share URL. For sharing use /t/{test_id}. - JSON: current carries window_from / window_to, stale, rank_status (incl. withheld) and rank_withheld_reason / rank_withheld_message. ## Report format (schema 0.3.0) - schema_version is "0.3.0". - generated_at and snapshot.taken_at are DATE ONLY, "YYYY-MM-DD" in UTC. Clock times are rejected: they are not collected. - rows[].model_raw starts with one of these vendor prefixes: claude- gpt- o[0-9] gemini- codex- deepseek qwen llama mistral grok kimi glm (o[0-9] means the letter o followed by a digit; matching ignores case; one routing namespace such as "anthropic.", "openai." or "global.anthropic." may come first). seedollar-scrub.mjs folds every other model name into "custom" - private model names are not sent - and rows that then share date, tool and "custom" come out of it as one row carrying the summed tokens. Independent implementations are held to the same rule. - Older 0.2.0 reports (second-precision timestamps, unfolded model names) are still accepted. ## Report fields The report is strict: any key not in this list is rejected. schema_version, report_id, generated_at, evidence_source, snapshot, provenance, tools_detected, rows, totals, taken_at, window, kind, start, end, content_sha256, collector, collector_version, collector_command, scrubber_version, generator, generator_version, estimation_method, date, tool, model_raw, tokens, sessions, client_cost_usd, input, output, cache_read, cache_creation, reasoning ## snapshot.content_sha256 (required - the server recomputes it and rejects mismatches) Agents following this file do not compute it: seedollar-scrub.mjs does. This section is for people maintaining an independent, reviewed implementation (CI tooling, a desktop client). content_sha256 = lowercase_hex(SHA-256(UTF8(JCS(hash_scope)))) The server recomputes it and rejects the report on mismatch (rule V12). A guessed or omitted value always fails. hash_scope is a whitelist - exactly these keys, nothing else: domain: "seedollar-snapshot-v1" (a literal member of the object, not a byte prefix) schema_version: report.schema_version evidence_source: report.evidence_source window: kind = snapshot.window.kind, from = snapshot.window.start, to = snapshot.window.end (NOTE the rename: start->from, end->to) tools_detected: report.tools_detected, sorted by Unicode code point rows: report.rows, sorted by (date, tool, model_raw) code point; each row keeps ONLY date, tool, model_raw, sessions and tokens{input,output,cache_read,cache_creation,reasoning} Excluded on purpose (hashing the whole report will NOT match): - totals — a pure function of rows, redundant - rows[].client_cost_usd — floats differ across implementations - report_id — a fresh uuid each time; including it would break idempotency - generated_at and snapshot.taken_at — clocks are not content - the entire provenance block — packaging metadata, not content - snapshot.content_sha256 itself — anti-recursion RFC 8785 JCS: object keys sorted by Unicode code point at every level, no whitespace, no trailing newline. UTF-8, no BOM. Integers only inside tokens; null is allowed only at rows[].tokens.reasoning and rows[].sessions. Self-check for independent implementations only (agents using seedollar-scrub.mjs skip this). For this report subset: {"schema_version":"0.3.0","evidence_source":"local_scan","snapshot":{"window":{"kind":"30d","start":"2026-07-14","end":"2026-08-12"}},"tools_detected":["codex-cli","claude-code"],"rows":[{"date":"2026-08-12","tool":"codex-cli","model_raw":"gpt-5.5","tokens":{"input":5,"output":6,"cache_read":7,"cache_creation":8,"reasoning":null},"sessions":null},{"date":"2026-08-11","tool":"claude-code","model_raw":"claude-opus-5","tokens":{"input":1,"output":2,"cache_read":3,"cache_creation":4,"reasoning":null},"sessions":null}]} the canonical JSON is: {"domain":"seedollar-snapshot-v1","evidence_source":"local_scan","rows":[{"date":"2026-08-11","model_raw":"claude-opus-5","sessions":null,"tokens":{"cache_creation":4,"cache_read":3,"input":1,"output":2,"reasoning":null},"tool":"claude-code"},{"date":"2026-08-12","model_raw":"gpt-5.5","sessions":null,"tokens":{"cache_creation":8,"cache_read":7,"input":5,"output":6,"reasoning":null},"tool":"codex-cli"}],"schema_version":"0.3.0","tools_detected":["claude-code","codex-cli"],"window":{"from":"2026-07-14","kind":"30d","to":"2026-08-12"}} and content_sha256 must be: 9e64e7b84c83f8f61d252b40cb12e32aa54576068ef54d0f7b38b07ba4ab478a Independent implementations: if yours does not reproduce that hex, fix your implementation before submitting - the server will reject the report. Agents: never edit a report to make a hash match; re-run the scrubber. ## When a submit fails 400 schema when: A field is missing, has the wrong type, or breaks a cross-field rule. This includes window.kind="30d" must span exactly 30 calendar days inclusive, and every row.date must fall inside the window. issues[] names each violation with its path. fix: If you used seedollar-scrub.mjs: do not edit the file. Re-download the scrubber (check the sha256 above), re-run it, show your human the new summary and POST the new file with the same ticket. Independent implementations: fix what issues[] lists. A schema failure does NOT consume the upload_token. 400 validation when: The report parsed but a validator rule rejected it. BY FAR the most common one: your content_sha256 does not match what the server recomputed (rule V12) - issues[0] then shows both hexes. NOTE: this is reported as error="validation", NOT as "content_sha256_mismatch". fix: If you used seedollar-scrub.mjs, the file was changed after the scrubber wrote it: re-run the scrubber and submit its output unchanged. Independent implementations: reproduce the self-check vector until your hex matches. Also does NOT consume the ticket. 401 unauthorized when: The reason field says which: no_credential (no Authorization header), already_used (the ticket was already consumed by a successful submit), expired (older than the TTL), not_your_test. fix: If your human gave you the ticket from /me, ask them for a new one - do not call POST /v1/tests, that is a different identity. Otherwise POST /v1/tests for a fresh ticket. Tickets are single-use and short-lived. 429 rate_limited when: Too many POST /v1/tests per hour from one IP without any credential. fix: Wait for the next hour. Reusing a credential you already hold is not rate limited. 413 payload_too_large when: The request body is larger than 2 MiB. Checked while streaming, before the body is parsed. fix: A 30-day report is a few tens of KB. A body this large is almost certainly not a report - check what you are sending. 400 unsupported_evidence_source when: The report parsed, but its evidence_source is not one the server can store yet. Accepted today: local_scan. agent_generated and user_estimated are planned (the schema already has them) but cannot be stored yet. fix: Submit the report seedollar-scrub.mjs wrote (it is always local_scan, rows produced by the collector named in provenance; see the ranking scope note below). Does NOT consume the ticket - resubmit the right file with the same upload_token. 400 unsupported_window when: A window= query value other than 30d. fix: Only 30d exists. The server never silently falls back to it. Rows outside the server window do not fail the submit - they are stored and simply not counted. A report entirely outside it returns 202 with rankable_token_volume_v1 = 0, not an error. The ranking window is anchored to the real today, not to the window declared in your report. Only rows that match a semantics profile count toward rankable_token_volume_v1: tool claude-code, codex-cli, gemini-cli [draft profile: results are labelled PROFILE: DRAFT] collected by ccusage >=20.0.0 <21.0.0 (`ccusage daily --json --by-agent`). Rows from any other tool, collector or collector version are stored but not counted. If no row inside the 30-day window counts, the submit returns 202 with status "not_ranked" and rankable_token_volume_v1 = null. ## Do not - Never write, convert, transcribe, round or edit any number in a report. The report is the file seedollar-scrub.mjs wrote; submit it byte-for-byte. If a submit is rejected, re-run the scrubber - never patch the JSON. - Never upload, paste or send the raw ccusage output (usage.json) anywhere - not to See Dollar, not into a chat. Only the scrubbed report leaves the machine. - Never open, print, edit, filter or rewrite usage.json yourself: only ccusage writes it and only seedollar-scrub.mjs reads it (printing it would send the raw output to your model provider). If seedollar-scrub.mjs exits non-zero, stop and show your human its message; never write a report by hand. - Never submit before your human has seen the scrubber summary and the report file and said OK. - If your human gave you a ticket from /me, do not call POST /v1/tests: that would put the result on a second identity. If the ticket expired, ask them for a new one. - Never ask a human to paste a long-lived API key into a chat. See Dollar never needs one; a ticket is single-use and expires in 10 minutes. - Do not send any field that is not in the allowed key list below. The schema is strict and will reject extras. - Do not set submission_path anywhere in the report. It does not exist in any schema version and the server decides it. - rank_status is the only source of truth for whether a rank exists. Do not infer it from other fields.