Verifying a ConsentReceipt
Agreely::verifyReceipt($receipt, $opts) is static, needs no API key, and
resolves on every input: a tampered or unreadable file returns a verdict,
never an exception. It takes the verification bundle a passkey consent issues
(version "1": the receipt, its signedOffer and its merkleRoot) or a bare
receipt (paper or telephone), as JSON text (recommended: the file is read
exactly as written), as the objects json_decode() returns, or as an array.
ReceiptVerifier::isVerificationBundle($value) tells a bundle from a receipt. In
PHP, the network seams are injected as httpGet / httpPost callables (not
fetch). This page describes the PHP SDK 0.6.2.
use Agreely\Sdk\Agreely;
use Agreely\Sdk\Verify\ReceiptVerifier;
$json = file_get_contents('agreely-verification-bundle.json');
ReceiptVerifier::isVerificationBundle(json_decode($json)); // true for a verification bundle
$v = Agreely::verifyReceipt($json, [
'rpcUrl' => 'https://base-rpc.publicnode.com', // optional: the on-chain checks, else skipped
]);
The load-bearing honesty point: verifyReceipt separates a check that failed
(fail, the cryptography or the chain actively contradicts the file) from a check
that could not complete (unavailable: a DID could not be resolved, an IPFS
gateway or an RPC failed, refused or timed out). A valid receipt during an outage
therefore never looks, byte for byte, like a forgery.
The three receipt types
company_attested: the company attested a consent (typically a hand-signed PDF). Its signature covers the receipt body. Best possible verdict:verified, whether the receipt is bare or in its bundle.company_documented: the organisation documented a consent given by telephone (aVerbalConsentReceipt). The company signature verifies, but there is no signed document: the best possible verdict ispartial.citizen: the person signed with a passkey (WebAuthn). Best possible verdict:verified, from its verification bundle only. A bare citizen receipt readsincomplete: pass the bundle issued with it (see the honest limit below).
The checks (plus the label binding)
verifyReceipt returns a ReceiptVerification with readonly properties. Each
check is a bare CheckStatus string. The human-readable explanations all live
in one flat notes array, written for humans: a program reads the properties,
never the notes. toArray() returns the same shape as the TypeScript SDK.
// $v->input 'bundle' | 'receipt'
// $v->receiptType 'company_attested' | 'company_documented' | 'citizen'
// $v->companySignature 'pass' | 'fail' | 'unavailable' | 'unbound' | 'withdrawn' | 'skipped' | 'unsupported'
// $v->receiptIntegrity, $v->citizenAssertion, $v->disclosureCopy,
// $v->documentAnchor, $v->consentAnchor, $v->cellLabelBinding (same CheckStatus strings)
// $v->overall 'verified' | 'partial' | 'incomplete' | 'failed' | 'unavailable' | 'malformed' | 'withdrawn'
// $v->withdrawal 'active' | 'withdrawn' | 'not_checked'
// $v->withdrawnAt ?int, unix seconds, when the chain records it
// $v->documentAnchorRecord ?array { anchoredAt, anchoredBlock }
// $v->consentAnchorRecords ?array of { consentRef, state, anchoredAt, revokedAt, anchoredBlock }
// $v->consentAnchorCounts ?array { cells, active, withdrawn, pending, unreadable }
// $v->registryRefused bool
// $v->notes string[], plain-language caveats, one flat array
companySignature: the company's Ed25519 signature. On a paper or telephone receipt it covers the receipt body. On a passkey bundle it isproof[0]over the signed offer. Only the methodproof[0].verificationMethodnames checks it: a method of the issuer (its id is the issuer DID followed by#and a fragment), listed underassertionMethodin the issuer's DID document. A resolved document without that key readsfail; an unresolvable DID readsunavailable. On a bare citizen receipt,unsupported: the offer only travels in the bundle.receiptIntegrity: on a passkey bundle, the receipt matches the signed offer field by field (its top-level keys,type,@contextand theurn:agreely:receipt:id included), thedisclosureHashrecomputes from the offer,validFromequalsgrantedAt, andgrantedAtfalls inside the offer's window, fromcreatedAttoexpiresAt(in whole seconds). Theproofarray has the shape the issuer writes: two proofs on a passkey receipt (the company'sDataIntegrityProof, then the person'sWebAuthnAssertion), one on a paper or telephone receipt. On a paper or telephone receipt, this check followscompanySignature(the signature covers the body) and readsfailwhen the proof does not have its issued shape. On a bare citizen receipt,unsupported.citizenAssertion: on a bundle, the WebAuthn assertion verifies, its challenge recomputes askeccak256(JCS({issuer, subject, requestId, action, grantedAt, merkleRoot}) || nonce), and the citizen's DID document (whoseidis the subject) lists that exact key underauthentication, with a[validFrom, validUntil)window coveringgrantedAt. A document that does not confirm the key readsfail; a document that cannot be read as this identity's readsunavailable. WhengrantedAtfalls outside the offer's window, the key is not evaluated at that date:unavailable. The citizen half is checked only for adid:agreely:citizen:subject, whatever the resolver; any other subject readsunavailable. On a bare citizen receipt, the assertion verifies over the challenge the receipt carries, but nothing ties that challenge to its content (it is computed from therequestId, the Merkle root and the nonce, which only the bundle carries):unbound, which proves nothing about the receipt. On a paper or telephone receipt,unsupported.disclosureCopy(optional cross-check,'verifyDisclosure' => true, otherwiseskippedwithout contacting the gateway): fetches the disclosure copy from IPFS and compares it to the signeddisclosureHash. On a bundle, that is the signed offer's. When the receipt declaresdisclosureVersion: "2", the hash covers{disclosure, legalBasis}, and a v2 receipt without itslegalBasisreadsfail. A gateway that does not return the document (404,502), fails or times out readsunavailable. On a bare citizen receipt,unsupported: its hash is signed by no one.documentAnchor(optional on-chain cross-check, needsrpcUrl, otherwiseskipped): oneeth_callrootOf(keccak256(cid))on theAgreelyRegistry. A record in the consent-document lane passes and fillsdocumentAnchorRecord(anchoredAt,anchoredBlock). No record yet readsunavailable: the document is anchored after it is published, so its absence is not a contradiction. A record in another lane, or a tagged one, readsfail. This check proves the document existed, not that a consent was given.consentAnchor(optional on-chain cross-check, needsrpcUrl, passkey bundle only): oneeth_callregistry(consentRef)per cell, tied to the receipt by the Merkle root the verified assertion binds.passwhen every cell was read under that root and is in force;withdrawnwhen the chain records a cell's withdrawal under that root (the consent is authentic, and no longer in force);failwhen the chain records a cell under another root;unavailableotherwise, a cell not yet anchored or a failed RPC included. Every accepted cell the receipt lists counts: a cell whoseconsentRefthe bundle does not name counts as unreadable, because no one signs that list.consentAnchorRecordsgives each cell read under that root (state,anchoredAt,revokedAt,anchoredBlock), andconsentAnchorCountsthe tally (cells,active,withdrawn,pending,unreadable). On a paper, telephone or bare citizen receipt,skipped.
On top of these checks sits one derived verdict:
cellLabelBinding: are the cells' human-readable labels (category/purpose) cryptographically bound? It is the field to read before trusting a displayed label. On a paper or telephone receipt it followscompanySignature(the labels sit in the signed body). On a bundle it ispasswhen the offer signature and the receipt integrity pass (each cell is an exact item of the signed offer),failwhen either fails, andunavailablewhen the issuer DID does not resolve. On a bare citizen receipt it isunsupported: do not trust the displayed labels.
Status legend
Per-check status (CheckStatus):
| Status | Meaning |
|---|---|
pass |
the check ran and verified |
fail |
the check ran and actively did not verify: a tamper, a wrong key, or a chain that contradicts the file |
unavailable |
the check could not complete: unresolvable DID, IPFS gateway or RPC failed, refused or timed out, an httpGet / httpPost callable that throws, or an unknown registry refused before any call |
unbound |
the signature verifies, but nothing ties it to the receipt's content (the assertion of a bare citizen receipt). Proves nothing about the receipt |
withdrawn |
the consent is authentic and under the receipt's root, but the chain records its withdrawal. Neither a success nor a forgery |
skipped |
the needed input is absent, or the check was not requested |
unsupported |
the check does not apply to this input |
Overall status (overall):
| Status | Meaning |
|---|---|
verified |
a sound company_attested receipt, bare or in its bundle, or a passkey bundle whose company signature, receipt integrity and citizen assertion pass. The checks listed hold; it is not a legal proof |
partial |
a telephone receipt (company_documented) whose company signature verifies: the organisation's word, authenticated. It does not on its own show that the person consented, since it carries no signature of theirs. The verifier at app.agreely.ca/verify also reads, with its verification bundle, whether a version 4 receipt is still in force |
incomplete |
a bare citizen receipt: it cannot be verified without its bundle. Neither a success nor a forgery |
failed |
a check actively failed |
unavailable |
a decisive check could not complete: inconclusive, NOT a forgery. Also when the bundle names a registry or chain the SDK does not read (registryRefused) |
malformed |
a file the verifier cannot read: not an object, a bundle of another version, a receipt or signedOffer that is not an object, a passkey bundle without its signedOffer or merkleRoot, a passkey receipt in a bundle carrying a field the issuer never writes, a value the canonical form refuses, or nesting deeper than 64 levels. No check ran: this verdict says nothing of a tamper |
withdrawn |
the receipt is authentic and the chain records the consent's withdrawal: every other decisive check would have read verified, and consentAnchor reads withdrawn |
Load-bearing rule: an active fail always beats an unavailable, and
withdrawn is returned only when nothing else failed or went missing. A true
negative is never masked by an outage.
A requested cross-check that could not complete (disclosureCopy,
documentAnchor or consentAnchor at unavailable) does not lower a verified:
read those checks before relying on the verdict. The CLI then exits 4.
The keys come from the DID documents the verifier reads. A resolver that
serves a citizen document without authentication or validFrom, or an issuer
document that does not list the key under assertionMethod, makes an authentic
receipt read failed. A failed alone is therefore not evidence of forgery: read
the check that failed and notes, check the documents your resolver serves, or
verify the receipt at app.agreely.ca/verify with
its verification bundle.
The withdrawal: withdrawal
overall judges the receipt; withdrawal says whether the consent is still in
force, as the chain records it. It is active when every accepted cell was read on
chain under the bundle's Merkle root and none is withdrawn, withdrawn when the
chain records a cell's withdrawal (withdrawnAt gives the latest, in unix seconds,
when the record carries it), and not_checked otherwise. Without rpcUrl nothing
reads it: a withdrawn consent verified offline reads overall verified and
withdrawal not_checked. A paper, telephone or bare citizen receipt always reads
not_checked, because this SDK reads the on-chain record of a passkey bundle only.
To accept a consent only while it is in force:
$result = Agreely::verifyReceipt($bundleJson, ['rpcUrl' => 'https://base-rpc.publicnode.com']);
$inForce = $result->overall === 'verified' && $result->withdrawal === 'active';
What it proves vs what it presumes
| Check | Paper or telephone receipt | Passkey bundle (citizen) |
A pass PROVES |
What stays PRESUMED |
|---|---|---|---|---|
companySignature |
Ed25519 over the receipt body | proof[0] over the signed offer |
the company signed this body (paper: it attests a hand-signed PDF of hash H) or this offer, under a key its DID document lists under assertionMethod |
that a human really signed the PDF (the SDK sees the attestation, not the wet signature); that the DID host is the company you expect |
receiptIntegrity |
follows companySignature, plus the proof shape |
the receipt against the offer, field by field, and grantedAt inside the offer's window |
the receipt says what the company offered, at a date the offer allows | nothing more about the content |
citizenAssertion |
unsupported |
WebAuthn, challenge recomputed, key confirmed by the citizen's DID at grantedAt |
a passkey the citizen's DID document attributes to them at grantedAt approved this receipt, as the bundle ties it |
that the person alone held that key (the assertion proves possession); that the citizen's DID document is truthful |
disclosureCopy |
IPFS copy against the receipt's disclosureHash |
against the signed offer's | the fetched IPFS copy matches the signed hash | that the gateway served the true copy (a pass only ties the fetched bytes to the hash) |
documentAnchor |
rootOf on chain |
same | the document was anchored in the consent-document lane | that a consent was given (the anchor proves the document, not a grant) |
consentAnchor |
skipped |
registry(consentRef) under the bound root |
each cell is recorded on chain under this bundle's root, in force when read | that the bundle's consentRef list is complete (no one signs it; an accepted cell without a ref counts as unreadable) |
cellLabelBinding |
follows companySignature |
pass when the offer and the integrity pass |
the displayed category/purpose labels sit in a body or an offer the company signed |
nothing more; on a bare citizen receipt (unsupported), do not presume the labels are accurate |
Best overall verdict per input:
| Input | Best verdict | Why |
|---|---|---|
company_attested, bare or in its bundle |
verified |
the company signature covers the receipt body |
company_documented |
partial |
the signature verifies, but the organisation's word is all there is |
citizen, in its bundle |
verified |
the offer signature, the receipt integrity and the bound assertion, with a key the citizen's DID confirms |
citizen, bare receipt |
incomplete |
nothing ties the assertion to the receipt's content (see below) |
The honest limit: a bare citizen receipt
On a bare citizen receipt, companySignature and receiptIntegrity are
unsupported and citizenAssertion is unbound, and it is not an
oversight. The company signed the original offer, which carries the full
disclosure, the nonce and the offer's dates; the receipt does not reproduce it, to
preserve unlinkability. The company's customer reference was never part of what the
company signed. The offer travels in the verification bundle the person
downloads with the receipt (receipt, signedOffer, merkleRoot): pass that
bundle to verifyReceipt, check it by hand
(the recipe) or in the public verifier at
app.agreely.ca/verify. This is why a bare citizen receipt reads incomplete.
What the SDK reads, and what app.agreely.ca/verify does on top
On a passkey bundle, Agreely::verifyReceipt runs the offer signature, the receipt integrity, the bound assertion with the key the citizen's DID confirms at grantedAt, and, with an rpcUrl, both anchors and the withdrawal. The client-side verifier at app.agreely.ca/verify also does the live DNS re-check of the did:web domain binding, the point-in-time validation of the company key across rotations, the withdrawal read of a version 4 paper or telephone receipt, and batch verification of bundles at app.agreely.ca/verify/batch. Do not assume the SDK performs those checks.
Reading the result
$v = Agreely::verifyReceipt($json, ['rpcUrl' => 'https://base-rpc.publicnode.com']);
switch ($v->overall) {
case 'verified':
// the checks listed held: read $v->withdrawal before treating the consent as in force
break;
case 'withdrawn':
// authentic, and the chain records its withdrawal: no longer in force, NOT a forgery
break;
case 'partial':
// a telephone receipt: the organisation's word, its signature verified. A
// partial does not on its own show consent: verify its bundle at app.agreely.ca/verify
// to read whether a version 4 receipt is still in force
break;
case 'incomplete':
// a bare passkey receipt: verify the verification bundle issued with it
break;
case 'unavailable':
// inconclusive (a DID, a gateway or an RPC did not answer, or an unknown registry), NOT a forgery
break;
case 'failed':
// a check ACTIVELY failed: read which one, and the notes, before calling it a tamper
break;
case 'malformed':
// not a file this verifier can read: no check ran
break;
}
Verify options
The options (an associative array):
| Option | Default | Role |
|---|---|---|
resolver |
default did:web resolver, and citizen resolver |
a callable fn(string $did) returning the DID document, preferably as JSON text so it is read as published; if it throws, the resolution counts as unavailable. Keys are never taken from the bundle's didDocuments |
companyDidHost |
app.agreely.ca |
host of the company did:web: the Agreely web tier, the only one that serves /c/{slug}/did.json (not the apex agreely.ca), used by resolveCompanyDid |
citizenResolverBaseUrl |
https://my.agreely.ca |
citizen DID resolution base: the Agreely citizen tier, which serves /did/{did} |
verifyDisclosure |
false |
runs the IPFS disclosure-copy cross-check; otherwise skipped, without contacting the gateway |
ipfsGateway |
Lighthouse gateway (https://gateway.lighthouse.storage/ipfs/ followed by the CID) |
a callable fn(string $cid): string returning the URL to fetch. A string (a URL) is silently ignored: the default gateway is used instead |
rpcUrl |
(none) | the JSON-RPC URL for the on-chain checks (documentAnchor, consentAnchor) and the withdrawal; without it, skipped |
registryAddress |
the known registry of chainId |
the AgreelyRegistry address, read by eth_call on any chain; off Base and Base Sepolia, pass it, or the anchor checks read unavailable |
chainId |
8453 (Base mainnet) |
the anchoring chain; pass 84532 to try against Base Sepolia |
timeoutMs |
10000 |
the timeout of each HTTP call the default callables make, in milliseconds |
rpcDeadlineMs |
30000 |
the deadline of a verification's whole on-chain sequence, from its first RPC call; past it, the anchor checks still pending read unavailable |
httpGet / httpPost |
default curl callables | fn(string $url): ?string and fn(string $url, string $body): ?string; a callable that throws counts as unavailable |
The registry the SDK reads
Without registryAddress, the SDK reads the chain's known registry: on Base mainnet (chain id 8453), 0x6F2cF94045cF0cbDD94338882c34D183575023E9, deployed at block 53113531; on Base Sepolia (84532), 0xf25D3F8E73648C797Be2CCCD428061f357567b9A, deployed at block 50213158. The on-chain checks read the registry by eth_call only, with no log query. A bundle whose issuer-key delegation names a registry or chain other than the one read is refused before any RPC call: the anchor checks read unavailable, registryRefused is true, and the verdict is never verified. That result is inconclusive, not a forgery. The address in force is always the one the public verifier publishes at app.agreely.ca/verify.
Trust and SSRF: inject your own resolver for an untrusted receipt
The default did:web resolver fetches a host taken from the receipt itself (HTTPS-only). A verified therefore shows that the receipt was signed by whoever controls that host's DID document, not that the host is the company: a receipt naming a host of its choosing, signed by a key that host publishes, can read verified. For a receipt you did not fetch yourself, inject your own resolver (or your httpGet / httpPost callables) or supply the DID documents locally: you then choose which issuers you trust, rather than letting the receipt choose them and steer an outbound request. Keys are never taken from the bundle's didDocuments, which anyone can write.
Next
- Verify a receipt yourself: the walkthrough with the SDK or the CLI, and a receipt to try.
- The verification recipe at the field level, without the SDK.
- TypeScript SDK · Verifying a ConsentReceipt.