Skip to content

NewThe DPDP Rules phase in by May 2027.Check your website’s consent readiness

Home

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:

Download the test vectors (JSON)Canonical JSON cases, two test keys, a four-event chain, receipts, an anchor, and tampered segments that must fail. The test keys are derived from public strings and are trusted nowhere.

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, no NaN or Infinity, no unpaired surrogates. Large integers such as seq travel 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 \u00xx in 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:

MemberFormat
actiongrant, deny, withdraw or expire
channelweb, app, server, portal, qr, kiosk, whatsapp or import
contextCapture context (UI variant, choices, location id). References and hashes only, no raw personal data.
device_refRandom device id (lowercase UUID) or null
idEvent id, evt_ + 26 Crockford base32 characters
is_testtrue for events written with test keys
languageLanguage of the notice shown (2–3 lowercase letters)
notice_idntc_…, or null only for imports
notice_versionInteger ≥ 1, or null only for imports
occurred_atWhen the person acted, UTC, YYYY-MM-DDTHH:mm:ss.sssZ
prev_hashPrevious event’s hash, 64 lowercase hex characters
principal_refHMAC-SHA-256 of a normalised identifier under a per-tenant secret, or null. Never a raw phone number or email.
property_idprp_ + 26 Crockford base32 characters
purpose_keysNon-empty, unique, sorted purpose keys
recorded_atWhen OptProof wrote the event, same format as occurred_at
seqPosition in the property’s chain, decimal string, starting at "1"
tenant_idten_ + 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:

text
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:

text
BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)

The header is exactly:

json
{"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:

MemberMeaningEvent field
rcReceipt code, OP-XXXX-XXXX (Crockford base32, no I, L, O or U)—
evtEvent idid
prpProperty idproperty_id
seqPosition in the chainseq
hEvent hashthe chain hash
actActionaction
purPurpose keyspurpose_keys
nvNotice versionnotice_version
langLanguagelanguage
chChannelchannel
tsWhen the person actedoccurred_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:

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 kid matches 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:

  1. Receipt signature. Header rules, key lookup by kid, Ed25519 signature, exact payload members.
  2. Receipt matches event. Every payload member equals the matching field of the first event in the segment, and h equals its hash.
  3. Record hashes. Every record is canonical JSON, parses to a valid event, and hashes to its stated hash.
  4. Record links. Consecutive records have consecutive seq, link by prev_hash, and share a property. The first event of a chain links to the genesis hash.
  5. Anchor signature. The anchor JWS verifies and matches the listed anchor fields and property. Pending if the event is not anchored yet.
  6. Anchor covers the event. The anchored seq falls 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 historyCaught by
A field of an event is editedRecord hashes (check 3)
An event is edited and its hash recomputedRecord links (check 4)
An event is deleted, or events are reorderedRecord links (check 4)
A forged event is insertedRecord links, or the anchor if every later hash was recomputed too
History is rewritten from some point with all hashes recomputedAny anchor signed before the rewrite, and every receipt already issued after that point
Events at the end are removedAnchor covers the event (check 6)
A receipt is changed or made upReceipt 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.