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/checkreturns 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/verifyreturns{"valid": false, "reason": "..."}with a 200. Only a bad API key on that endpoint is a 401.
Status codes
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:
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.