Verifying receipts

Receipt verification is offline — no API call to Allowly is needed. Any party with your workspace's public key can verify any signed receipt, permanently, even if Allowly goes offline.

How it works

  1. Fetch your workspace's public keys (once, then cache)
  2. Pass the signed receipt and the keys to the reference verifier
  3. The verifier checks the Ed25519 signature over the canonical payload

Fetch public keys

GET https://api.allowly.ai/v1/workspaces/{workspace_id}/keys

This endpoint is public — no API key required, so auditors can call it directly.

{
  "workspace_id": "ws_01HXA1...",
  "keys": [
    {
      "key_id": "workspace-signing-key-version",
      "alg": "Ed25519",
      "public_key": "base64url-encoded 32-byte Ed25519 public key",
      "active_from": "2026-04-01T00:00:00Z",
      "active_until": null
    }
  ]
}

Keys remain published after rotation so historical receipts stay verifiable. active_until being non-null means the key is retired but still valid for receipts signed during its active window.

Key rotation

Allowly publishes current and retired verification keys with validity windows. Use the active_from and active_until fields in /v1/workspaces/{workspace_id}/keys to verify that a receipt was signed during the key's valid signing window.

Verify with Python

from allowly.verify import fetch_keys_doc, verify_receipt, load_keys_from_json

# Fetch keys (HTTPS-only, 5-minute in-memory cache, optional SHA-256 pin)
keys_doc = fetch_keys_doc(workspace_id)
keys = load_keys_from_json(keys_doc)

# Fetch the signed receipt
receipt_doc = httpx.get(
    f"https://api.allowly.ai/v1/receipts/{receipt_id}",
    headers={"Authorization": f"Bearer {api_key}"},
).json()

assert receipt_doc["status"] == "signed", "receipt not yet signed"
signed_receipt = receipt_doc["receipt"]

# Verify — raises VerificationError if invalid
try:
    verify_receipt(signed_receipt, keys)
    print(f"Valid: {signed_receipt['decision']} at {signed_receipt['issued_at']}")
except Exception as e:
    print(f"Invalid: {e}")

Verify with TypeScript

import { fetchKeysDoc, loadKeysFromJson, verifyReceipt } from "@allowly/sdk";

const keysDoc = await fetchKeysDoc(workspaceId);

const keys = loadKeysFromJson(keysDoc);

// throws VerificationError if invalid
await verifyReceipt(signedReceipt, keys);

CLI verification

# Install the SDK with verification helpers
pip install allowly

# Use the standalone reference verifier from the open-source repo
python allowly-receipt-format/verifiers/python/verifier.py receipt.json keys.json

What verification proves

A valid scope receipt attests that: *at issued_at, the Allowly workspace identified by workspace_id made decision about scope by agent_id on behalf of user_id, under authorization_id.*

A valid event receipt attests that Allowly recorded an event tied to that authorization, such as authorization.create, authorization.revoke, or escalation.resolve.

What it does NOT prove

  • That the action actually happened — the receipt records what the agent *asked about*, not what it *did*
  • That the user's authorization was informed — the receipt records that your system told Allowly the user approved
  • That context fields are accurate — they reflect what your system reported

The verification algorithm

The verifier applies these steps in order (any failure rejects the receipt):

  1. version == "1.0"
  2. All required fields present, no unknown fields, signature.value decodes to exactly 64 bytes
  3. Scope/event pairing: event: "authorization.create" -> authorization_granted, event: "authorization.revoke" -> authorization_revoked, event: "escalation.resolve" -> escalation_approved/escalation_rejected, scope receipts -> allow/deny/confirm/escalate
  4. signature.alg == "Ed25519"
  5. issued_at not in the future (5-minute skew allowed)
  6. Canonicalize payload (sorted keys, no whitespace, UTF-8)
  7. Look up key by key_id, check active window, verify Ed25519 signature

See the receipt format spec for the full normative definition.