FR EN

Verifying a ConsentReceipt

Agreely::verifyReceipt($receipt, $opts) is static, needs no API key, and always resolves. A tampered receipt yields a verdict, never an exception. In PHP, the network seams are injected as httpGet / httpPost callables (not fetch).

use Agreely\Sdk\Agreely;

$v = Agreely::verifyReceipt($receipt, [
    'rpcUrl' => getenv('BASE_SEPOLIA_RPC'), // required for the on-chain anchor check, else skipped
]);

The load-bearing honesty point: verifyReceipt separates a check that failed (fail, the crypto actively did not verify) from a check that could not complete (unavailable, the issuer or citizen DID could not be resolved). A valid receipt during a DID outage therefore never looks byte-identical to a forgery.

The two receipt types

  • company_attested: the company attested a consent (typically a hand-signed PDF). The company signature verifies fully offline. Best possible verdict: verified.
  • citizen: the citizen signed with a passkey (WebAuthn). Best possible verdict: partial. A citizen receipt can never be verified offline (see the honest limitation below).

The four checks (plus the label binding)

verifyReceipt returns a ReceiptVerification. Each check is a bare string property (readonly string, not an object with ->status); the human-readable explanations all live in a single flat $v->notes array.

// $v->receiptType        "company_attested" | "citizen"
// $v->companySignature   "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->citizenAssertion   "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->disclosureCopy     "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->documentAnchor     "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->cellLabelBinding   "pass" | "fail" | "unavailable" | "skipped" | "unsupported"
// $v->overall            "verified" | "partial" | "failed" | "unavailable"
// $v->notes              string[]  (plain-language caveats, one flat array)
// $v->toArray()          the same field shape as the TS SDK (golden-vector discipline)
  1. companySignature: the company's Ed25519 signature. On a company_attested receipt, verified offline (pass / fail / unavailable if the company DID is unresolvable). On a citizen receipt, unsupported offline.
  2. citizenAssertion: the citizen's WebAuthn assertion. On a citizen receipt, verified (pass / fail / unavailable). On a company_attested receipt, unsupported.
  3. disclosureCopy (opt-in cross-check, verifyDisclosure default true): fetches the IPFS disclosure copy, computes "0x"+sha256(JCS(disclosure)) and compares it to the receipt disclosureHash (pass / fail; skipped if the receipt has no document.ipfsCid + disclosureHash, or the fetch fails).
  4. documentAnchor (opt-in on-chain cross-check, needs opts['rpcUrl'], else skipped): commitment = keccak256(utf8(cid)), then eth_getLogs on the AgreelyRegistry for IdentityAnchored + that commitment (pass / fail).

On top of those four checks comes one derived verdict:

  1. cellLabelBinding: are the human-readable cell labels (category / purpose) cryptographically bound in this offline verification? It is the field to read before trusting a displayed label. On a company_attested receipt it tracks companySignature. On a citizen receipt it is unsupported offline: the receipt omits the salted commitment and Merkle root that bind the labels, so a mutated label cannot be detected offline (use the server receipts/verify endpoint).

Status legend

Per-check status (CheckStatus):

Status Meaning
pass the check ran and verified cryptographically
fail the check ran and actively did not verify (tamper or wrong key)
unavailable the check could not complete: DID unresolvable, unreachable host, network outage, key not found
skipped the input is absent or the check was not requested
unsupported the check does not apply to this receipt type

Overall status (OverallStatus):

Status Meaning
verified a company_attested receipt, fully sound
partial a citizen receipt verified offline: honest, not a failure
failed a decisive check actively failed
unavailable a decisive check could not complete: inconclusive, NOT a forgery

Load-bearing rule: an active fail always beats an unavailable. A real negative is never masked by an unavailability.

What it proves vs what it trusts

