Skip to main content

Signed Receipts and Evidence

Logging what an agent did only helps if the agent actually asked first, and if the log can be trusted later. Auditail signs its decisions and its log with an Ed25519 key, so you don't have to take our word for either:

Question a reviewer will askAnswer
"Can the agent skip the check?"Your payment or email service only acts on a signed receipt. Without one, the action fails.
"Can someone edit the log afterwards, even Auditail?"Keep a signed checkpoint. Any later rewrite of the log no longer matches it.
"What do I hand my auditor?"An evidence export that checks out offline, with no Auditail account or network access.

Signed receipts: enforce approvals where the action happens​

When a human approves a request, Auditail issues a receipt: a short signed token. It binds:

  • your account,
  • the approval id,
  • the agent, the action and the environment,
  • a SHA-256 hash of the payload exactly as the approver saw it,
  • who approved it, and an expiry (default 15 minutes).

The agent gets it from guard() and passes it along with the action:

const result = await auditail.guard({ action: 'create_payout', payload, agentId: 'payout-agent' });
if (!result.approved) return result.reason;
await paymentsService.createPayout(payload, { auditailReceipt: result.approval?.receipt });

The payments service (a separate system the agent cannot change) checks the receipt before moving money:

import { verifyReceipt } from 'auditail-sdk';

const check = await verifyReceipt(receipt, {
account: 'key_xxxxxxxxxxxx', // your account id, shown in the dashboard under "Evidence and receipts"
action: 'create_payout',
payload, // the payout you are about to execute
});
if (!check.valid) throw new Error(`Refusing payout: ${check.reason}`);
if (await usedReceipts.has(check.claims.aid)) throw new Error('Receipt already used');
await usedReceipts.add(check.claims.aid);
// ...execute the payout
from auditail.verify import verify_receipt # pip install "auditail[verify]"

check = verify_receipt(receipt, account="key_xxxxxxxxxxxx", action="create_payout", payload=payout)
if not check.valid:
raise PermissionError(f"Refusing payout: {check.reason}")
if used_receipts.seen(check.claims["aid"]):
raise PermissionError("Receipt already used")

The check rejects a receipt that was:

ProblemExample reason
Edited, forged, or signed by anyone but Auditailbad signature (...)
Issued to a different Auditail customerreceipt belongs to a different Auditail account
For a different actionreceipt is for action "refund", not "create_payout"
For a different payload, such as a swapped amount or beneficiarypayload does not match the approved payload
Past its expiryreceipt has expired

The policy lives in the payments service: for example, "payouts over $10,000 need a valid receipt". So an agent can no longer move that money by skipping guard(). It has no receipt to show.

Things you must do​

  • Always pass account. Any Auditail customer can obtain valid receipts. The account check is what makes a receipt yours.
  • Pass action and payload. Without them, you are only checking that some approval happened.
  • Make receipts single-use. Store claims.aid and reject repeats, so one approval cannot pay out twice.
  • Pin our public key for the strongest guarantee. By default the verifier fetches it from https://api.auditail.io/.well-known/auditail-keys.json. Pinning (publicKeys: [...]) means even a compromise of that URL cannot make a forged receipt pass.

What the hash covers​

The payload hash covers the payload as the approver saw it, with secret fields (password, token, apiKey, ...) already masked to [REDACTED]. Changing any visible field breaks the receipt. Changing a masked secret does not, because nobody approved its value. If the agent's client uses custom redactKeys, pass the same list to the verifier. Send large numeric identifiers as strings, because numbers above 253 lose precision in JSON.


Signed checkpoints: detect a rewrite of the whole log​

The hash chain catches any edit or deletion. Someone with direct database access, including us, could still rewrite the entire chain consistently, and the server-side check would pass. A checkpoint closes that gap. It is a signed statement that event #N had hash H.

Keep checkpoints somewhere Auditail cannot change:

  • Every approval.decided webhook includes a fresh checkpoint, so if you have a webhook, you already collect them.
  • Fetch one any time with await auditail.checkpoint() (Python: auditail.checkpoint()), or with Copy signed checkpoint in the dashboard. A daily copy into a ticket, a git commit or your own database is plenty.

If the log is later rewritten, an evidence export checked against a saved checkpoint fails with "the log was rewritten".


Evidence exports: verify without trusting us​

Download an export from the dashboard (Evidence and receipts → Download evidence) or from the API:

curl -H "Authorization: Bearer $AUDITAIL_API_KEY" \
"https://api.auditail.io/v1/export?from=2026-07-01T00:00:00Z&to=2026-09-30T23:59:59Z" > q3-evidence.json

The file contains every event in the range with the raw fields needed to recompute each hash, plus a signed checkpoint for the last event. Anyone can check it:

npx -p auditail-sdk auditail-verify q3-evidence.json --checkpoint atc1.eyJ2Ijox...
Account: key_xxxxxxxxxxxx
Events: 1284 (#3121 to #4404)
Held checkpoints matched: 1 of 1

VALID: every event is intact, linked in order, and vouched for by an Auditail signature.

The verifier recomputes every hash, checks every link, and checks the signature. It reports exactly which event was modified, deleted or rewritten. It exits 0 if valid and 1 if not, so it fits into a script. Pass --keys keys.json to use pinned keys and make no network calls at all.


Pinning Auditail's public key​

The current production key, published here and in the GitHub repository so you can cross-check it against a second source:

{
"keys": [
{
"kid": "57678e4f4869d316",
"alg": "Ed25519",
"publicKey": "xuoo9VMYWu2d6dhIm5wcKsTOIEvCBN-dQhfxftOnZ1o",
"status": "current"
}
]
}
await verifyReceipt(receipt, { account: 'key_...', payload, publicKeys: [{ kid: '57678e4f4869d316', alg: 'Ed25519', publicKey: 'xuoo9VMYWu2d6dhIm5wcKsTOIEvCBN-dQhfxftOnZ1o' }] });

If we ever rotate keys, the old key stays published as previous so existing receipts and exports remain verifiable.


Limits, stated plainly​

  • Receipts only protect systems that check them. Agents that call an API directly, without a receipt check in front of it, are protected only by guard().
  • A signature proves who decided, not that the decision was right. A careless approver still approves.
  • Receipts are issued for human approvals only. Decisions made by rules inside your agent are recorded, but Auditail cannot vouch for code it does not run.
  • Auditail supports your audit evidence. It is not a SOC2 certification and does not replace your own controls.

Endpoints​

EndpointPurpose
GET /.well-known/auditail-keys.jsonPublic keys (no auth).
GET /v1/approvals/{id}Includes receipt once approved.
GET /v1/checkpointSigned checkpoint of your current log head.
GET /v1/export?from=&to=Evidence export (up to 50,000 events per file).