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 TypeScript, the network seams are injected through opts.fetch.
import { Agreely } from "@agreely/sdk";
const v = await Agreely.verifyReceipt(receipt, {
rpcUrl: process.env.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
CheckStatus string (not a { status } object); the human-readable
explanations all live in a single flat notes array for the whole result.
// {
// receiptType: "company_attested" | "citizen",
// companySignature: "pass" | "fail" | "unavailable" | "skipped" | "unsupported",
// citizenAssertion: "pass" | "fail" | "unavailable" | "skipped" | "unsupported",
// disclosureCopy: "pass" | "fail" | "unavailable" | "skipped" | "unsupported",
// documentAnchor: "pass" | "fail" | "unavailable" | "skipped" | "unsupported",
// cellLabelBinding: "pass" | "fail" | "unavailable" | "skipped" | "unsupported",
// overall: "verified" | "partial" | "failed" | "unavailable",
// notes: string[] // plain-language caveats, one flat array
// }
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(the labels sit inside the signed body, so a mutation breaks it). On acitizenreceipt it isunsupportedoffline: the receipt omits the salted commitment and Merkle root that bind the labels (unlinkability / crypto-shredding), so a mutated label cannot be detected offline. Use the serverreceipts/verifyendpoint (it holds the salt) for a sound label check.
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
const v = await Agreely.verifyReceipt(receipt, { rpcUrl: process.env.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.
} else if (v.overall === "unavailable") {
// inconclusive (a DID or RPC could not be reached), NOT a forgery: retry later.
} else if (v.overall === "partial") {
// a citizen receipt, verified offline as far as offline can go. Honest, not a failure.
}
Verify options
VerifyReceiptOptions:
| 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 |
fetch |
global fetch |
inject your own fetch |
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 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.
- PHP SDK · Verifying a ConsentReceipt.