Screwtape.ai

对代理输出给出通过、失败或重试的判定以及共识评分。

托管 MCP 服务器

npx add-mcp 'https://screwtape.ai/mcp'

可安装到 Claude Code、Codex、Cursor 等客户端

文档

Facts

Price per call$0.005 USD for POST /v1/judge and POST /v1/consensus.
Free tier10 calls per UTC day per key. FREE_DAILY_LIMIT defaults to 10.
Evaluation PackEvaluation Pack $100 USD for 2,000 standard checks, valid 30 days, one per customer. Run a meaningful batch evaluation and test agent integration. Card only through PayPal, POST /v1/packs/checkout with pack trial. Up to 24,000 characters in total per call (task, agent_output and evidence combined; agent_output alone is at most 16,000 on every plan).
Networks and assetsBase (eip155:8453) USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913. Solana (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp) USDC mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.
AuthAuthorization: Bearer API key, or the x402 PAYMENT-SIGNATURE header. Signup is optional.
EndpointsPOST /v1/judge, POST /v1/consensus, and POST /v1/signup.
MCP URLhttps://screwtape.ai/mcp
Input limitstotal per call of task, agent_output or answers, evidence and criteria 24000 characters (12000 on a free key with no prepaid calls); task 8000 characters; agent_output and consensus output 16000 characters; evidence up to 20 items of 4000 characters; criteria up to 20 items of 500 characters; answers 2 to 16; agent_id 80 characters; request body 100000 bytes; signup name 80 characters; signup email 200 characters.
Data retentionRetention by plan: judged content is not stored unless you opt in. Free keys and prepaid packs (including the Evaluation Pack) keep signed receipts, and any content you opt in to keep, for 24 hours by default (set retention_days to 0 to keep none). Team keeps signed receipts for 1 year and Finance Control for 7 years; on either plan, opted-in content is kept 24 hours by default or 30, 90 or 365 days. Judged content is stored only when retain is true and the caller uses an API key. Day 0 stores neither content nor receipts. DELETE /v1/keys/me deletes the key, its retained rows, and its receipt log. Opt-in data sharing is off by default. A key that turns it on stores redacted samples of its calls for 90 days, and turning it off deletes them.
IndependenceScrewtape runs as a separate service from your agent, with its own prompt and checks, and it is not paid more when your agent passes. A successful judge or consensus call includes a signed receipt when receipt signing is configured. The receipt proves what was checked, when, and the verdict. It does not prove the verdict is correct. POST /v1/receipts/verify checks that receipt.
StatusGET /v1/status, /status.json, and /status/ report measured response time for judge and consensus. uptime is successful calls divided by successes, 5xx responses, and timeouts. It is null when that denominator is 0. Unpaid 402 responses are a separate count and are not failures. 401 and 429 responses are not failures either.

Screwtape.ai

Verify before the agent pays. Screwtape.ai is a separate check on agent work before a payment goes out: invoices, duplicate invoices (POST /v1/duplicate) and reconciliation, with signed receipts. It also returns a pass, fail, or retry verdict on another agent's output, and scores consensus across several agents, including false-consensus risk. It is the agent validation API, not the arXiv paper also named Screwtape. Authenticate with Authorization: Bearer or, for a single call, an x402 PAYMENT-SIGNATURE header. That payment is $0.005 USDC on Base or Solana. Base (eip155:8453) is paid to the payTo in the 402 response, also listed in /pricing.json. Solana payTo is the wallet owner. USDC is sent to that owner's associated token account for the mint. Solana is listed when that payee is set and settles through https://x402.dexter.cash. Payments are verified and settled through a facilitator. The worker does not hold a private key. When a model key is configured, the default model is openai/gpt-oss-120b, then Cloudflare Workers AI @cf/meta/llama-3.1-8b-instruct-fast if Groq fails, then deterministic checks. Each model call is capped at 600 output tokens. Judged content is not stored unless retain is true or the API key turned on opt-in data sharing (off by default; redacted, kept 90 days). A successful judge or consensus response includes a receipt when RECEIPT_SIGNING_KEY is set. The receipt is Ed25519 over the canonical JSON of its fields. Public keys are at /.well-known/screwtape-receipt-keys.json. POST /v1/receipts/verify checks one receipt. When the secret is unset, the response omits receipt. The public spec omits operator-only routes.

Base URL: https://screwtape.ai

GET /v1/health

Health check (no key)

Liveness. Needs no API key and reports no secrets. The body includes paypal_configured. GET /health returns the same body. Not a substitute for GET /v1/keys/me when you need quota.

  • 200: Service is up.

GET /health

Health check alias

Same response as GET /v1/health.

  • 200: Service is up.

POST /v1/judge

Judge one agent output: pass, fail or retry

