Verifying a ConsentReceipt
Agreely::verifyReceipt($receipt, $opts) is static, needs no API key, and
always resolves. A tampered receipt yields a verdict, never an exception.
In PHP, the network seams are injected as httpGet / httpPost callables (not
fetch).
use Agreely\Sdk\Agreely;
$v = Agreely::verifyReceipt($receipt, [
'rpcUrl' => getenv('BASE_SEPOLIA_RPC'), // required for the on-chain anchor check, else skipped
]);
The load-bearing honesty point: verifyReceipt separates a check that failed
(fail, the crypto actively did not verify) from a check that could not
complete (unavailable, the issuer or citizen DID could not be resolved). A
valid receipt during a DID outage therefore never looks byte-identical to a
forgery.
The two receipt types
company_attested: the company attested a consent (typically a hand-signed PDF). The company signature verifies fully offline. Best possible verdict:verified.citizen: the citizen signed with a passkey (WebAuthn). Best possible verdict:partial. A citizen receipt can never beverifiedoffline (see the honest limitation below).
The four checks (plus the label binding)
verifyReceipt returns a ReceiptVerification. Each check is a bare string
property (readonly string, not an object with ->status); the human-readable
explanations all live in a single flat $v->notes array.
// $v->receiptType "company_attested" | "citizen"
// $v->companySignature "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->citizenAssertion "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->disclosureCopy "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->documentAnchor "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->cellLabelBinding "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->overall "verified" | "partial" | "failed" | "unavailable"
// $v->notes string[] (plain-language caveats, one flat array)
// $v->toArray() the same field shape as the TS SDK (golden-vector discipline)
companySignature: the company's Ed25519 signature. On acompany_attestedreceipt, verified offline (pass/fail/unavailableif the company DID is unresolvable). On acitizenreceipt,unsupportedoffline.citizenAssertion: the citizen's WebAuthn assertion. On acitizenreceipt, verified (pass/fail/unavailable). On acompany_attestedreceipt,unsupported.disclosureCopy(opt-in cross-check,verifyDisclosuredefaulttrue): fetches the IPFS disclosure copy, computes"0x"+sha256(JCS(disclosure))and compares it to the receiptdisclosureHash(pass/fail;skippedif the receipt has nodocument.ipfsCid+disclosureHash, or the fetch fails).documentAnchor(opt-in on-chain cross-check, needsopts['rpcUrl'], elseskipped):commitment = keccak256(utf8(cid)), theneth_getLogson theAgreelyRegistryforIdentityAnchored+ thatcommitment(pass/fail).
On top of those four checks comes one derived verdict:
cellLabelBinding: are the human-readable cell labels (category/purpose) cryptographically bound in this offline verification? It is the field to read before trusting a displayed label. On acompany_attestedreceipt it trackscompanySignature. On acitizenreceipt it isunsupportedoffline: the receipt omits the salted commitment and Merkle root that bind the labels, so a mutated label cannot be detected offline (use the serverreceipts/verifyendpoint).
Status legend
Per-check status (CheckStatus):
| Status | Meaning |
|---|---|
pass |
the check ran and verified cryptographically |
fail |
the check ran and actively did not verify (tamper or wrong key) |
unavailable |
the check could not complete: DID unresolvable, unreachable host, network outage, key not found |
skipped |
the input is absent or the check was not requested |
unsupported |
the check does not apply to this receipt type |
Overall status (OverallStatus):
| Status | Meaning |
|---|---|
verified |
a company_attested receipt, fully sound |
partial |
a citizen receipt verified offline: honest, not a failure |
failed |
a decisive check actively failed |
unavailable |
a decisive check could not complete: inconclusive, NOT a forgery |
Load-bearing rule: an active fail always beats an unavailable. A real
negative is never masked by an unavailability.
What it proves vs what it trusts
| Check | company_attested receipt |
citizen receipt |
A pass PROVES |
What it still TRUSTS |
|---|---|---|---|---|
companySignature |
Ed25519, offline (pass/fail/unavailable) |
unsupported offline |
the company attested a hand-signed PDF with hash H, under a key its DID publishes |
that a human actually signed the PDF (the SDK sees the attestation, not the wet signature); that the DID host is the real company |
citizenAssertion |
unsupported |
WebAuthn (pass/fail/unavailable) |
the citizen's passkey signed a committed challenge: passkey possession | that possession equals cell-level consent semantics (it does not prove which cells were consented) |
disclosureCopy |
IPFS copy vs disclosureHash (pass/fail/skipped) |
same | the public IPFS copy matches the committed disclosureHash |
that the IPFS gateway served the real copy (a pass only ties the fetched bytes to the hash) |
documentAnchor |
on-chain commitment (pass/fail/skipped) |
same | the document existed on-chain (keccak256(cid) logged in the registry) |
that on-chain existence equals a consent (anchoring proves the document, not any grant) |
cellLabelBinding |
tracks companySignature (pass/fail/unavailable) |
unsupported offline |
the displayed category/purpose labels sit inside the signed body, so the verified signature covers them |
nothing more on an attested receipt; on a citizen receipt, do not assume the labels are accurate offline (use receipts/verify) |
Best overall verdict per receipt type:
| Receipt type | Best verdict | Why |
|---|---|---|
company_attested |
verified |
the signature verifies fully offline |
citizen |
partial |
companySignature is unsupported offline (see below) |
The honest limitation: companySignature on a citizen receipt
On a citizen receipt, companySignature is unsupported offline, and
that is not an oversight. The company signed the original offer, which
includes the subject reference and the full disclosure; the receipt omits
those fields to preserve unlinkability. A sound company-signature check therefore
needs the server receipts/verify endpoint, which does not exist yet. Do not
assume it does. This is exactly why a citizen receipt caps at partial offline.
The SDK is the leaner verifier; the fuller model lives at verify.agreely.ca
Agreely::verifyReceipt is the leaner four-check verifier (company signature, citizen assertion, disclosure copy, document anchor) plus the derived cellLabelBinding verdict. Its only on-chain check is documentAnchor (the IdentityAnchored event: it proves the document existed, not that consent was given). The fuller client-side model (on-chain company-DID issuer authenticity, point-in-time key validation across key rotations, live-DNS re-check of the did:web domain binding, consent active/revoked lifecycle, and batch dossier verification) lives in the client-side verifier at verify.agreely.ca, not (yet) in the SDK. Do not assume the SDK does the richer model.
Reading the result
$v = Agreely::verifyReceipt($receipt, ['rpcUrl' => getenv('BASE_SEPOLIA_RPC')]);
$v->receiptType; // "company_attested" | "citizen"
$v->overall; // "verified" | "partial" | "failed" | "unavailable"
$v->companySignature; // "pass" | "fail" | "unavailable" | "skipped" | "unsupported" (a bare string)
$v->citizenAssertion; // same CheckStatus string
$v->disclosureCopy;
$v->documentAnchor;
$v->cellLabelBinding; // the field to read before trusting a displayed label
$v->notes; // string[] of plain-language caveats (one flat array)
if ($v->overall === 'failed') {
// a decisive check ACTIVELY failed: treat as tamper/forgery, reject.
} elseif ($v->overall === 'unavailable') {
// inconclusive (a DID or RPC could not be reached), NOT a forgery: retry later.
} elseif ($v->overall === 'partial') {
// a citizen receipt, verified offline as far as offline can go. Honest, not a failure.
}
Verify options
VerifyReceiptOptions (an associative array):
| Option | Default | Role |
|---|---|---|
resolver |
default did:web resolver |
inject your own DID resolver |
companyDidHost |
agreely.ca |
apex host for the company did:web (serves /c/{slug}/did.json), used by resolveCompanyDid |
citizenResolverBaseUrl |
https://api.agreely.ca |
citizen DID resolution base |
verifyDisclosure |
true |
run the disclosure-copy cross-check |
ipfsGateway |
https://gateway.lighthouse.storage/ipfs/ |
IPFS gateway (Lighthouse) |
rpcUrl |
(none) | required for the on-chain anchor check, else skipped |
registryAddress |
0x3532a9DfF3C910152543afCa49De7c66113C5312 |
AgreelyRegistry (Base Sepolia) |
chainId |
84532 (Base Sepolia) |
mainnet 8453 is null, not deployed |
httpGet / httpPost |
default curl callables | inject your own network seams |
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). It can never yield a false verified (the key must still verify), but for untrusted receipts, inject your own resolver (or your httpGet / httpPost callables) or supply the DID documents locally, rather than letting the receipt steer an outbound request.
Next
- Verify a receipt yourself: the field-level four-check recipe, without the SDK.
- TypeScript SDK · Verifying a ConsentReceipt.