FR EN

Verifying a ConsentReceipt

Agreely.verifyReceipt(input, 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). isVerificationBundle(value) tells one from the other. In TypeScript the network seams are injected through opts.fetch. This page describes @agreely/sdk 0.6.1.

import { readFile } from "node:fs/promises";
import { Agreely, isVerificationBundle } from "@agreely/sdk";

const file = JSON.parse(await readFile("./agreely-verification-bundle.json", "utf8"));
isVerificationBundle(file); // true for a verification bundle, false for a bare receipt

const v = await Agreely.verifyReceipt(file, {
  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 (a VerbalConsentReceipt). The company signature verifies, but there is no signed document: the best possible verdict is partial.
  • citizen: the person signed with a passkey (WebAuthn). Best possible verdict: verified, from its verification bundle only. A bare citizen receipt reads incomplete: pass the bundle issued with it (see the honest limit below).

The 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 one flat notes array, written for humans: a program reads the fields, never the notes.

// {
//   input: "bundle" | "receipt",
//   receiptType: "company_attested" | "company_documented" | "citizen",
//   companySignature: CheckStatus,  // "pass" | "fail" | "unavailable" | "unbound"
//   receiptIntegrity: CheckStatus,  //   | "withdrawn" | "skipped" | "unsupported"
//   citizenAssertion: CheckStatus,
//   disclosureCopy:   CheckStatus,
//   documentAnchor:   CheckStatus,
//   consentAnchor:    CheckStatus,
//   cellLabelBinding: CheckStatus,
//   overall: "verified" | "partial" | "incomplete" | "failed" | "unavailable" | "malformed" | "withdrawn",
//   withdrawal: "active" | "withdrawn" | "not_checked",
//   withdrawnAt?: number,                               // unix seconds, when the chain records it
//   documentAnchorRecord?: { anchoredAt, anchoredBlock },
//   consentAnchorRecords?: [{ consentRef, state, anchoredAt, revokedAt, anchoredBlock }],
//   consentAnchorCounts?: { cells, active, withdrawn, pending, unreadable },
//   registryRefused?: boolean,
//   notes: string[]   // plain-language caveats, one flat array
// }
  1. companySignature: the company's Ed25519 signature. On a paper or telephone receipt it covers the receipt body. On a passkey bundle it is proof[0] over the signed offer. Only the method proof[0].verificationMethod names checks it: a method of the issuer (its id is the issuer DID followed by # and a fragment), listed under assertionMethod in the issuer's DID document. A resolved document without that key reads fail; an unresolvable DID reads unavailable. On a bare citizen receipt, unsupported: the offer only travels in the bundle.
  2. receiptIntegrity: on a passkey bundle, the receipt matches the signed offer field by field (its top-level keys, type, @context and the urn:agreely:receipt: id included), the disclosureHash recomputes from the offer, validFrom equals grantedAt, and grantedAt falls inside the offer's window, from createdAt to expiresAt (in whole seconds). The proof array has the shape the issuer writes: two proofs on a passkey receipt (the company's DataIntegrityProof, then the person's WebAuthnAssertion), one on a paper or telephone receipt. On a paper or telephone receipt, this check follows companySignature (the signature covers the body) and reads fail when the proof does not have its issued shape. On a bare citizen receipt, unsupported.
  3. citizenAssertion: on a bundle, the WebAuthn assertion verifies, its challenge recomputes as keccak256(JCS({issuer, subject, requestId, action, grantedAt, merkleRoot}) || nonce), and the citizen's DID document (whose id is the subject) lists that exact key under authentication, with a [validFrom, validUntil) window covering grantedAt. A document that does not confirm the key reads fail; a document that cannot be read as this identity's reads unavailable. When grantedAt falls outside the offer's window, the key is not evaluated at that date: unavailable. The citizen half is checked only for a did:agreely:citizen: subject, whatever the resolver; any other subject reads unavailable. 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 the requestId, 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.
  4. disclosureCopy (optional cross-check, verifyDisclosure: true, otherwise skipped without contacting the gateway): fetches the disclosure copy from IPFS and compares it to the signed disclosureHash. On a bundle, that is the signed offer's. When the receipt declares disclosureVersion: "2", the hash covers {disclosure, legalBasis}, and a v2 receipt without its legalBasis reads fail. A gateway that does not return the document (404, 502), fails or times out reads unavailable. On a bare citizen receipt, unsupported: its hash is signed by no one.
  5. documentAnchor (optional on-chain cross-check, needs opts.rpcUrl, otherwise skipped): one eth_call rootOf(keccak256(cid)) on the AgreelyRegistry. A record in the consent-document lane passes and fills documentAnchorRecord (anchoredAt, anchoredBlock). No record yet reads unavailable: the document is anchored after it is published, so its absence is not a contradiction. A record in another lane, or a tagged one, reads fail. This check proves the document existed, not that a consent was given.
  6. consentAnchor (optional on-chain cross-check, needs opts.rpcUrl, passkey bundle only): one eth_call registry(consentRef) per cell, tied to the receipt by the Merkle root the verified assertion binds. pass when every cell was read under that root and is in force; withdrawn when the chain records a cell's withdrawal under that root (the consent is authentic, and no longer in force); fail when the chain records a cell under another root; unavailable otherwise, a cell not yet anchored or a failed RPC included. Every accepted cell the receipt lists counts: a cell whose consentRef the bundle does not name counts as unreadable, because no one signs that list. consentAnchorRecords gives each cell read under that root (state, anchoredAt, revokedAt, anchoredBlock), and consentAnchorCounts the tally (cells, active, withdrawn, pending, unreadable). On a paper, telephone or bare citizen receipt, skipped.

On top of these checks sits one derived verdict:

  1. 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 follows companySignature (the labels sit in the signed body). On a bundle it is pass when the offer signature and the receipt integrity pass (each cell is an exact item of the signed offer), fail when either fails, and unavailable when the issuer DID does not resolve. On a bare citizen receipt it is unsupported: 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, 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 (OverallStatus):

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:

const result = await Agreely.verifyReceipt(bundle, { rpcUrl: "https://base-rpc.publicnode.com" });
const 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

const v = await Agreely.verifyReceipt(file, { rpcUrl: "https://base-rpc.publicnode.com" });

v.input;               // "bundle" | "receipt"
v.receiptType;         // "company_attested" | "company_documented" | "citizen"
v.overall;             // "verified" | "partial" | "incomplete" | "failed" | "unavailable" | "malformed" | "withdrawn"
v.withdrawal;          // "active" | "withdrawn" | "not_checked"
v.companySignature;    // a bare CheckStatus string
v.receiptIntegrity;
v.citizenAssertion;
v.disclosureCopy;
v.documentAnchor;
v.consentAnchor;
v.cellLabelBinding;    // the field to read before trusting a displayed label
v.consentAnchorCounts; // { cells, active, withdrawn, pending, unreadable } once the consent anchor read ran
v.registryRefused;     // true when the bundle names a registry or chain this SDK does not read
v.notes;               // string[] of plain-language caveats, for humans: programs read the fields

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

VerifyReceiptOptions:

Option Default Purpose
resolver default did:web resolver, and citizen resolver inject your own DID resolver; keys are never taken from the bundle's didDocuments
companyDidHost app.agreely.ca the company did:web host: Agreely's Web tier, the only one that serves /c/{slug}/did.json (not the agreely.ca apex), used by resolveCompanyDid
citizenResolverBaseUrl https://my.agreely.ca the citizen DID resolution base: Agreely's 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 function (cid: string) => string returning the URL to fetch, not a URL
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 RPC, IPFS and DID call, 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
fetch global fetch inject your own fetch

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