Returns a verdict (pass, fail or retry), a score from 0 to 1, reasons, claims, optional check results, verification, the engine that ran (groq, workers_ai, deterministic, or demo), a request_id, a retention object, and a receipt when signing is configured. Groq judges claims first. Workers AI is the next fallback. String checks run only when both are unavailable. Finance checks fail closed: on /v1/duplicate, on any call with a duplicate or sum check, and on plan calls, only the primary model (groq) can pass. If it is unavailable, Workers AI is not called and the response is verdict retry, fail_closed true and next_step escalate primary_engine_unavailable (advisory mode: recommended_action review). A failed check or a duplicate match still fails. Every other call keeps the fallback, and engine is always in the response and the signed receipt. Optional checks are run by the worker. A failed check forces fail. Local checks (required_fields, types, format, sum, equals, regex, numeric, json_schema) run first; if one fails the call returns at once with verification failed, no model call, and next_step naming what to fix. Every response has next_step {action: pass, retry or escalate; error_type; hint}. A bad required_fields, types, format or sum check returns 400 invalid_checks with field, expected and details. Call it before returning or acting on one agent's output. Send the evidence the output should be grounded in, and criteria it must meet. It does not search the web except for a url_contains check you send. For several answers to the same task, use POST /v1/consensus. On retry, feed the reasons back and call again with a small cap. On fail, stop. Auth: Authorization: Bearer (free key: 10 calls per UTC day), or with no key, an x402 PAYMENT-SIGNATURE of $0.005 USDC on Base or Solana. Without either, GET, HEAD, and POST return 402 with x402Version, accepts, and error at the top level, plus signup_url and pricing_url. Auth and payment are checked before the body is validated. Advisory mode keeps a person in charge. Screwtape checks the invoice or payment instruction, signs what it found, and recommends approve, review or return. It never approves or releases a payment itself, and the signed receipt records that the call was advisory. Use blocking mode when your pipeline should stop automatically on a fail; use advisory mode when your controls require a human approver. Send mode: advisory for this; leaving it out (blocking) is unchanged. Plan keys can send policy_id with decision_version 2 for an invoice decision (approve, needs_review, deny or retry) from structured invoice fields or invoice_text, optionally with their own model key in X-Model-Key: https://screwtape.ai/docs/plans/#invoice-decisions.

  • Idempotency-Key: Optional. 8 to 128 letters, digits, _ or -. Send the same key on a retry and you get the stored response back with Idempotent-Replayed: true, with no second charge, no second x402 settlement and the same receipt. Kept 24 hours per API key (or per x402 payer). Only a billed 2xx response is stored: an error or a degraded (unbilled) result frees the key so the retry runs again. The same key with a different body or endpoint is 422 idempotency_key_reused. While the first call is still running, a retry gets 409 idempotency_in_progress with Retry-After: 2. An x402 retry still needs a payment header that verifies, but a replay never settles it. With a duplicate check, a retry after the 24 hours (or after a released key) reports duplicate.status replay instead of duplicate.
  • X-Model-Key: Optional, plan keys with decision_version 2 and a policy_id only. Your own model key, used for needs_review triage on this one request. Never stored, logged, signed or returned. If the provider fails, the decision stays needs_review (decision_source rules); there is no fallback to our model. Billed as one normal call. Not available over MCP.
  • X-Model-Provider: Optional. groq (default) or openai-compatible.
  • X-Model-Base-URL: Required for openai-compatible. One of: https://api.groq.com/openai/v1, https://api.openai.com/v1, https://api.together.xyz/v1, https://api.fireworks.ai/inference/v1, https://openrouter.ai/api/v1, https://api.mistral.ai/v1, https://api.deepseek.com/v1.
  • X-Model-Id: Optional model id, 1 to 120 of A-Z a-z 0-9 . _ : / -. groq defaults to openai/gpt-oss-120b.
{
  "task": "State the crew size and launch site.",
  "agent_output": "The crew size was 4. The mission launched from Cape Canaveral.",
  "evidence": [
    "The mission launched on 2024-03-01 from Cape Canaveral.",
    "The crew size was 4."
  ],
  "criteria": [
    "crew size",
    "Cape Canaveral"
  ]
}
  • 200: Verdict.
  • 400: Invalid request. invalid_checks for a bad required_fields, types, format, sum or duplicate check, with field, expected and details (at most 10).
  • 401: API key was not recognized, or key_required: a duplicate check was sent without a Bearer key.
  • 402: Payment required. The body has x402Version, accepts, and a string error at the top level, plus error_detail, signup_url, pricing_url, and pricing_json_url. PAYMENT-REQUIRED carries the x402 requirements, including the Bazaar discovery extension. GET and HEAD return this same 402.
  • 409: idempotency_in_progress: a call with the same Idempotency-Key is still running. Retry after the Retry-After seconds (2).
  • 413: payload_too_large: invoice_text takes at most 5000 characters. Send larger invoices as structured invoice fields.
  • 422: idempotency_key_reused: the Idempotency-Key was used in the last 24 hours with a different body or endpoint.
  • 429: Daily quota or rate limit exceeded.
  • 503: duplicate_unavailable: the duplicate check is not configured. Send the call without it or retry later.

GET /v1/judge

Judge payment requirements

Returns the same HTTP 402 as an unpaid POST. The PAYMENT-REQUIRED header is the x402 requirements object: accepts for Base and Solana, plus Bazaar discovery metadata whose input schema describes the POST body. This method does not run the call.

  • 402: Same payment requirements as an unpaid POST. error is a string. error_detail is the structured object. PAYMENT-REQUIRED carries accepts and extensions.bazaar, including the input schema.

HEAD /v1/judge

Judge payment requirements

Returns the same HTTP 402 as an unpaid POST. The PAYMENT-REQUIRED header is the x402 requirements object: accepts for Base and Solana, plus Bazaar discovery metadata whose input schema describes the POST body. This method does not run the call.

  • 402: Same payment requirements as an unpaid POST. error is a string. error_detail is the structured object. PAYMENT-REQUIRED carries accepts and extensions.bazaar, including the input schema.

POST /v1/duplicate

Duplicate invoice check before an agent pays

Checks an invoice an agent is about to pay against the invoices this API key recorded earlier, and judges it like POST /v1/judge. Same body, price and payment handling as POST /v1/judge, with one duplicate check required: send it in checks as {type: duplicate, vendor_path, invoice_id_path, amount_path, currency_path?, date_path?, window_days?, store?, retain_days?}, or as a top-level duplicate object with the same fields (type may be left out). agent_output is the invoice as JSON. Needs Authorization: Bearer, because fingerprints are kept per key; an x402 payment alone is not settled and returns 401 key_required. Only keyed HMAC fingerprints are stored, and only when store is true. The response has duplicate.status: unique, duplicate, possible_duplicate, replay or not_checked. duplicate escalates with next_step.error_type duplicate_invoice; possible_duplicate escalates too. Without a key or payment, GET, HEAD and POST return the same 402 as /v1/judge, before the body is validated. A body without a duplicate check returns 400 invalid_checks.

  • Idempotency-Key: Optional. 8 to 128 letters, digits, _ or -. Send the same key on a retry and you get the stored response back with Idempotent-Replayed: true, with no second charge, no second x402 settlement and the same receipt. Kept 24 hours per API key (or per x402 payer). Only a billed 2xx response is stored: an error or a degraded (unbilled) result frees the key so the retry runs again. The same key with a different body or endpoint is 422 idempotency_key_reused. While the first call is still running, a retry gets 409 idempotency_in_progress with Retry-After: 2. An x402 retry still needs a payment header that verifies, but a replay never settles it. With a duplicate check, a retry after the 24 hours (or after a released key) reports duplicate.status replay instead of duplicate.
  • X-Model-Key: Optional, plan keys with decision_version 2 and a policy_id only. Your own model key, used for needs_review triage on this one request. Never stored, logged, signed or returned. If the provider fails, the decision stays needs_review (decision_source rules); there is no fallback to our model. Billed as one normal call. Not available over MCP.
  • X-Model-Provider: Optional. groq (default) or openai-compatible.
  • X-Model-Base-URL: Required for openai-compatible. One of: https://api.groq.com/openai/v1, https://api.openai.com/v1, https://api.together.xyz/v1, https://api.fireworks.ai/inference/v1, https://openrouter.ai/api/v1, https://api.mistral.ai/v1, https://api.deepseek.com/v1.
  • X-Model-Id: Optional model id, 1 to 120 of A-Z a-z 0-9 . _ : / -. groq defaults to openai/gpt-oss-120b.
{
  "task": "State the crew size and launch site.",
  "agent_output": "The crew size was 4. The mission launched from Cape Canaveral.",
  "evidence": [
    "The mission launched on 2024-03-01 from Cape Canaveral.",
    "The crew size was 4."
  ],
  "criteria": [
    "crew size",
    "Cape Canaveral"
  ]
}
  • 200: Verdict.
  • 400: Invalid request. invalid_checks for a bad required_fields, types, format, sum or duplicate check, with field, expected and details (at most 10).
  • 401: API key was not recognized, or key_required: a duplicate check was sent without a Bearer key.
  • 402: Payment required. The body has x402Version, accepts, and a string error at the top level, plus error_detail, signup_url, pricing_url, and pricing_json_url. PAYMENT-REQUIRED carries the x402 requirements, including the Bazaar discovery extension. GET and HEAD return this same 402.
  • 409: idempotency_in_progress: a call with the same Idempotency-Key is still running. Retry after the Retry-After seconds (2).
  • 413: payload_too_large: invoice_text takes at most 5000 characters. Send larger invoices as structured invoice fields.
  • 422: idempotency_key_reused: the Idempotency-Key was used in the last 24 hours with a different body or endpoint.
  • 429: Daily quota or rate limit exceeded.
  • 503: duplicate_unavailable: the duplicate check is not configured. Send the call without it or retry later.

