Receipts and verification
Every consent decision is an event in an append-only hash chain, and every event gets a receipt signed with an OptProof Ed25519 key. This page describes exactly what is hashed and signed, so anyone can check a receipt without trusting OptProof.
This is a summary of the verification specification (hash scheme v1). Test vectors let you check an independent implementation byte for byte:
You can also verify a receipt now: paste a receipt code or scan its QR code. No login.
What a receipt proves#
A valid receipt shows that OptProof recorded the stated event, for the stated purposes and notice version, at the stated time, and that the record has not been changed since. It does not show who the person was (the ledger holds only a pseudonymous reference), and it does not judge whether your notice or consent met any legal standard.
Canonical JSON#
Everything that is hashed or signed is serialised in one canonical form. For the values it allows, it is byte-identical to RFC 8785 (JSON Canonicalization Scheme).
- Allowed values:
null,true,false, strings, integers between −(2^53 − 1) and 2^53 − 1, arrays and objects. No fractions, noNaNorInfinity, no unpaired surrogates. Large integers such asseqtravel as decimal strings. - No whitespace. Object members sorted by key, comparing UTF-16 code units. Arrays keep their order.
- Strings escape only
",\, the control characters (\b,\t,\n,\f,\r, and\u00xxin lowercase hex for the rest). Everything else, including/and non-ASCII text, is written as itself. - The text is encoded as UTF-8 without a byte order mark.
A verifier should parse and re-serialise canonical text and reject it if the bytes differ.
Ledger events#
An event has exactly these 17 members, shown in canonical order:
| Member | Format |
|---|---|
action | grant, deny, withdraw or expire |
channel | web, app, server, portal, qr, kiosk, whatsapp or import |
context | Capture context (UI variant, choices, location id). References and hashes only, no raw personal data. |
device_ref | Random device id (lowercase UUID) or null |
id | Event id, evt_ + 26 Crockford base32 characters |
is_test | true for events written with test keys |
language | Language of the notice shown (2–3 lowercase letters) |
notice_id | ntc_…, or null only for imports |
notice_version | Integer ≥ 1, or null only for imports |
occurred_at | When the person acted, UTC, YYYY-MM-DDTHH:mm:ss.sssZ |
prev_hash | Previous event’s hash, 64 lowercase hex characters |
principal_ref | HMAC-SHA-256 of a normalised identifier under a per-tenant secret, or null. Never a raw phone number or email. |
property_id | prp_ + 26 Crockford base32 characters |
purpose_keys | Non-empty, unique, sorted purpose keys |
recorded_at | When OptProof wrote the event, same format as occurred_at |
seq | Position in the property’s chain, decimal string, starting at "1" |
tenant_id | ten_ + 26 Crockford base32 characters |
At least one of principal_ref and device_ref is set. A mixed decision (some purposes accepted, some refused) is written as two events in one transaction: a grant and a deny.
Hash chain#
Each property has its own chain, numbered seq = 1, 2, 3, … with no gaps:
hash(e) = SHA-256( prev_hash_bytes || UTF-8(canonical_json(e)) )prev_hash_bytes is the 32-byte hash of the previous event. For the first event it is the genesis hash: 32 zero bytes. The event's own prev_hash member must equal that value in lowercase hex. Because each hash covers the one before it, changing any past event changes every hash after it.
Receipt format#
A receipt is a JWS in compact serialisation, signed with EdDSA over Ed25519:
BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)The header is exactly:
{"alg":"EdDSA","kid":"<key id>","typ":"optproof-receipt+jws"}Reject a receipt whose alg is not EdDSA, whose typ is not optproof-receipt+jws, that has no kid, or that has a crit member.
The payload has exactly these members, each equal to the matching event field:
| Member | Meaning | Event field |
|---|---|---|
rc | Receipt code, OP-XXXX-XXXX (Crockford base32, no I, L, O or U) | — |
evt | Event id | id |
prp | Property id | property_id |
seq | Position in the chain | seq |
h | Event hash | the chain hash |
act | Action | action |
pur | Purpose keys | purpose_keys |
nv | Notice version | notice_version |
lang | Language | language |
ch | Channel | channel |
ts | When the person acted | occurred_at |
The payload never contains principal_ref, device_ref, context or anything else about the person. The signature is the 64-byte Ed25519 signature over the ASCII bytes of header.payload, verified with the strict RFC 8032 equation.
Signing keys#
Public keys are published as a JWK Set at https://api.optproof.in/.well-known/optproof-keys.json:
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "<base64url public key>", "kid": "opk_2026_10_7k3m9q2x", "use": "sig", "alg": "EdDSA", "status": "active", "created_at": "2026-10-01T00:00:00.000Z", "retired_at": null } ]}- A new key is created every 12 months. Retired keys stay in the set forever, so older receipts still verify. A receipt signed with a retired key is valid.
- Select the key whose
kidmatches the receipt header. If there is none, or more than one, the receipt cannot be verified. - Private keys are kept in AWS Secrets Manager in Mumbai and used only by OptProof's signing service.
- If you verify receipts regularly, keep your own copy of the key set over time.
Daily anchors#
At 00:30 IST each day, for each property with new events, OptProof signs an anchor for the previous IST day: the seq and hash of the last event recorded that day. The anchor is a JWS like a receipt, with typ optproof-anchor+jws and a payload of prp, date, last_seq and last_hash.
Anchors are written to a storage bucket with S3 Object Lock in compliance mode. Objects in that mode cannot be overwritten or deleted by anyone, OptProof included, until the retention period ends. Because last_hash depends on every earlier event, one anchor fixes the whole history of the property up to that point.
How verification works#
The verifier fetches GET /v1/public/receipts/{code}, which returns the receipt, the chain segment from the receipt's event up to the next anchor (each record with its canonical JSON and hashes), and the anchor. It contains no personal data. If the person has their own copy of the receipt, the verifier uses that copy.
It then runs six checks, each reported as pass, fail or pending:
- Receipt signature. Header rules, key lookup by
kid, Ed25519 signature, exact payload members. - Receipt matches event. Every payload member equals the matching field of the first event in the segment, and
hequals its hash. - Record hashes. Every record is canonical JSON, parses to a valid event, and hashes to its stated hash.
- Record links. Consecutive records have consecutive
seq, link byprev_hash, and share a property. The first event of a chain links to the genesis hash. - Anchor signature. The anchor JWS verifies and matches the listed anchor fields and property. Pending if the event is not anchored yet.
- Anchor covers the event. The anchored
seqfalls inside the segment and its hash equals the anchored hash.
The overall result is verified if all pass, pending anchor if nothing fails but the anchor checks are pending, and failed otherwise.
What tampering is detected#
| Change to stored history | Caught by |
|---|---|
| A field of an event is edited | Record hashes (check 3) |
| An event is edited and its hash recomputed | Record links (check 4) |
| An event is deleted, or events are reordered | Record links (check 4) |
| A forged event is inserted | Record links, or the anchor if every later hash was recomputed too |
| History is rewritten from some point with all hashes recomputed | Any anchor signed before the rewrite, and every receipt already issued after that point |
| Events at the end are removed | Anchor covers the event (check 6) |
| A receipt is changed or made up | Receipt signature (check 1) |
Limits, stated plainly#
- Unanchored events. Until the next anchor (up to about 24.5 hours), the only protection against OptProof rewriting an event is the receipt already in the person's hands. The verifier shows this as pending, not verified.
- Same key family. Receipts and anchors are signed with OptProof keys. What stops a silent rewrite is that earlier anchors cannot be replaced, receipts already issued bind each event's hash, and, from phase 3, anchors will also be published on a public feed so others can keep copies. An auditor who keeps anchors or receipts independently gets the strongest assurance.
- Superseded consent. A receipt stays valid after a later withdrawal. The withdrawal is a separate event with its own receipt, and the verifier shows that the consent was later superseded.
Full-chain verification#
The evidence pack includes a chain report: every event of a property checked from seq 1, with every anchor compared against the recomputed hash. It reports the first broken seq and the reason, or confirms the whole chain is intact.