Verify a receipt
Independent verifiability only matters if you can actually do it. The shortest way
is Agreely.verifyReceipt: a static method, with no API key, that returns
a verdict on every input, never an exception. The CLI exposes the
exact same statuses through agreely verify.
Verify with the SDK or CLI
Pass the verification bundle a passkey consent issues (the receipt, the signed offer and the Merkle root, in one JSON file), or a paper or telephone receipt. The on-chain checks (the anchors and the withdrawal) are optional and only run if you supply an RPC URL.
import { readFile } from "node:fs/promises";
import { Agreely } from "@agreely/sdk";
const file = JSON.parse(await readFile("./agreely-verification-bundle.json", "utf8"));
const v = await Agreely.verifyReceipt(file, {
rpcUrl: "https://base-rpc.publicnode.com", // optional: the on-chain anchors and the withdrawal
});
v.receiptType; // "company_attested" | "company_documented" | "citizen"
if (v.overall === "verified") {
// the checks listed held; v.withdrawal reads "active" once the chain reads the consent in force
} else if (v.overall === "withdrawn") {
// authentic, and the chain records its withdrawal: no longer in force, NOT a forgery
} else if (v.overall === "partial") {
// a telephone receipt, the organisation's word. A
// partial does not on its own show consent: verify its bundle at app.agreely.ca/verify
} else if (v.overall === "incomplete") {
// a bare passkey receipt: verify the verification bundle issued with it
} else if (v.overall === "unavailable") {
// inconclusive (a DID, a gateway or an RPC did not answer), NOT a forgery: retry later
} else {
// "failed" or "malformed": read which check failed, and the notes, before calling it a tamper
}use Agreely\Sdk\Agreely;
$v = Agreely::verifyReceipt(file_get_contents('agreely-verification-bundle.json'), [
'rpcUrl' => 'https://base-rpc.publicnode.com', // optional: the on-chain anchors and the withdrawal
]);
$v->receiptType; // "company_attested" | "company_documented" | "citizen"
$v->overall; // "verified" | "partial" | "incomplete" | "failed" | "unavailable" | "malformed" | "withdrawn"
$v->withdrawal; // "active" | "withdrawn" | "not_checked"# zero-install (no global install needed):
npx @agreely/cli verify ./agreely-verification-bundle.json
# or, if the CLI is installed globally:
agreely verify ./agreely-verification-bundle.json --json
# human mode prints the check matrix, the withdrawal line, an overall verdict and notes
# exit 0 = verified (or partial on a telephone receipt), 4 = a check could not complete,
# 6 = failed or malformed, 11 = a bare passkey receipt, 12 = withdrawn (with --onchain)
# air-gapped: supply the DID documents locally, taken from the issuer and the citizen
# tier, never out of the file under test:
agreely verify ./agreely-verification-bundle.json --did-doc ./issuer-did.json --did-doc ./citizen-did.json
# opt-in cross-checks: the IPFS copy, the on-chain anchors and the withdrawal:
agreely verify ./agreely-verification-bundle.json --ipfs --onchain --rpc-url https://base-rpc.publicnode.comThe result is a ReceiptVerification: an input (bundle or receipt), a
receiptType, one status per check, an overall, a withdrawal (active,
withdrawn or not_checked), and plain-language notes, written for humans: a
program reads the fields.
The checks
companySignature: the company's Ed25519 signature, over the body of a paper or telephone receipt, or over the signed offer of a passkey bundle.receiptIntegrity: on a bundle, the receipt matches the signed offer field by field, and its acceptance date (grantedAt) falls inside the offer's window.citizenAssertion: the WebAuthn assertion of the person's passkey. On a bundle, its challenge recomputes from the offer and the Merkle root, and the citizen's DID document confirms the key atgrantedAt. On a bare citizen receipt, the assertion verifies over the challenge the receipt carries, without tying that challenge to the receipt's content: it readsunbound, and on its own does not show that this person consented to these purposes. A citizen receipt is verified with its verification bundle.disclosureCopy: an optional cross-check that fetches the disclosure copy from IPFS and compares it to the signeddisclosureHash(theverifyDisclosure: trueoption in the SDK,--ipfsin the CLI); otherwiseskipped.documentAnchor: an optional on-chain cross-check that confirms the document was anchored. It needs an RPC URL (rpcUrlin the SDK,--onchainwith--rpc-urlorAGREELY_RPC_URLin the CLI); otherwiseskipped.consentAnchor: an optional on-chain cross-check, on a passkey bundle only. It reads each consent's record under the bundle's root, and it is whatwithdrawalrests on.
The matrix also shows cellLabelBinding: a derived summary of whether the
human-readable labels (category / purpose) are cryptographically bound. It is
the field to read before trusting a displayed label. On a paper or telephone
receipt it follows companySignature (the labels sit in the signed body, so
mutating them breaks verification); on a bundle it passes when the signed offer and
the receipt integrity pass; on a bare citizen receipt it is unsupported.
Try it yourself
A regulator told "this is independently verifiable" needs something to try. Download a genuine company-attested receipt and its issuer DID document, extracted from the SDK's golden vectors, then verify the receipt offline (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 output starts with VERIFIED and exit code 0. It is a company-attested
receipt, so verification is clean rather than partial. The
WITHDRAWAL NOT CHECKED line is a reminder that this verification says nothing of
a withdrawal: this verifier reads the withdrawal on chain from a passkey consent's
bundle.
The honesty matrix
The best possible verdict depends on the receipt type, and this is the load-bearing honesty point of this page.
| Receipt type | Best verdict | Why |
|---|---|---|
company_attested |
verified |
the company signature covers the receipt body and verifies offline |
company_documented |
partial |
a verbal consent: the company 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 |
the assertion is tied to no content (unbound, see below) |
A company-attested receipt (typically a hand-signed PDF) reaches verified
offline: its company signature covers the receipt body. A passkey consent reaches
verified from its verification bundle. On a bare citizen receipt, the
citizen's passkey assertion verifies over the challenge the receipt carries,
without tying it to the receipt's content: citizenAssertion reads unbound and
overall reads incomplete. A verified means the checks listed hold; it is not
a legal proof.
Why a telephone receipt stops at partial, and a bare citizen receipt at incomplete
A telephone receipt carries the company's signature, but no signature of the person: it documents what the organisation says it collected. A partial therefore does not on its own show that this person consented: it is the organisation's word, authenticated. The public verifier at app.agreely.ca/verify also reads, with its verification bundle, whether a version 4 receipt is still in force. On a citizen receipt, the company signed the original offer, which carries the full disclosure; the receipt does not reproduce it, to preserve unlinkability. Only the verification bundle carries the signed offer, the requestId and the Merkle root that tie the assertion to the receipt: pass it to the SDK, the CLI or the public verifier.
Failed vs unavailable
verifyReceipt separates a check that failed (failed: the cryptography or
the chain actively contradicts the file) from a check that could not complete
(unavailable: a DID, an IPFS gateway or an RPC did not answer, or the bundle
names a registry the verifier does not read). An active fail always beats an
unavailable: a true negative is never masked by an outage, and a valid receipt
during an outage never looks like a forgery. In the CLI, that is exit 6
(failed) versus exit 4 (unavailable).
A requested cross-check that could not complete (disclosureCopy,
documentAnchor or consentAnchor at unavailable) leaves the overall verdict at
verified in the SDK; the CLI then exits 4. Read each check and notes before
relying on the verdict.
withdrawn is neither a failure nor an outage: the receipt is authentic, and the
chain records the consent's withdrawal, so it is no longer in force (exit 12 in
the CLI). Without an RPC URL nothing reads the withdrawal: withdrawal reads
not_checked.
The keys come from the DID documents the verifier reads. A resolver or a
--did-doc 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 DID documents you supplied, or verify the receipt at
app.agreely.ca/verify with its verification
bundle.
Next
- Verify a ConsentReceipt: the full "what is proven versus what is assumed" matrix and every option.
- The field-level verify recipe, to rebuild the checks without the SDK.