GET /v1/duplicate

Duplicate check payment requirements

Returns the same HTTP 402 as an unpaid POST. The PAYMENT-REQUIRED header is the x402 requirements object: accepts for Base and Solana, plus Bazaar discovery metadata whose input schema describes the POST body. This method does not run the call.

  • 402: Same payment requirements as an unpaid POST. error is a string. error_detail is the structured object. PAYMENT-REQUIRED carries accepts and extensions.bazaar, including the input schema.

HEAD /v1/duplicate

Duplicate check payment requirements

Returns the same HTTP 402 as an unpaid POST. The PAYMENT-REQUIRED header is the x402 requirements object: accepts for Base and Solana, plus Bazaar discovery metadata whose input schema describes the POST body. This method does not run the call.

  • 402: Same payment requirements as an unpaid POST. error is a string. error_detail is the structured object. PAYMENT-REQUIRED carries accepts and extensions.bazaar, including the input schema.

POST /v1/consensus

False-consensus check: score agreement across 2 to 16 agent answers

Returns consensus_answer, agreement_score, false_consensus_risk (low, medium or high), reasons, clusters, unsupported_agreement, verification, engine, request_id, retention, and a receipt when signing is configured. Clusters are overlap grouping only: each cluster's role is clustering. If the answers agree and the supplied evidence does not support the shared answer, unsupported_agreement is true and false_consensus_risk is raised. If the agreed answer fails an arithmetic check or a claim check, verification is failed and false_consensus_risk is high. Agreement cannot lower that risk. Identical wording alone does not raise the risk when the agreed answer verifies. In the receipt, verdict is false_consensus_risk and score is agreement_score. engine is groq, workers_ai, deterministic, or demo. Groq is first, Workers AI is next, and string checks are the last fallback. Optional evidence is a string or an array of up to 20 strings, each at most 4000 characters, and is used when scoring. Call it when several agents or samples answered the same task and you are about to accept the majority. High agreement with medium or high risk suggests copied or thin agreement: verify consensus_answer with POST /v1/judge before you use it. Not for a single answer. Same auth and price as POST /v1/judge: a Bearer key (free tier 10 calls per UTC day) or $0.005 USDC per call with x402 on Base or Solana. GET and HEAD return the same 402 as an unpaid POST. Advisory mode keeps a person in charge. Screwtape checks the invoice or payment instruction, signs what it found, and recommends approve, review or return. It never approves or releases a payment itself, and the signed receipt records that the call was advisory. Use blocking mode when your pipeline should stop automatically on a fail; use advisory mode when your controls require a human approver. Send mode: advisory for this; leaving it out (blocking) is unchanged.

  • Idempotency-Key: Optional. 8 to 128 letters, digits, _ or -. Send the same key on a retry and you get the stored response back with Idempotent-Replayed: true, with no second charge, no second x402 settlement and the same receipt. Kept 24 hours per API key (or per x402 payer). Only a billed 2xx response is stored: an error or a degraded (unbilled) result frees the key so the retry runs again. The same key with a different body or endpoint is 422 idempotency_key_reused. While the first call is still running, a retry gets 409 idempotency_in_progress with Retry-After: 2. An x402 retry still needs a payment header that verifies, but a replay never settles it. With a duplicate check, a retry after the 24 hours (or after a released key) reports duplicate.status replay instead of duplicate.
{
  "task": "State the crew size.",
  "answers": [
    {
      "agent_id": "alpha",
      "output": "The crew size was 4."
    },
    {
      "agent_id": "beta",
      "output": "The crew size was 4."
    },
    {
      "agent_id": "gamma",
      "output": "The crew size was 4."
    }
  ]
}
  • 200: Consensus.
  • 400: Invalid request.
  • 401: API key was not recognized.
  • 402: Payment required. The body has x402Version, accepts, and a string error at the top level, plus error_detail, signup_url, pricing_url, and pricing_json_url. PAYMENT-REQUIRED carries the x402 requirements. GET and HEAD return this same 402.
  • 409: idempotency_in_progress: a call with the same Idempotency-Key is still running. Retry after the Retry-After seconds (2).
  • 422: idempotency_key_reused: the Idempotency-Key was used in the last 24 hours with a different body or endpoint.
  • 429: Daily quota or rate limit exceeded.

GET /v1/consensus

Consensus payment requirements

Returns the same HTTP 402 as an unpaid POST. The PAYMENT-REQUIRED header is the x402 requirements object: accepts for Base and Solana, plus Bazaar discovery metadata whose input schema describes the POST body. This method does not run the call.

  • 402: Same payment requirements as an unpaid POST. error is a string. error_detail is the structured object. PAYMENT-REQUIRED carries accepts and extensions.bazaar, including the input schema.