Check company_attested receipt citizen receipt A pass PROVES What it still TRUSTS
companySignature Ed25519, offline (pass/fail/unavailable) unsupported offline the company attested a hand-signed PDF with hash H, under a key its DID publishes that a human actually signed the PDF (the SDK sees the attestation, not the wet signature); that the DID host is the real company
citizenAssertion unsupported WebAuthn (pass/fail/unavailable) the citizen's passkey signed a committed challenge: passkey possession that possession equals cell-level consent semantics (it does not prove which cells were consented)
disclosureCopy IPFS copy vs disclosureHash (pass/fail/skipped) same the public IPFS copy matches the committed disclosureHash that the IPFS gateway served the real copy (a pass only ties the fetched bytes to the hash)
documentAnchor on-chain commitment (pass/fail/skipped) same the document existed on-chain (keccak256(cid) logged in the registry) that on-chain existence equals a consent (anchoring proves the document, not any grant)
cellLabelBinding tracks companySignature (pass/fail/unavailable) unsupported offline the displayed category/purpose labels sit inside the signed body, so the verified signature covers them nothing more on an attested receipt; on a citizen receipt, do not assume the labels are accurate offline (use receipts/verify)

Best overall verdict per receipt type:

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

The honest limitation: companySignature on a citizen receipt

On a citizen receipt, companySignature is unsupported offline, and that is not an oversight. 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 the server receipts/verify endpoint, which does not exist yet. Do not assume it does. This is exactly why a citizen receipt caps at partial offline.

The SDK is the leaner verifier; the fuller model lives at verify.agreely.ca

Agreely::verifyReceipt is the leaner four-check verifier (company signature, citizen assertion, disclosure copy, document anchor) plus the derived cellLabelBinding verdict. Its only on-chain check is documentAnchor (the IdentityAnchored event: it proves the document existed, not that consent was given). The fuller client-side model (on-chain company-DID issuer authenticity, point-in-time key validation across key rotations, live-DNS re-check of the did:web domain binding, consent active/revoked lifecycle, and batch dossier verification) lives in the client-side verifier at verify.agreely.ca, not (yet) in the SDK. Do not assume the SDK does the richer model.

Reading the result

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

$v->receiptType;       // "company_attested" | "citizen"
$v->overall;           // "verified" | "partial" | "failed" | "unavailable"
$v->companySignature;  // "pass" | "fail" | "unavailable" | "skipped" | "unsupported" (a bare string)
$v->citizenAssertion;  // same CheckStatus string
$v->disclosureCopy;
$v->documentAnchor;
$v->cellLabelBinding;  // the field to read before trusting a displayed label
$v->notes;             // string[] of plain-language caveats (one flat array)

if ($v->overall === 'failed') {
    // a decisive check ACTIVELY failed: treat as tamper/forgery, reject.
} elseif ($v->overall === 'unavailable') {
    // inconclusive (a DID or RPC could not be reached), NOT a forgery: retry later.
} elseif ($v->overall === 'partial') {
    // a citizen receipt, verified offline as far as offline can go. Honest, not a failure.
}

Verify options

VerifyReceiptOptions (an associative array):

Option Default Role
resolver default did:web resolver inject your own DID resolver
companyDidHost agreely.ca apex host for the company did:web (serves /c/{slug}/did.json), used by resolveCompanyDid
citizenResolverBaseUrl https://api.agreely.ca citizen DID resolution base
verifyDisclosure true run the disclosure-copy cross-check
ipfsGateway https://gateway.lighthouse.storage/ipfs/ IPFS gateway (Lighthouse)
rpcUrl (none) required for the on-chain anchor check, else skipped
registryAddress 0x3532a9DfF3C910152543afCa49De7c66113C5312 AgreelyRegistry (Base Sepolia)
chainId 84532 (Base Sepolia) mainnet 8453 is null, not deployed
httpGet / httpPost default curl callables inject your own network seams

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). It can never yield a false verified (the key must still verify), but for untrusted receipts, inject your own resolver (or your httpGet / httpPost callables) or supply the DID documents locally, rather than letting the receipt steer an outbound request.

Next