Skip to navigation

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": 10000, "monthlyChecks": 10000, ...}
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 tenant gets 10,000 receipts 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": 10000,
"monthlyChecks": 10000,
"checkout": "/v1/checkout"
}

Infrastructure failure (200, marked)

If the gate itself cannot reach its decision store (DynamoDB down, a southbound timeout) POST /v1/check does not 500. It answers 200 with an explicitly marked envelope instead:

{
"receiptId": "rcpt_4a9f...",
"decision": "ALLOW",
"verb": "refund",
"reason": "infrastructure failure — decision unavailable (fail-open; policy was NOT evaluated; verify out of band)",
"infrastructureFailure": true,
"failMode": "open"
}

There is no authToken, no policy attribution, and nothing is durably receipted — the audit hash chain is untouched. failMode is "open" by default (WRIT_CHECK_FAILURE_MODE=failopen); strict tenants switch the gate to fail-closed, which returns the same shape with decision: "DENY" and failMode: "closed". Treat both envelopes as “decision unavailable” and verify out of band: an open-mode ALLOW means “the gate is unreachable”, not “the policy said yes”.

Self-host operators can watch for this condition with a CloudWatch metric filter (or any log aggregator) on the structured line {"writ_metric": "check_infra_failure", "failMode": "open|closed"}. It is emitted once per infra-failure envelope so unreceipted fail-open traffic is observable.

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.