HEAD /v1/consensus

Consensus payment requirements

Returns the same HTTP 402 as an unpaid POST. The PAYMENT-REQUIRED header is the x402 requirements object: accepts for Base and Solana, plus Bazaar discovery metadata whose input schema describes the POST body. This method does not run the call.

  • 402: Same payment requirements as an unpaid POST. error is a string. error_detail is the structured object. PAYMENT-REQUIRED carries accepts and extensions.bazaar, including the input schema.

GET /.well-known/screwtape-receipt-keys.json

Receipt signing public keys

JWKS-style Ed25519 public keys for verdict receipts. The current key comes first. After a key rotation the old public key stays listed with status retired and not_after, so older exports still verify. keys is empty when no key is configured. No private key is in this document.

  • 200: Public keys.

GET /v1/status

Measured response time, uptime, rate limits, and facilitator health

Returns measured p50 and p95 response time for POST /v1/judge and POST /v1/consensus over the last hour and the last 24 hours. uptime is successes divided by successes, 5xx responses, and timeouts (408 and 504). It is null when that denominator is 0. unpaid counts HTTP 402 and is not part of uptime. 401 and 429 responses are samples only. Facilitator health is the last cached probe of GET /supported on https://facilitator.xpay.sh and https://x402.dexter.cash, refreshed in the background with a 1.5 second timeout, and null until a probe finishes. The same JSON is at /status.json. A human page is at /status/. The 99.9% figure on the enterprise page is a target, not this measurement. With a valid Bearer key, the response also has key.data_sharing (true or false) for that key and is not cached by shared caches. Without a key the body is unchanged.

  • 200: Status.

GET /status.json

Status document

Same body as GET /v1/status.

  • 200: Status.

GET /v1/receipts

List signed receipts for the current key

Returns the signed receipts stored for this API key. The row is the receipt plus expires_at. It does not include the prompt or the output. Query from and to are ISO-8601 bounds on issued_at. limit defaults to 50 and offset defaults to 0. Keyless calls are not in this log. Requires a Bearer key. Rows are ordered by issued_at then receipt id. next_cursor (also in the X-Next-Cursor and Link rel="next" headers) is set when more rows follow; pass it back as cursor.

  • from: Inclusive ISO-8601 lower bound on issued_at.

  • to: Inclusive ISO-8601 upper bound on issued_at.

  • limit: Page size. Default 50.

  • offset: Rows to skip. Default 0.

  • order: desc (default, newest first) or asc.

  • cursor: next_cursor from the previous page. Do not send it with offset.

  • 200: Receipt page.

  • 400: Bad limit, offset, from, to, order or cursor, or cursor sent with offset.

  • 401: API key was not recognized.

GET /v1/receipts/export

Export signed receipts as JSONL, CSV or JSON

Same rows as GET /v1/receipts, for audits. format=jsonl returns application/x-ndjson, one receipt plus expires_at per line. format=json returns {receipts, count, next_cursor, verification:{keys_url, verify_url, alg, canonicalisation, unsigned_fields}}. format=csv returns text/csv. Its first 7 columns are unchanged: id, kid, issued_at, endpoint, verdict, score, expires_at. Then schema_version, request_sha256, verification, engine, agent_id, version, mode, checks (type:passed;...), signature and receipt_json (the signed receipt without expires_at). A CSV cell that starts with =, +, -, @, a tab or a carriage return is prefixed with ' so spreadsheets do not run it as a formula. limit is 1 to 1000 (default 50). from and to may be at most 92 days apart. order is desc (default) or asc. Rows are ordered by issued_at then receipt id, and next_cursor (also in the X-Next-Cursor and Link rel="next" headers) is set when more rows follow; pass it back as cursor. offset still works, but not together with cursor. At most 30 exports per minute per key; exports are free and do not use a call. The file does not include the prompt, evidence, output or answers, only hashes and labels. Coverage: the receipt log holds only what the key's retention_days allows. The default is 1 day, 0 stores nothing, and free and pack keys keep at most 1 day, so export daily. Keys on a subscription plan keep receipts for the plan's retention period. Keyless x402 calls and /try/ demo calls are never in the log, and each key sees only its own rows. To verify a row, drop signature and expires_at, build canonical JSON (sorted keys, no whitespace) and check the Ed25519 signature with the key from /.well-known/screwtape-receipt-keys.json, or POST the row without expires_at to /v1/receipts/verify. The full steps and Node and Python scripts are at /docs/#audit-export.

  • format: jsonl, csv or json.

  • from: Inclusive ISO-8601 lower bound on issued_at.

  • to: Inclusive ISO-8601 upper bound on issued_at. At most 92 days after from.

  • limit: Rows per page, 1 to 1000. Default 50.

  • offset: Rows to skip. Not with cursor.

  • order: desc (default, newest first) or asc.

  • cursor: next_cursor from the previous page.

  • 200: JSONL, CSV or JSON export of the receipt log. X-Next-Cursor and Link headers are set when more rows follow.

  • 400: Bad format, limit, cursor or order, from after to, a span over 92 days, or cursor with offset.

  • 401: API key was not recognized.

  • 429: More than 30 exports in a minute for this key. See the retry-after header.

POST /v1/receipts/{receipt_id}/publish

Publish one signed receipt

The key owner publishes one receipt. Nothing is public until this call. include_content defaults to false. When it is true, the task and the agent output are stored and shown only if that call was retained for this key. label is optional and at most 80 characters. The response includes the public page URL, the badge SVG URL, and HTML and Markdown embeds. The public id is not the receipt id. Republishing keeps the same public id.

  • receipt_id: Receipt id from the signed receipt.

  • 200: Public URL and embeds.

  • 400: The body was not valid, or the signature does not match.

  • 401: API key was not recognized.

  • 404: Receipt was not found for this key.

  • 409: include_content was true and no retained call matched.

DELETE /v1/receipts/{receipt_id}/publish

Unpublish a receipt

Removes the public page and the badge for a receipt this key published. Another key's receipt is not found.

  • receipt_id: Receipt id from the signed receipt.

  • 200: Unpublished.

  • 401: API key was not recognized.

  • 404: Receipt was not found for this key.

POST /v1/receipts/{receipt_id}/replay

Replay an invoice decision from its stored evidence

