Turn an action into a verifiable fact
Your log is your word. A receipt is verifiable evidence. The difference only shows up on the day someone disputes it — by then it is too late to add it. This page covers what actually bites during integration; every item here was written down after getting bitten by it.
Step one: decide what to notarize
Do not "record everything." Receipts are public and cannot be deleted — noise stays forever.
Worth notarizing
Something someone will later need to prove happened, and happened a specific way. Compliance verdicts, approvals and denials, trades and pricing, commitments made to a customer. The common thread: it can be disputed, and a second party can disagree.
Not worth it
Internal process steps, debug messages, every poll, cache hits. Nobody will ever ask you to prove these — integrating them is just extra maintenance, and it turns the Registry into a log system, which is not what it is for.
The test is one sentence
"What can I produce when this gets disputed?" A receipt and your own log are, to the other party, answers of two different classes.
The three interop rules
🔴 Violating one of these shows up as "receipt rejected," not a code error— which makes it hard to diagnose. The client library below already blocks all three locally.
rating must be a JSON integer, 0–10
Not "a number" — an integer. 7.5 gets rejected. If you are not rating it, leave the field out entirely.
Why: Python prints 7.0 as "7.0"; JavaScript prints it as "7". The same receipt hashes to different canonical bytes on each side, so the signature never verifies.
Omit empty optional fields entirely
Do not send to_agent: "" or action: "". Include the key only when it has a value — no value means no key at all.
Why: The signature covers the canonical bytes. One extra empty string means what you signed is not what the Registry verifies ⇒ empty_optional_field comes back.
metadata must always be present — {} if empty
And it has to be filled in before you sign, never added afterward.
Why: metadata is part of the signed bytes. Add it after signing and the bytes you send no longer match the bytes you signed.
The metadata naming trap
The easiest one to trip over. The Registry keeps a private-keyword blocklist, and itsplits field names on underscores and matches each segment — so the instinct of "just add a suffix" is wrong here.
# ❌ any of these gets the WHOLE receipt rejected prompt_hash # splits out prompt ⇒ blocked user_email # splits out email ⇒ blocked raw_count # splits out raw ⇒ blocked session_id # splits out session ⇒ blocked # ✅ just rename it — meaning is unchanged content_digest text_digest actor_ref order_ref tenant_ref
This is deliberate, not a bug. It blocks accidentally notarizing customer data. Full blocklist (28 keys — blocked at any level, any segment, anywhere in the receipt):
api_key api_keys access_token auth_token authorization cookie cookies credential credentials email full_receipt html image messages ocr_text password private_key prompt raw raw_content request_body response secret screenshot session token transcript user_content
Only put in irreversible values
Receipts are public, and there is no delete API.
Do not include
Raw copy text, names, phone numbers, email addresses, raw order numbers, exact monetary amounts, any free-text user input.
Do include
Enum values (verdict / decision / kind), version numbers (rulebook version, code commit),sha256 reference digests, bucketed ranges (amounts as buckets, not exact figures).
Ask yourself one question
"Would I regret it if this receipt were posted online?" If yes, leave it out. A hash reference can prove "this is the same one" without revealing which one — that is exactly the property you want.
What this guarantees, and what it does not
🔴 See where the line is before you rely on this.The "guarantees" column below is mathematics — anyone can verify it independently; the "does not guarantee" column is a current implementation limit, not a promise.
Guaranteed: content is unaltered
Change a single byte in the receipt and the signature fails to verify. The Registry itself cannot forge this either — it does not have your private key, and the verify endpoint does not need to trust it.
Guaranteed: identity cannot be forged
The DID is derived from the public key (sha256), and anyone can recompute it offline. Without your private key, nobody can sign a receipt under your DID.
Not guaranteed: it will never be deleted
Receipts live in an ordinary database. There is no blockchain-style immutability guarantee, and no hash chain or external anchor. "It will not be deleted" currently means trusting the Registry's operator, not mathematics.
Not guaranteed: the timestamp is real
The timestamp field is filled in by the signer — you could sign a receipt dated three years ago. The Registry separately records when it received the receipt, but that is also just its own word, not independently verifiable proof.
Not guaranteed: the list is complete
There is no way to prove a given receipt was not inserted after the fact, and no way to prove the list you see has nothing left out.
So what it is good for
Good for evidence you produce yourself — you want to prove something happened the way you said, and the other party's doubt is "you edited this afterward." Not good for arguing against the Registry itself, and not a trusted timestamp on its own. For that, see below.
Need a trusted timestamp or non-deletion? Both gaps already have a ready-made fix, and it ships in the package:
# only the hash is sent — the receipt and the file never leave your machine pip install "agentoath[timestamps]" from agentoath.timestamps import stamp_receipt, verify, upgrade proof = stamp_receipt(receipt) report = verify(proof) report["any_valid"] # a timestamp authority signed this hash together with its own clock report["bitcoin_confirmed"] # …and it is already written into a Bitcoin block proof = upgrade(proof) # a few hours later: pending → confirmed
Both mechanisms are used deliberately, because they fail differently. RFC 3161 has a timestamp authority sign with its own clock — instant, and a format courts and eIDAS recognize; you trust that authority, not us. OpenTimestamps writes the hash into Bitcoin — no party to trust, at the cost of waiting a few hours for it to land in a block.
🔴 Once you hold an OpenTimestamps proof plus your own copy of the receipt, even if every receipt here were deleted, you can still prove the thing happened, and when. It does not make the database undeletable — it makes the database **not matter** — which is the property you actually want.
The JavaScript package does not have this module: the only long-lived OpenTimestamps library on that side stopped being maintained in 2019, and the newer alternatives are all v0.x, unknown authors, published less than two months ago. A package other people install should not carry that kind of dependency. targetOfReceipt() gives you the hash to stamp; do the actual stamping with the Python package or the ots CLI — the proof format is language-neutral JSON.
Attesting a document
There is no "document receipt" type, and that is the point: a document is an ordinary receipt plus one metadata convention, so signing, the cross-language byte-identical canonical form, the privacy blocklist, and the Registry's validation all keep working unchanged. A new type would mean re-earning all of that from scratch.
# Python from agentoath.documents import build_document_receipt receipt = build_document_receipt(identity, "scans/book-0417.pdf", refs={"source_ref": "isbn:9780000000001", "acquired_ref": "po-2026-0817"}) // JavaScript import { buildDocumentReceipt } from 'agentoath/documents';
receipt_id is derived from the file's hash, so attesting the same file twice produces the same receipt, not two unrelated ones. The file is read as a stream — a multi-gigabyte scan does not have to fit in memory.
🔴 The file itself never leaves your machine — only its sha256 goes into the receipt. The hash proves "this is the same one" to someone who already has the file, and proves nothing to anyone who does not — which is exactly the property you want when the thing you are attesting is a scan you cannot redistribute.
Reference fields must end in _ref and stay under 128 characters. Receipts are public and permanent — a sentence you paste in cannot be taken back.
🔴 A receipt is a side effect, not a precondition
If the Registry is down, the key is not set, or the network is unreachable, your main function must still complete normally. The reverse must never happen.
// ✅ the correct shape const result = await doTheActualWork(); // do the real work first try { recordReceipt(result); // async — don't make the user wait on the network } catch { /* a recording failure must never touch the line above */ } return result; // return it exactly as-is
Three more things worth doing in practice: off by default (not a single byte is sent until it is explicitly turned on); if it cannot send, sign it and land it in an outbox to retry later — the receipt id is fixed at signing time, so a retry goes through the idempotent path and never becomes two records; catch at both layers — the caller wraps it in try/catch, and the recording function itself never throws either.
Client libraries
Both official packages build this in. Zero extra dependencies (Python adds onlycryptography; JS uses Node's built-in crypto), canonical JSON byte-identical to the Registry, all three interop rules enforced locally first, the blocklist scanned locally first, off by default.
# Python pip install agentoath from agentoath.hosted import Identity, build_signed_receipt // JavaScript (ESM; use await import for CJS) npm install agentoath import { Identity, buildSignedReceipt } from 'agentoath/hosted';
Don't want the dependency, or not using either language? This is the same thing as a single zero-dependency file you can drop straight into your project:
⬇ Download agentoath-client.mjs
// save as lib/agentoath.mjs and import it directly import { Identity, RegistryClient, buildSignedReceipt, sha256Json } from "./lib/agentoath.mjs"; // 1. generate an identity (store the private key in an env var, never commit it) const id = Identity.generate(); console.log(id.did, id.privateKey); // 2. register once (idempotent — rerunning never creates a second identity) const c = new RegistryClient({ apiKey: process.env.AGENTOATH_KEY, enabled: true }); await c.registerIdentity(id, { name: "my-service", platform: "my-platform" }); // 3. every action worth notarizing const receipt = buildSignedReceipt(id, { receiptId: `verdict-$${orderId}`, action: "compliance.verdict.red", metadata: { schema: "v1", rulebook_version: "2026-08-20a", content_digest: sha256Json(adText) }, }); await c.publishReceipt(receipt);
🔴 Do not rewrite canonical JSON yourself. One bit of difference between the two sides and the signature never verifies, and the symptom is "receipt rejected," not an error. Use the file above, or implement the spec at GET /api/v1/registryliterally, byte for byte. The Python-side equivalent is json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")).
How to prove it actually worked
🔴 Do not look at "no errors" — that is not evidence. Evidence looks like a state flip.
# before curl -s $REGISTRY/api/v1/agents/$DID/status # → receipt_count: 0 # trigger one REAL action (do not use fake test data) # after curl -s $REGISTRY/api/v1/agents/$DID/status # → receipt_count: 1 ← this 0→1 is the evidence # negative control: change any one field in the same receipt, it must flip to valid=false curl -s -X POST $REGISTRY/api/v1/receipts/verify \ -H "Content-Type: application/json" \ -d '{"receipt": <receipt with one field changed>, "from_public_key": "<your public key>"}' # → {"valid": false, ...}
Verify both directions. Checking only that the valid case passes is not enough — it might just say pass to everything. Only when the negative control actually flips to false is the signature proven to be protecting the content.
Common snags
| Symptom | Actual cause |
|---|---|
| publish returns 422, message says signature invalid | Nine times out of ten it is one of the three interop rules, or a self-written canonical JSON that differs from the Registry by one bit. Run the client's verifyLocally locally first. |
| Returns private field / field rejected | The metadata key, split on underscores, hits the blocklist (things like prompt_hash, user_email). Rename it and it's fixed. |
| Receipt count stays 0, but the code reports no errors | The client is off by default, and it silently does not send. Check whether enabled and the API key are actually being read. |
| Two receipts show up for the same action | receipt_id used a timestamp or a random value. It must be derived from the action itself (e.g. a hash of the order id), so a rerun lands on the same record. |
| Old receipts don't show up after rotating keys | The DID is derived from the public key, so a new key means a new identity — reputation resets to zero. This is by design, not a bug. Treat your private key as a long-term asset. |