Verify a receipt
A consent receipt is meant to be checkable by anyone, with no trust in Agreely. This page is the field-level recipe; for a runnable walkthrough see verify a receipt yourself.
Try it yourself
Download a genuine company-attested receipt and its issuer DID document, extracted from the SDK's golden vectors, then verify the receipt offline with the CLI: no network, no API key.
- sample-receipt.json: the company-attested receipt
- sample-did.json: the issuer DID document
agreely verify sample-receipt.json --did-doc sample-did.json
✓ VERIFIED (company_attested, read as a receipt)
companySignature pass
receiptIntegrity pass
citizenAssertion unsupported
disclosureCopy skipped
documentAnchor skipped
consentAnchor skipped
cellLabelBinding pass
withdrawal not checked
! WITHDRAWAL NOT CHECKED: this verify did not read the chain, so it says nothing of a withdrawal since the consent was given. For a passkey consent's verification bundle, add --onchain --rpc-url https://base-rpc.publicnode.com to read it.
· Company signature verified against issuer DID did:web:api.agreely.ca:c:acme (key did:web:api.agreely.ca:c:acme#kms-1).
· This proves the company ATTESTED to a hand-signed PDF (hash 0xabababababababababababababababababababababababababababababababab); it does NOT prove a human signed.
· A company-attested receipt carries no citizen passkey assertion, so citizenAssertion is not applicable.
· Cell labels bound: the category/purpose labels sit inside the company-signed body, so the verified signature covers them. Mutating any item label breaks it.
· disclosureCopy skipped: the receipt carries no document.ipfsCid + disclosureHash pair to fetch and compare.
· documentAnchor skipped: pass an rpcUrl to check the on-chain document anchor.
· consentAnchor skipped: this verifier reads a consent's on-chain record from a passkey bundle only, so the withdrawal of a paper or telephone consent is not checked.
The cellLabelBinding row is the one field to read before trusting a displayed
label: here it is pass, because the labels sit inside the company-signed body.
The receipt contains demo data only (the fictional company "Acme").
What you need
To verify one cell end to end, a verifier holds:
- the receipt VC (the consent receipt);
- the original offer object (to check
companySig, which signed the offer, and to recompute the challenge with itsnonce); - the cell's envelope: its 32-byte
saltand its Merkle inclusion proof (disclosed by Agreely; the salt is destroyed on erasure); - the anchored Merkle root (from the receipt's consent, cross-checkable on the on-chain registry);
- the two DID documents (resolved independently).
The four checks
1. Recompute the commitment from the claim and salt
Rebuild the frozen claim from the receipt fields, JCS-canonicalize it, raw-concat the salt, and keccak256. It must equal the stored commitment.
claim = {
"issuer": receipt.issuer,
"subject": receipt.credentialSubject.id,
"item": { itemId, category, purpose }, // from receipt.credentialSubject.consent.items[i]
"grantedAt": receipt.credentialSubject.consent.grantedAt
}
commitment == keccak256( JCS(claim) || salt )
2. Verify Merkle inclusion against the root
Take leaf = keccak256(commitment) and fold it with the proof siblings using the
commutative pairing. The result must equal the consent's Merkle root, which is
the value anchored on-chain.
leaf = keccak256(commitment)
fold(leaf, proof) == merkleRoot // keccak256(min||max) at each step
3. Verify companySig against the company DID key
Resolve the company DID document and take
the method proof[0].verificationMethod names: its id is the issuer DID followed by
# and a fragment, it is listed under assertionMethod, and it is never simply the
document's first method. Decode its publicKeyMultibase to the raw Ed25519 key, and
verify proof[0].proofValue
(decoded from base58btc) against the JCS bytes of the offer minus its envelope
fields (companySig, subject, status, result). The verification bundle's
signedOffer already has them removed.
method = companyDoc.verificationMethod[ id == proof[0].verificationMethod ] // listed under companyDoc.assertionMethod
pubKey = decodeEd25519( method.publicKeyMultibase )
ed25519_verify( decodeBytes(proof[0].proofValue), JCS(offer\{companySig,subject,status,result}), pubKey )
4. Verify the WebAuthn assertion against the key valid at grantedAt
Resolve the citizen DID document. First
recompute the challenge from the receipt's fields, the Merkle root and the signed
offer's nonce: it must equal proof[1].challenge, and so must the embedded
clientDataJSON.challenge. Take the method proof[1].verificationMethod names and
verify the assertion (authenticatorData, clientDataJSON, signature) against its
COSE key. That key is the person's only when a method of the subject DID, listed
under authentication, carries exactly that COSE key and its
[validFrom, validUntil) window covers grantedAt (validUntil excluded). Last,
grantedAt must fall inside the offer's window, createdAt and expiresAt
included.
challenge = keccak256( JCS({ issuer, subject, requestId, action, grantedAt, merkleRoot }) || hexBytes(offer.nonce) )
assert challenge == proof[1].challenge // clientDataJSON.challenge carries the same bytes
method = citizenDoc.verificationMethod[ id == proof[1].verificationMethod ]
webauthn_verify( proof[1], method.publicKeyCose )
assert method.id under the subject DID, listed in citizenDoc.authentication
assert method.validFrom <= grantedAt and (method.validUntil is null or grantedAt < method.validUntil)
assert offer.createdAt <= grantedAt <= offer.expiresAt
Why these four, and nothing about Agreely
Check 1 proves the claim is exactly what was committed. Check 2 proves that commitment is in the consent that was anchored. Check 3 proves the company made the offer. Check 4 proves the person approved it, with a key the DID document says was theirs at the time. Every input is a published artifact or an independent resolution; none of it asks you to take Agreely's word.
The honest verification boundary
The four checks above assume you were handed the original offer and the cell's envelope. What you can verify from the receipt alone, offline, depends on its variant.
A company-attested receipt is fully verifiable offline: its company Ed25519
signature verifies against the resolved company did:web key. There is no citizen
half to check.
A citizen receipt is only partially verifiable offline:
- The citizen passkey assertion verifies over the challenge the receipt
carries, without tying it to the receipt's content: recomputing that challenge
takes the signed offer's
nonce, which the receipt does not reproduce. It shows passkey possession, not that this person consented to these cells. - The company-signature half is not verifiable from the receipt alone.
The company signed the original offer, which carries the full disclosure,
the nonce and the offer's dates, and the receipt does not reproduce it. No server
endpoint re-checks it. It is checked offline from the verification bundle the
person downloads with their receipt:
receipt,signedOffer(the exact offer the company signed, withoutsubjector the other envelope fields),merkleRoot,consentRefsanddidDocuments(issuerandcitizen). The company's customer reference is never in it.
"Unavailable" is not "failed"
Always distinguish unavailable from failed. A DID document that did not resolve is unavailable: the result is inconclusive, not a forgery. An active cryptographic failure (a signature that does not verify against the right key) is failed: it is tamper or a wrong key. Never report one as the other.
This matrix is implemented by
verifying a receipt in TypeScript,
verifying a receipt in PHP, and the
CLI verify command.
PHP reference
This mirrors Agreely's own integration test, which performs exactly these checks.
use App\Models\Crypto\{Canonicalizer, Commitment, Keccak, Merkle, Multibase};
$canon = new Canonicalizer();
$commitment = new Commitment();
// 1 + 2: per item, recompute the commitment and verify Merkle inclusion.
foreach ($receipt['credentialSubject']['consent']['items'] as $item) {
$claim = [
'issuer' => $receipt['issuer'],
'subject' => $receipt['credentialSubject']['id'],
'item' => ['itemId' => $item['itemId'], 'category' => $item['category'], 'purpose' => $item['purpose']],
'grantedAt' => $receipt['credentialSubject']['consent']['grantedAt'],
];
$recomputed = $commitment->commit($claim, $salt); // == stored commitment
assert(Merkle::verify(Merkle::leafForCommitment($recomputed), $proofSiblings, $merkleRoot));
}
// 3: companySig over the offer (envelope fields removed) vs the key proof[0] names.
// This test's document holds one method, the one proof[0] names; a verifier selects it by id.
$pubKey = Multibase::decodeEd25519PublicKey($companyDoc['verificationMethod'][0]['publicKeyMultibase']);
unset($offer['subject'], $offer['status'], $offer['companySig'], $offer['result']);
$sig = Multibase::decodeBytes($receipt['proof'][0]['proofValue']);
assert(sodium_crypto_sign_verify_detached($sig, $canon->encode($offer), $pubKey));
// 4: recompute the challenge, then verify the WebAuthn assertion with the COSE key valid at grantedAt
// (use a WebAuthn library; the method's [validFrom, validUntil) must cover grantedAt).
Next
- Verify a receipt yourself: a runnable guide.