Plan keys, for receipts whose policy had evidence on. Re-runs the rules on the stored record with the original policy version (default) or the current one. A model is never called again. Returns original, replayed, match, diffs, model_part, document_match and a signed replay_receipt. Not billed.

  • receipt_id: Receipt id from the signed receipt.
{
  "policy": "original"
}
  • 200: Replay result with a signed replay_receipt.
  • 400: invalid_request: bad receipt id or body.
  • 404: receipt_not_found, evidence_not_found (evidence was off, or the decision was deny or retry), or policy_not_found.
  • 409: evidence_tampered, document_mismatch, or rules_version_unknown.
  • 410: evidence_deleted: the evidence was deleted or expired. The signed receipt is kept.

DELETE /v1/receipts/{receipt_id}/evidence

Delete the stored evidence for one receipt

Plan keys. Deletes the evidence record; the signed receipt is kept. Evidence is also in GET /v1/retention and erased by DELETE /v1/retention.

  • receipt_id: Receipt id from the signed receipt.

  • 200: {"deleted": true, "receipt_id": ...}

  • 404: evidence_not_found.

GET /r/{public_id}

Public page for one published receipt

Shows the verdict, the score when the receipt has one, the criteria hash or a short note, the time, the signing key id, and the signature. The task and the agent output appear only when the owner published them. The page says: this shows that this output received this verdict at this time. It is not a general safety certification of the agent. noindex. Unknown ids are 404.

  • public_id: Unguessable public id. Not the receipt id.

  • 200: HTML page for the published receipt.

  • 404: This receipt is not published.

GET /badge/

How the Screwtape Verified badge works

Explains the badge and shows HTML and Markdown embed snippets. PASS is teal on #0a0d12. FAIL and RETRY are neutral. Nothing is public until the key owner publishes a receipt.

  • 200: HTML guide.

GET /badge/{public_id}.svg

Screwtape Verified badge

Small SVG badge for a published receipt. PASS is teal on #0a0d12. FAIL, RETRY, and other results are neutral. The image can be cached. An unknown or unpublished id is a grey unverified badge with HTTP 404.

  • public_id:

  • 200: SVG badge. Content-Type is image/svg+xml.

  • 404: Grey unverified SVG badge.

GET /docs/checks/

How verifiable checks work

Optional checks on POST /v1/judge. The worker verifies a public URL, JSON, an exact or numeric value, or a free public chain read. A failed check forces fail. The model still judges claims when every check passes. Fetched pages are not stored.

  • 200: HTML guide.

GET /dashboard/

Key owner charts

noindex page. The customer pastes a key, which stays in sessionStorage and is sent as a Bearer token. Charts use GET /v1/drift. Recent alerts use GET /v1/drift/alerts. The alert webhook is GET and PATCH /v1/account/settings.

  • 200: HTML dashboard. noindex.

GET /docs/drift/

How agent tags and drift alerts work

Explains optional agent_id and version tags, the daily counts at GET /v1/drift, and the signed https webhook. Counts do not store the task or the output.

  • 200: HTML guide.

GET /v1/drift/alerts

Recent drift alerts for this key

Lists the last alerts recorded for this key: agent_id, metric, and sent_at. The task and the output are not included. Deleting the key deletes this history.

  • 200: Recent alerts.
  • 401: The API key was not recognized.

GET /v1/drift

Daily verdict counts for one key

Returns the daily series for the requested span (days defaults to 30, maximum 90), the previous 30-day baseline, and the last 24 hours. agent_id filters to one tag. Omit it to combine every tag on the key. Fail rate is fails divided by judge calls. Average false-consensus risk maps low to 0, medium to 0.5, and high to 1. The counts do not include the task or the output, and they are kept when retention_days is 0.

  • agent_id: Optional tag. Empty combines every tag on the key.

  • days:

  • 200: Drift series.

  • 400: agent_id or days was not valid.

  • 401: API key was not recognized.

GET /v1/account/settings

Read retention, anonymous-stats, and alert webhook settings

retention_days is 0, 1, 30, 90, or 365. The default is 1. 30, 90 and 365 need a key on an active subscription plan. contribute_anonymous_stats defaults to false. alert_webhook_url is the https URL for drift alerts, or null when unset. The signing secret is not returned.

  • 200: Settings.
  • 401: API key was not recognized.

PATCH /v1/account/settings

Update retention, anonymous-stats, and the alert webhook

Send retention_days as 0, 1, 30, 90, or 365 (30, 90 and 365 need a key on a subscription plan; other keys get 400 plan_required), contribute_anonymous_stats as a boolean, or alert_webhook_url as an https URL. An empty alert_webhook_url removes the webhook. Setting a URL returns alert_webhook_secret once. Omitted fields stay as they are. Existing stored rows keep their original expiry.

{
  "retention_days": 30,
  "contribute_anonymous_stats": false
}
  • 200: Updated settings.
  • 401: API key was not recognized.
  • 422: retention_days or contribute_anonymous_stats was not one of the allowed values.

POST /v1/enterprise/inquiry

Ask about enterprise pricing

Stores name, email, optional organization, and message. JSON returns {id, received, request_id}. A form post returns HTML that says Request received. name is required and at most 80 characters. organization is optional and at most 120 characters. message is required and at most 2000 characters.

{
  "name": "Ada",
  "email": "YOUR_EMAIL",
  "organization": "Example",
  "message": "We need a monthly invoice."
}
  • 200: Inquiry stored.
  • 400: A field was missing or too long.

GET /.well-known/screwtape-payees.json

Signed list of payment addresses

Every address Screwtape.ai takes USDC at, signed with the receipt key. To verify: remove kid and signature, then check the Ed25519 signature over the UTF-8 bytes of screwtape-payee-manifest-v1, a newline, and the canonical JSON of the rest (sorted keys, no whitespace). Reject it after expires_at. Never pay a payTo that is not listed. signature is null when signing is off.

  • 200: Signed payee manifest.

POST /v1/payees/verify

Check that a payment address is ours

Send {network, payTo} to ask whether an address is in the signed payee list, or {manifest} to check a saved manifest's signature and expiry. No key needed. Nothing is stored.

  • 200: Result.
  • 400: Invalid request.

POST /v1/receipts/verify

Verify a verdict receipt

POST the receipt object. The response is {valid:true} when the Ed25519 signature matches the current public key, and {valid:false} otherwise. This call does not require an API key and does not store the receipt.

  • 200: Signature check.

