Check a receipt
Paste a whole answer from the agent service, MCP or HTTP, or one priced offer with its receipt. Each price is checked against our published keys.
The format
A receipt sits beside each price's proof. It holds no account, key, email, address or anything else about who asked, so it can be handed to anyone.
"receipt": {
"v": "0.1",
"key_id": "21fe31dfa154a261",
"issued_at": "2026-11-01T12:00:00.000Z",
"read": "page",
"sig": "kOiq03shA3BWL9XCt0m26hHYS68zfzVI5YyHpJXLHXUS3mSA86vP_vaTX1dx9W-oTCfm85zsof5aKoBqLw5tBA"
}| Field | Meaning |
|---|---|
v | The format version, "0.1". |
key_id | Which of our keys signed it: the first 16 hex characters of SHA-256 of the raw 32-byte public key. |
issued_at | When the answer was made and signed (ISO 8601, UTC). |
read | How the price was read: "page", the shop's own page (every v0.1 price). Later also "feed" or "store". |
sig | The Ed25519 signature, base64url without padding. |
What is signed
One claim: the receipt's v, key_id, issued_at and read, plus these values exactly as the answer shows them. A field marked "left out" is not in the claim at all, never null. Shipping is shown beside the price but is not signed in v0.1.
| Claim field | An offer | A history point | A lowest price |
|---|---|---|---|
store | shop.domain | the point's shop.domain | lowest.shop |
listing | proof.listing | proof.listing | proof.listing |
product | the answer's product.name (a scan offer's own product.name) | the answer's product.name | that product's name |
price_minor | price.minor | price.minor | amount times 100, rounded to a whole number |
currency | price.currency | price.currency | currency |
stock | stock | left out | left out |
checked_at | checked_at | checked_at | checked_at |
proof | proof.fingerprint | proof.fingerprint | proof.fingerprint |
An offer is a price from price_at, cheapest, live_check or market_scan; a history point comes from price_history; a lowest price from find with a country.
Canonical JSON and Ed25519
The claim is written as canonical JSON (RFC 8785): keys sorted, no spaces, strings and whole numbers written as JSON.stringify writes them. The signature is Ed25519 over those UTF-8 bytes. The claim behind the receipt above:
{"checked_at":"2026-11-01T11:30:00.000Z","currency":"EGP","issued_at":"2026-11-01T12:00:00.000Z","key_id":"21fe31dfa154a261","listing":"https://shop-a.example/coffee","price_minor":9500,"product":"Sunny Morning Coffee 200g","proof":"fp-1-1","read":"page","stock":"in_stock","store":"shop-a.example","v":"0.1"}Our public keys
mcp.agorantis.com/.well-known/agorantis-receipt-keys.json lists every key that ever signed a receipt: its key_id, the public key as a JWK (kty OKP, crv Ed25519, x), whether it is active or retired, and its dates. A retired key stays listed forever, so an old receipt still checks.
Check it yourself
Node 22 or later, or any current browser:
const canon = (v) => Array.isArray(v) ? `[${v.map(canon)}]` : v && typeof v === "object"
? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`)}}` : JSON.stringify(v);
const b64 = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
async function check(offer, product, keysUrl) { // one priced offer and its answer's product
const r = offer.receipt, { keys } = await (await fetch(keysUrl)).json();
const key = await crypto.subtle.importKey("jwk", keys.find((k) => k.key_id === r.key_id).jwk, { name: "Ed25519" }, false, ["verify"]);
const claim = { v: r.v, key_id: r.key_id, issued_at: r.issued_at, read: r.read, store: offer.shop.domain, listing: offer.proof.listing, product: product.name,
price_minor: offer.price.minor, currency: offer.price.currency, stock: offer.stock, checked_at: offer.checked_at, proof: offer.proof.fingerprint };
return crypto.subtle.verify({ name: "Ed25519" }, key, b64(r.sig), new TextEncoder().encode(canon(claim)));
}
// check(answer.results[0], answer.product, "https://mcp.agorantis.com/.well-known/agorantis-receipt-keys.json")check gives true only when every value matches what was signed. Change one character of any of them and it gives false.
Rules
- A receipt is made when the answer is made, over the exact values shown. Nothing about who asked is ever in it.
- With no working key there are no receipts: the answer carries no
receiptfield. A receipt is never made up. - While receipts are on, a price that cannot be signed is not shown, the same way a price without its proof is not shown.
- The private key exists only inside our signing service. No person ever sees it.