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 always resolves. A tampered receipt returns a verdict, never an exception. The CLI exposes the exact same statuses through agreely verify.

Verify with the SDK or CLI

Pass the receipt. The on-chain anchor check is optional and only runs if you supply an RPC URL.

import { Agreely } from "@agreely/sdk";

const v = await Agreely.verifyReceipt(receipt, { rpcUrl: process.env.BASE_SEPOLIA_RPC, // optional: enables the on-chain anchor check });

v.receiptType; // "company_attested" | "citizen" if (v.overall === "failed") { // a decisive check ACTIVELY failed: treat as tamper/forgery, reject } else if (v.overall === "unavailable") { // a DID or RPC could not be reached: inconclusive, NOT a forgery, retry later } else { // "verified" (company_attested) or "partial" (citizen, offline) }

use Agreely\Sdk\Agreely;

$v = Agreely::verifyReceipt($receipt, ['rpcUrl' => getenv('BASE_SEPOLIA_RPC')]);

$v->receiptType; // "company_attested" | "citizen" $v->overall; // "verified" | "partial" | "failed" | "unavailable"

# zero-install (no global install needed):
npx @agreely/cli verify ./receipt.json

or, if the CLI is installed globally:

agreely verify ./receipt.json --json

human mode prints a five-row honesty matrix + an overall verdict + notes

exit 0 = verified/partial, 4 = unavailable, 6 = failed (tamper)

air-gapped: supply the issuer DID document locally, no network at all:

agreely verify ./receipt.json --did-doc ./issuer-did.json

opt-in cross-checks:

agreely verify ./receipt.json --ipfs --onchain --rpc-url $AGREELY_RPC_URL

The result is a ReceiptVerification: a receiptType, five per-check statuses (companySignature, citizenAssertion, disclosureCopy, documentAnchor, cellLabelBinding), an overall, and plain-language notes.

The four checks

  1. companySignature: the company's Ed25519 signature over the offer.
  2. citizenAssertion: the WebAuthn assertion from the citizen passkey.
  3. disclosureCopy: an optional cross-check that fetches the disclosure copy from IPFS and compares it to the receipt's disclosureHash. Enabled with --ipfs on the CLI (or the verifyDisclosure option); otherwise skipped.
  4. documentAnchor: an optional on-chain cross-check that confirms the document was anchored. Enabled with --onchain, which requires an RPC URL (--rpc-url or AGREELY_RPC_URL); otherwise skipped.

The matrix also surfaces a fifth row, cellLabelBinding: a derived summary of whether the human-readable category/purpose labels are cryptographically bound in this offline check. It is the one field to read before trusting a displayed label. On a company-attested receipt it tracks companySignature (the labels sit inside the signed body, so mutating one breaks the check); on a citizen receipt it is unsupported offline.

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)
  companySignature  pass
  citizenAssertion  unsupported
  disclosureCopy    skipped
  documentAnchor    skipped
  cellLabelBinding  pass

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

The output ends in VERIFIED with exit code 0. It is a company-attested receipt, so the verdict is a clean verified, not partial.

The honesty matrix

The best possible verdict depends on the receipt type, and that is the load- bearing honesty point of this page.

Receipt type Best verdict Why
company_attested verified the company signature verifies fully offline
citizen partial companySignature is unsupported offline (see below)

A company-attested receipt (typically a hand-signed PDF) reaches verified with no network at all: its company signature verifies offline. A citizen receipt honestly caps at partial: the citizen's passkey assertion does verify offline, but the company-signature half cannot.

Why a citizen receipt caps at partial offline

On a citizen receipt, 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 a server receipts/verify endpoint, which does not exist yet (see the roadmap). That is why companySignature is unsupported offline on a citizen receipt, and partial is the honest verdict, not a failure.

Failed versus unavailable

verifyReceipt separates a check that failed (failed: the cryptography actively disproved the receipt, a forgery) from a check that could not complete (unavailable: the issuer or citizen DID could not be resolved, or a gateway was unreachable). An active fail always wins over an unavailable: a true negative is never masked by unavailability, and a valid receipt during a DID-resolution outage never looks like a forgery. On the CLI this is exit 6 (failed) versus exit 4 (unavailable).

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 four-step check from scratch without the SDK.