GET /v1/keys

Read how to create a free API key

GET /v1/keys does not create a key. POST the same path with optional email, name and data_sharing (off unless true) and a required pow_nonce. The human form is /signup/.

  • 200: Signup instructions, including the proof-of-work challenge.

POST /v1/keys

Create a free API key (alias of POST /v1/signup)

Identical to POST /v1/signup. Prefer /v1/signup in new code.

{
  "name": "crew-judge",
  "pow_nonce": "compute-me"
}
  • 201: Key created. The secret is in this response only.
  • 400: Invalid signup or proof of work. When the proof is missing, the body includes a pow object.
  • 429: Signup rate limit, daily cap, or email cap.

GET /v1/signup

Read how to create a free API key

GET /v1/signup does not create a key. POST the same path with optional email, name and data_sharing (off unless true) and a required pow_nonce. The human form is /signup/.

  • 200: Signup instructions, including the proof-of-work challenge.

POST /v1/signup

Create a free API key (agent signup with proof of work)

Creates a free-tier key (10 calls per UTC day) with no human approval. Send pow_nonce such that SHA-256 of the UTF-8 string screwtape-signup-v1:: has 12 leading zero bits and the nonce matches ^[A-Za-z0-9_-]{8,64}$. Email and name are optional. A 400 without a valid nonce includes a pow object describing the challenge. Returns 201 with api_key (shown once; only its SHA-256 hash is stored). Limits: 5 keys per IP per hour, 5 per IP per UTC day, 3 active keys per email when an email is sent. Same call as POST /v1/keys. Free; no key or payment needed.

{
  "name": "crew-judge",
  "pow_nonce": "compute-me"
}
  • 201: Key created. The secret is in this response only.
  • 400: Invalid signup or proof of work. When the proof is missing, the body includes a pow object.
  • 429: Signup rate limit, daily cap, or email cap.

GET /v1/keys/me

Show usage and credit balance for the current key

Returns metadata, usage and credit balance for the Bearer key. The secret is never returned. Call it to check remaining quota before a batch of judge or consensus calls. Requires a Bearer key; free.

  • 200: Key metadata.
  • 401: API key was not recognized.

DELETE /v1/keys/me

Delete the current key and all its data

Permanently deletes the key hash, daily counters, payment records, any retained judgments, shared samples and feedback for the Bearer key. Irreversible: only call it when the user asks to delete their key or data.

  • 200: Deleted.
  • 401: API key was not recognized.

GET /v1/keys/me/data-sharing

Read the opt-in data-sharing setting for the current key

Data sharing is off by default. Returns enabled, updated_at, consent_at and consent_source (when and where sharing was last turned on: signup_checkbox or api), stored_samples (how many redacted samples are stored for this key now), and a policy object that says what is stored, how it is redacted, and for how long. Requires a Bearer key; free.

  • 200: Data-sharing setting.
  • 401: API key was not recognized.

PATCH /v1/keys/me/data-sharing

Turn opt-in data sharing on or off for the current key

Send {"enabled": true} to store a redacted sample of each later judge or consensus call made with this key, kept 90 days. Calls that send share: false are not stored. Send {"enabled": false} to stop and to delete every stored sample for this key at once; deleted_samples says how many. Redaction is automatic and rule based (emails, phone numbers, Luhn-checked card numbers, IBANs, SSN-like and RUT-like ids, API keys and tokens, long digit runs). It is not anonymisation and can miss personal data. There is no reward for turning it on. Only call it when the user asks. Requires a Bearer key; free.

{
  "enabled": false
}
  • 200: Updated setting.
  • 400: Body was not {enabled: boolean}.
  • 401: API key was not recognized.

POST /v1/feedback

Say whether the verdict on one of your calls was right

Send receipt_id, or call_id (the request_id of the call), and verdict_correct. expected_verdict (pass or fail) and note (at most 300 characters) are optional. A key can only send feedback on its own calls; any other id returns 404. One feedback record per call: sending again replaces it (200), the first time returns 201. Stores ids, labels and the note after automatic redaction, never the judged text. Kept 90 days. Feedback is possible while the call's receipt is in this key's receipt log (retention_days 1, 30 or 90) or a shared sample for it exists. Free: it does not use a call. Limited to 30 requests per minute per key.

{
  "receipt_id": "rcpt_example",
  "verdict_correct": false,
  "expected_verdict": "fail",
  "note": "The output cites a figure the evidence does not contain."
}
  • 200: Feedback replaced.
  • 201: Feedback stored.
  • 400: Invalid body.
  • 401: API key was not recognized.
  • 404: No call with that id for this key.
  • 429: More than 30 feedback requests in a minute for this key.

GET /v1/retention

List calls this key chose to retain

Returns the 20 newest judge and consensus calls made with retain: true by this key. Rows expire after the key's retention_days: 1 day by default, or 30, 90 or 365 days on a subscription plan. Calls without retain are never stored, so they never appear here. Requires a Bearer key.

  • 200: Retained rows.
  • 401: API key was not recognized.

DELETE /v1/retention

Delete every retained call for this key's account

Free. Deletes every retained call for the key's subscription account (all its keys), or for this key when it has no plan, and returns how many were deleted. Requires a Bearer key.

  • 200: Deleted.
  • 401: API key was not recognized.

DELETE /v1/dedupe/fingerprints

Delete every duplicate-check fingerprint this key stored

Free. Deletes all keyed fingerprints the duplicate check stored for this key and returns how many were deleted. Deleting the key (DELETE /v1/keys/me) deletes them too. Requires a Bearer key.

  • 200: Deleted.
  • 401: API key was not recognized.

GET /v1/retention/{id}

Replay one retained call with its receipt

Audit replay. Returns the stored request and result for one retained call, plus its signed receipt when the receipt log still holds it. Add ?verify=1 to re-check the receipt signature; verified is then true or false. Requires the Bearer key that created it.

  • id: Retention id returned when retain was true.

  • verify: 1 re-checks the receipt signature.

  • 200: The retained call.

  • 401: API key was not recognized.

  • 404: No such row for this key.

DELETE /v1/retention/{id}

Delete one retained call

Deletes one retained judge or consensus call by its retention id (from retention.id in the original response or from GET /v1/retention). Requires the Bearer key that created it.

  • id: Retention id returned when retain was true.

  • 200: Deleted.

  • 401: API key was not recognized.

  • 404: No such row for this key.

