FR EN

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.com

The 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

  1. companySignature: the company's Ed25519 signature, over the body of a paper or telephone receipt, or over the signed offer of a passkey bundle.
  2. receiptIntegrity: on a bundle, the receipt matches the signed offer field by field, and its acceptance date (grantedAt) falls inside the offer's window.
  3. 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 at grantedAt. On a bare citizen receipt, the assertion verifies over the challenge the receipt carries, without tying that challenge to the receipt's content: it reads unbound, and on its own does not show that this person consented to these purposes. A citizen receipt is verified with its verification bundle.
  4. disclosureCopy: an optional cross-check that fetches the disclosure copy from IPFS and compares it to the signed disclosureHash (the verifyDisclosure: true option in the SDK, --ipfs in the CLI); otherwise skipped.
  5. documentAnchor: an optional on-chain cross-check that confirms the document was anchored. It needs an RPC URL (rpcUrl in the SDK, --onchain with --rpc-url or AGREELY_RPC_URL in the CLI); otherwise skipped.
  6. 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 what withdrawal rests 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):

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