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
companySignature: the company's Ed25519 signature over the offer.citizenAssertion: the WebAuthn assertion from the citizen passkey.disclosureCopy: an optional cross-check that fetches the disclosure copy from IPFS and compares it to the receipt'sdisclosureHash. Enabled with--ipfson the CLI (or theverifyDisclosureoption); otherwiseskipped.documentAnchor: an optional on-chain cross-check that confirms the document was anchored. Enabled with--onchain, which requires an RPC URL (--rpc-urlorAGREELY_RPC_URL); otherwiseskipped.
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):
- 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)
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.