GET /a2a

Describe the A2A JSON-RPC endpoint

GET does not judge and does not require a payment. POST message/send maps to a judge verdict.

  • 200: Endpoint description.

POST /a2a

A2A message/send

JSON-RPC method message/send, or the SendMessage alias. A data part with task and agent_output, or text parts, maps to POST /v1/judge. An unpaid POST returns HTTP 402 with x402Version, accepts, and error at the top level. A successful result is an agent message with text and data parts.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "message/send",
  "params": {
    "message": {
      "role": "user",
      "parts": [
        {
          "kind": "data",
          "data": {
            "task": "State the crew size and launch site.",
            "agent_output": "The crew size was 4. The mission launched from Cape Canaveral.",
            "evidence": [
              "The mission launched on 2024-03-01 from Cape Canaveral.",
              "The crew size was 4."
            ],
            "criteria": [
              "crew size",
              "Cape Canaveral"
            ]
          }
        }
      ]
    }
  }
}
  • 200: JSON-RPC result. The result is an agent message.
  • 400: The JSON-RPC request could not be judged.
  • 402: Payment required. x402Version, accepts, and a string error are top-level fields. Structured detail is error_detail.
  • 429: Rate limit exceeded.

POST /v1/packs/checkout

Start a PayPal card checkout for the Evaluation Pack (pack id trial)

Evaluation Pack (pack id trial) is $100 for 2,000 standard checks, valid 30 days, one per customer (email, PayPal account and API key), card only, with up to 24,000 characters in total per call (task, agent_output and evidence combined; agent_output alone is at most 16,000 on every plan) while it is the key's only prepaid balance. A second Evaluation Pack returns 409 trial_already_used. Retired packs return 410 pack_retired. Send pack and either an email or an api_key, not both. The response url is the PayPal approval page, where PayPal can also take a card. After approval, the return page captures the order and adds the calls to the key. If you sent an email and no key, that page shows a new API key once. A JSON body gets a JSON url. A form post is redirected to PayPal. This route returns 503 until PayPal is configured. PayPal capture and refund notifications are verified on a separate webhook that this public document omits.

{
  "pack": "trial",
  "email": "YOUR_EMAIL"
}
  • 200: Approval URL.
  • 303: Form post. The Location header is the PayPal approval URL.
  • 400: Pack, email, or api_key was not valid.
  • 401: API key was not recognized.
  • 409: trial_already_used: this email or API key already had an Evaluation Pack.
  • 503: PayPal checkout is not configured.

POST /mcp

MCP Streamable HTTP endpoint (judge, check_duplicate_invoice, consensus, get_api_key, buy_pack, publish_receipt)

JSON-RPC for initialize, tools/list, and tools/call. Tools are judge, check_duplicate_invoice, consensus, get_api_key, buy_pack, and publish_receipt. An unpaid judge or consensus tools/call returns HTTP 200 with result.isError true and structuredContent set to the PaymentRequirements. Pay by retrying with params._meta['x402/payment']. Settlement is echoed in result._meta['x402/payment-response']. publish_receipt needs the key that owns the receipt and does not take an x402 payment. GET is reserved and returns 405 because this server does not open an SSE stream. OPTIONS /mcp allows any Origin.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list"
}
  • 200: JSON-RPC result. An unpaid judge or consensus tools/call is HTTP 200 with result.isError true and structuredContent set to the PaymentRequirements. content[0].text is that same JSON. A settled call echoes result._meta['x402/payment-response']. get_api_key does not require a payment.
  • 202: Notification accepted.

Invoice decisions (decision_version 2)

Plan keys can ask POST /v1/judge (and POST /v1/duplicate) for an invoice decision instead of a pass or fail verdict. Send policy_id and decision_version 2. The policy must set rules.max_amount. Accounts created on or after 2026-12-23 get version 2 by default when they send a policy_id.

Send exactly one of invoice (structured fields), invoice_text (plain text) or agent_output. task is optional and defaults to "Check this invoice against the payment policy." Without decision_version 2, invoice, invoice_text and po return 400 invalid_request.

The response has decision (approve, needs_review, deny or retry), decision_source (rules, or rules+model when a model added triage), the rules that ran, review_id when a review was opened, and the usual reasons and receipt. deny and retry come from the rules alone and are not billed. approve and needs_review are billed as one normal call. needs_review opens a review for a person.

  • invoice: vendor, invoice_number, po_number, currency (3 letters), issue_date (YYYY-MM-DD), line_items (description, qty, unit_price, amount), subtotal, tax, total. Amounts are numbers or decimal strings with up to 6 decimals. At most 2,000 line items; the whole body is at most 256 KB.
  • po (optional): po_number, vendor, currency, total. Checked against the invoice.
curl -s https://screwtape.ai/v1/judge \
  -H "Authorization: Bearer $SCREWTAPE_KEY" -H "Content-Type: application/json" \
  -d '{"policy_id":"ap-default","decision_version":2,
       "invoice":{"vendor":"Acme Ltd","invoice_number":"INV-1042","currency":"USD","issue_date":"2026-10-01",
                  "line_items":[{"description":"Support","qty":1,"unit_price":"1200.00","amount":"1200.00"}],
                  "subtotal":"1200.00","tax":"0","total":"1200.00"},
       "po":{"po_number":"PO-77","vendor":"Acme Ltd","currency":"USD","total":"1200.00"}}'

invoice_text: plain-text invoices

invoice_text is a string of at most 5,000 characters. The worker reads vendor, invoice number, currency, line items, subtotal, tax and total from it with fixed parsing rules. No model reads the text, and the text is not stored or signed: only its SHA-256 goes into the receipt. The response has extraction: "deterministic".

Longer text returns 413 with error.code payload_too_large and the message "invoice_text takes at most 5000 characters. Send larger invoices as structured invoice fields." For bigger invoices, send the structured invoice fields instead. They have no character limit (up to 2,000 line items and a 256 KB body) and are read exactly.

If the text does not reconcile (two different totals, line items that do not add up, or a missing vendor, currency or total), the decision is needs_review with error_type extraction_unreconciled. Text that cannot be read is never denied on that basis; a person reviews it. Text that reconciles goes through the policy like a structured invoice, so a total over max_amount is still denied.

