Errors

Errors are JSON with an error string and, where useful, structured detail. Two Writ-specific gotchas first, because they surprise people:

  • DENY is HTTP 200. POST /v1/check returns 200 for ALLOW, DENY, and STEP_UP alike — the decision is in the body. Only auth failures, quota exhaustion, and malformed requests change the status code.
  • An invalid auth token is HTTP 200. POST /v1/tokens/verify returns {"valid": false, "reason": "..."} with a 200. Only a bad API key on that endpoint is a 401.

Status codes

CodeMeaningTypical body
200OK — including DENY/STEP_UP decisions and valid: false token checksthe resource or decision
201Created — key, invite, grant, revocation, sandbox grantthe created object
400Bad request — malformed JSON body, missing fields, invalid enum values{"error": "missing fields", "fields": [...]}
401Unauthorized — missing, malformed, or unknown credential{"error": "API key required..."} or {"error": "sponsor token required"}
402Payment required — free check quota exhausted{"error": "free tier exhausted", "checksUsed": 100, "monthlyChecks": 100, ...}
404Not found — unknown route, or a receipt that isn’t yours{"error": "not found"} / {"error": "receipt not found"}
429Rate limited — key issuance, 10/hour per IP{"error": "too many keys requested from this address; try again later"}

Auth errors (401)

Writ has two credentials and they are not interchangeable. Using an API key where the sponsor token is required (grants, revoke, reinstate, revoked list) returns {"error": "sponsor token required"}; using a sponsor token on an API-key endpoint returns the API-key error. An unknown or malformed API key returns {"error": "unknown API key"}.

Quota (402)

Each API key gets 100 checks per calendar month on the free tier. Exhausting it returns 402 on POST /v1/check — the check is not evaluated and no receipt is written. The response tells you where you stand:

{
"error": "free tier exhausted",
"checksUsed": 100,
"monthlyChecks": 100,
"checkout": "/v1/checkout"
}

Quota resets on the first of the month. The kill switch (POST /v1/revoke) is never gated — revocation always works.

Not found (404)

GET /v1/receipts/{receiptId} returns 404 both when the receipt doesn’t exist and when it belongs to another tenant — deliberately, so one tenant cannot probe another’s receipt IDs. Unknown routes return {"error": "not found"}.

Retries

POST /v1/check is safe to retry on network failure: a retried check simply writes another receipt (each check is a distinct decision event). POST /v1/grants is not idempotent — retrying mints a second grant — so generate grants once and reuse the grantId. The stream (GET /v1/receipts/stream) is designed for reconnects: resume with since or Last-Event-ID and the cursor guarantees no gaps.