curl -s https://screwtape.ai/v1/judge \
  -H "Authorization: Bearer $SCREWTAPE_KEY" -H "Content-Type: application/json" \
  -d '{"policy_id":"ap-default","decision_version":2,"invoice_text":"Acme Ltd\nInvoice INV-1042\nCurrency USD\nTotal 1200.00"}'

Own model key (X-Model-Key)

On a needs_review decision, a policy with model_triage set asks a model for a short explanation. A model can never loosen a decision. You can have that triage run on your own model account by sending these headers with a decision_version 2 call that has a policy_id. Without both, X-Model-Key returns 400: "X-Model-Key works only with decision_version 2 and a policy_id."

Your key is used for that one request only. It is never stored, logged, signed or returned in a response. The receipt records only the provider host and the model id.

If your provider fails, times out (15 seconds) or returns something unusable, the decision stays the rules decision, needs_review, with decision_source rules. The call does not fall back to our model. An own-key call is billed as one normal call, the same as any approve or needs_review.

Own-key headers are not available over MCP. The MCP server ignores X-Model-* headers on every tool. Use the HTTP API.

curl -s https://screwtape.ai/v1/judge \
  -H "Authorization: Bearer $SCREWTAPE_KEY" -H "Content-Type: application/json" \
  -H "X-Model-Key: $YOUR_GROQ_KEY" \
  -d '{"policy_id":"ap-default","decision_version":2,"invoice":{...}}'

Evidence and replay

Turn evidence on per policy with evidence: {"enabled": true, "retain_days": 90, "store_vendor_name": false}. retain_days is 1 to 400 (default 90). Each approve or needs_review then keeps one small record: the fields the rules read, the policy version and the rule results. It keeps no line descriptions, addresses or bank details, and the vendor is a keyed hash unless store_vendor_name is true. The record's SHA-256 is signed into the receipt as evidence_sha256. deny and retry keep no evidence.

Evidence counts toward the content history quota and is included in GET /v1/retention (export) and DELETE /v1/retention (erase). When the quota is full, evidence is skipped; the decision is never blocked.

POST /v1/receipts/{receipt_id}/replay re-runs the rules on the stored record. policy is original (the policy version used at the time, the default) or current. Optional document is the original body (for example {"invoice": {...}}) to prove it is the same document. The response has original, replayed, match, diffs, model_part ("recorded, not re-run" or "no model ran"), document_match and a signed replay_receipt. A model is never called again. Replay needs a plan key and is not billed.

DELETE /v1/receipts/{receipt_id}/evidence deletes the record and returns {"deleted": true}. The signed receipt is kept.

  • 400 invalid_request: bad receipt id or body.
  • 404 receipt_not_found, evidence_not_found (evidence was off, or the decision was deny or retry), policy_not_found.
  • 410 evidence_deleted: deleted or expired; the receipt is kept.
  • 409 evidence_tampered (record does not match the signed hash), document_mismatch, rules_version_unknown.
curl -s -X POST https://screwtape.ai/v1/receipts/$RECEIPT_ID/replay \
  -H "Authorization: Bearer $SCREWTAPE_KEY" -H "Content-Type: application/json" \
  -d '{"policy":"original"}'

Audit export: how an auditor verifies receipts

Coverage: the receipt log holds only what the key's retention_days allows. The default is 1 day, 0 stores nothing, and free and pack keys keep at most 1 day, so export daily. Keys on a subscription plan keep receipts for the plan's retention period. Keyless x402 calls and /try/ demo calls are never in the log, and each key sees only its own rows.

  1. Export with GET /v1/receipts/export?format=jsonl (or csv or json), from and to at most 92 days apart, limit up to 1000. Follow next_cursor (also in the X-Next-Cursor and Link rel="next" headers) until it is null. At most 30 exports per minute per key; they are free and do not use calls.
  2. Fetch /.well-known/screwtape-receipt-keys.json and pick the JWK whose kid matches the receipt. Retired keys stay listed with status retired and not_after; they only vouch for receipts issued up to not_after.
  3. Remove signature and expires_at. expires_at is export metadata and is not signed. In CSV, the receipt_json column is the signed receipt without expires_at.
  4. Build the canonical JSON: keys sorted, no whitespace, standard JSON numbers, absent fields left out.
  5. Verify the base64url-decoded 64-byte Ed25519 signature over the UTF-8 bytes of that JSON. Or POST the receipt without expires_at to /v1/receipts/verify.

Node:

// node verify.mjs receipts.jsonl   (Node 18+, no packages)
import { readFileSync } from "node:fs";
import { createPublicKey, verify } from "node:crypto";
const doc = await (await fetch("https://screwtape.ai/.well-known/screwtape-receipt-keys.json")).json();
const canon = (v) => Array.isArray(v) ? `[${v.map(canon).join(",")}]`
  : v && typeof v === "object" ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`
  : JSON.stringify(v);
let bad = 0;
for (const line of readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean)) {
  const { signature, expires_at, ...fields } = JSON.parse(line);
  const jwk = doc.keys.find((k) => k.kid === fields.kid && (!k.not_after || Date.parse(fields.issued_at) <= Date.parse(k.not_after)));
  const key = jwk && createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, format: "jwk" });
  const ok = Boolean(key) && verify(null, Buffer.from(canon(fields), "utf8"), key, Buffer.from(signature, "base64url"));
  if (!ok) bad += 1;
  console.log(ok ? "ok " : "BAD", fields.receipt_id);
}
process.exit(bad ? 1 : 0);

Python:

# pip install cryptography; python3 verify.py receipts.jsonl
import base64, json, sys, urllib.request
from datetime import datetime
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def b64(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def when(s): return datetime.fromisoformat(s.replace("Z", "+00:00"))
def canon(v): return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
url = "https://screwtape.ai/.well-known/screwtape-receipt-keys.json"
keys = json.load(urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "receipt-audit"})))["keys"]
bad = 0
for line in open(sys.argv[1], encoding="utf-8"):
    if not line.strip(): continue
    fields = json.loads(line); fields.pop("expires_at", None); sig = b64(fields.pop("signature"))
    jwk = next((k for k in keys if k["kid"] == fields["kid"] and ("not_after" not in k or when(fields["issued_at"]) <= when(k["not_after"]))), None)
    try:
        Ed25519PublicKey.from_public_bytes(b64(jwk["x"])).verify(sig, canon(fields).encode("utf-8")); ok = True
    except Exception: ok = False
    bad += not ok
    print("ok " if ok else "BAD", fields["receipt_id"])
sys.exit(1 if bad else 0)