Receipt verifier
Verify a signed Axiom receipt without an Axiom login: see each signer, which fields are disclosed and whether they match their commitments, what is missing, how fresh the key status is, and which phases the receipt proves: authorized, approved, dispatched and destination confirmed. Verification is local computation; the receipt is never uploaded.
Three ways to verify, all built on the same verifyReceiptBundle in @axiom/core:
| Where | How |
|---|---|
| Browser | https://sectigoaxiom.com/tools/verify-receipt: drop, choose or paste a bundle, or pick a reference fixture. |
| CLI | axiom verify receipt.json (see CLI reference). |
| TypeScript | verifyReceipt from @sectigo/axiom-sdk/verify. |
Field-by-field formats are in Receipt and event formats; the concepts are in Receipts and verification.
Get a receipt
An agent downloads its own receipts; people with evidence.read on the project or tenant can too:
axiom receipt act_01… --out receipt.json
axiom receipt act_01… --reveal operation,params.amount,params.currency --out redacted.json--reveal (API: GET /v1/actions/:id/receipt?reveal=…) produces a redacted export that discloses only the named fields. Hidden fields stay committed, so the export still proves what it shows.
Verify with the CLI
axiom verify receipt.json
axiom verify receipt.json --issuer-jwks https://api.sectigoaxiom.com/.well-known/jwks.json \
--provider-jwks https://sample.sectigoaxiom.com/.well-known/axiom/outcome-keys.json \
--trusted-issuer https://api.sectigoaxiom.com| Flag | Effect |
|---|---|
| (none) | Offline: uses the key snapshot embedded in the bundle (keySource: bundle_snapshot). |
--issuer-jwks URL | Fetches Axiom's keys instead; a signature by a key now marked revoked makes the receipt not valid. |
--provider-jwks URL | Fetches the provider's outcome keys instead of trusting the snapshot. |
--trusted-issuer ISS | Fails unless the receipt's issuer is listed (repeatable). |
--json | Prints { valid, key_source, report }. |
The command exits non-zero when the receipt is not valid. Sample output:
Receipt rcp_01…: VALID
Action act_01… (billing.refund.create on invoice 417)
Issuer https://api.sectigoaxiom.com, issued 2026-11-14T18:20:06.000Z
Agent agt_01… (credential jwk 3q2…)
Tenant ten_01…
Outcome succeeded · re_01…
Phases authorized yes · approved yes · dispatched yes · destination confirmed yes
Keys fetched
Signer observation key …: valid, key active
Signer provider outcome key …: valid
Checks
PASS Axiom observation signature - 1 signature(s) verified
PASS Issuer - https://api.sectigoaxiom.com
PASS Disclosed fields match commitments - all fields disclosed
PASS Request digest - sha-256:…
PASS Approval by Finance manager (axiom) - approved, verified via axiom_session
PASS Provider outcome attestation - succeeded · re_01…Verify in TypeScript
import { readFile } from "node:fs/promises";
import { verifyReceipt } from "@sectigo/axiom-sdk/verify";
const bundle = JSON.parse(await readFile("receipt.json", "utf8"));
const v = await verifyReceipt(bundle, {
issuerJwksUrl: "https://api.sectigoaxiom.com/.well-known/jwks.json",
trustedIssuers: ["https://api.sectigoaxiom.com"],
});
console.log(v.valid, v.keySource, v.report.phases);
console.log(v.text);Options: issuerJwksUrl, providerJwksUrl, pinned issuerKeys / providerKeys (used in preference to fetched keys), trustedIssuers and fetch. The result is { valid, report, keySource, fetchedKeyStatus, text }.
The checks
Check id | pass | Other statuses |
|---|---|---|
signature | Every Axiom signature on the receipt verifies. | fail with each signer's error. |
version | (only reported on failure) | fail when v is not axiom.receipt.v1. |
issuer | Issuer shown (and listed, with trustedIssuers). | fail when not in the trusted list. |
disclosures | Every field disclosed and matching its commitment. | warn when some fields are redacted (named in detail); fail when a disclosed value does not match. |
request_digest | Recomputed digest equals request_digest. | missing when not every field is disclosed (commitments verified instead); fail on mismatch. |
approval:{approval_id}:{approver} | Approved, bound to the same request digest. | fail when the approval digest differs or the decision is denied. |
approval | (only reported when missing) | missing when policy required approval and none is recorded. |
outcome | Provider attestation verifies and is bound to this action. | fail for a bad signature, a wrong typ or an attestation for another action; missing when only the gateway observed an HTTP status, or no outcome is recorded. |
A receipt is valid when no check is fail. missing and warn are not failures: they say what the receipt does not prove. Phases are reported separately: dispatched is true when a dispatch is recorded, and destination_confirmed only when a valid provider attestation is present, never from an observed HTTP 2xx alone.
Trust: pin the keys
The embedded key snapshot is not a trust anchor
A bundle carries the public keys that signed it so it can be checked offline. Anyone can build an internally consistent bundle with keys of their own, so offline verification against the snapshot proves the receipt was not altered, not who signed it. For a trust decision, pin Axiom's issuer keys (fetch https://api.sectigoaxiom.com/.well-known/jwks.json or configure them), pin the provider's outcome keys, and require the issuer with trustedIssuers.
The two signer roles are kept apart even with the snapshot: exported bundles carry the provider's outcome keys separately in provider_keys, and the reference verifier never accepts an outcome attestation signed by a key that signed the receipt or by a pinned issuer key. An Axiom key therefore cannot pass as the provider. Pinning still matters for who the keys belong to.
Build an independent verifier
The format is small enough to implement in any language with SHA-256, base64url and ES256/EdDSA. Implement these steps, then test against the reference fixtures.
1. Parse the bundle
An axiom.receipt.bundle.v1 is JSON:
{
"type": "axiom.receipt.bundle.v1",
"receipt": { "payload": "<base64url JCS claims>", "signatures": [{ "protected": "<base64url header>", "signature": "<base64url>" }] },
"disclosures": { "params.amount": { "salt": "…", "value": "25000" } },
"keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "…", "x": "…" }],
"provider_keys": [{ "kty": "EC", "crv": "P-256", "kid": "…", "x": "…", "y": "…" }],
"status": { "checked_at": "…", "source": "https://…/.well-known/jwks.json", "keys": { "kid": "active" } }
}2. Verify the receipt signatures
receipt is a JWS JSON General Serialization (RFC 7515 §7.2.1). For each signature, decode protected (it carries alg, kid, typ: axiom-receipt+jws and role: observation), resolve the key by kid from your pinned issuer keys, and verify over the ASCII bytes protected + "." + payload. ES256 signatures are raw r||s (64 bytes), not DER. Accept only ES256 and EdDSA. The payload decodes to the receipt claims (v: axiom.receipt.v1), which were serialised with RFC 8785 JCS before encoding.
3. Check disclosures against commitments
claims.commitments maps each canonical field name (tenant, agent, audience, operation, resource, not_after, optional root_action, and params.{name} for each material field) to a commitment:
commitment = "sha-256:" + base64url( SHA-256( salt + "." + JCS(value) ) )For every entry in disclosures, recompute and compare. A disclosure without a matching commitment, or with a different value, fails. Fields with a commitment and no disclosure are redacted.
4. Recompute the request digest (full disclosure only)
When every committed field is disclosed, rebuild the canonical action and hash it:
action = { "v": "axiom.action.v1", tenant, agent, audience, operation, resource,
params: { …each params.* value… }, not_after, [root_action] }
request_digest = "sha-256:" + base64url( SHA-256( JCS(action) ) )It must equal claims.request_digest. With redacted fields, report the digest as not recomputable and rely on the commitments.
5. Check approvals
Each entry in claims.approvals[] must have decision: "approved" and digest equal to claims.request_digest: the approver decided on exactly this request. If claims.policy.decision is require_approval and there are no approvals, report the approval as missing. source.verified_via says how Axiom observed the decision (axiom_session, connector_readback, signed_callback); Axiom signs that observation, the native system did not.
6. Verify the provider outcome attestation
claims.outcome.attestation, when present, is a compact JWS (typ: axiom-outcome+jws) of an axiom.outcome.v1 document, signed by the provider's outcome key. Require the header typ to be axiom-outcome+jws. Resolve the key from your pinned provider keys, or else from the bundle's provider_keys (falling back to keys for bundles without it), and exclude every key ID that signed the receipt or that you pinned as an issuer key. Then require action_id, request_digest and operation_key to equal the receipt's. Only then is the destination confirmed. claims.outcome.observed (an HTTP status the gateway saw) is never confirmation.
7. Report phases
authorized and approved come from claims.phases; dispatched from the presence of claims.dispatch; destination_confirmed from step 6.
Test against the reference fixtures
GET https://api.sectigoaxiom.com/v1/public/fixtures returns a deterministic axiom.fixtures.v1 set signed with published test keys (issuer https://fixtures.sectigoaxiom.com, kids ending in NOT-FOR-PRODUCTION; never trust them in production). It contains keys.issuer, keys.provider (the keys to pin), test_private_keys (so you can produce your own variants) and four fixtures, each with the result a conforming verifier must report. The fixture bundles carry both test keys in keys and no provider_keys, so they also exercise the fallback and the issuer-key exclusion in step 6:
| Fixture | Scenario | Expected |
|---|---|---|
valid | Full disclosure, finance approval, provider-signed outcome. | valid; signature, disclosures, request_digest, outcome all pass; all four phases true. |
redacted | Only operation, params.amount, params.currency disclosed. | valid; disclosures warn, request_digest missing, outcome pass. |
tampered | The disclosed amount was changed to 2500000 after signing. | not valid; signature pass, disclosures fail. |
forged-outcome | The outcome attestation is signed by a key that is not the provider's. | not valid; signature pass, outcome fail; destination_confirmed false. |
curl -sS https://api.sectigoaxiom.com/v1/public/fixtures | jq '.fixtures[] | {name, expected}'
curl -sS https://api.sectigoaxiom.com/v1/public/fixtures | jq '.fixtures[] | select(.name=="valid") | .bundle' > valid.json
axiom verify valid.jsonThe fixed salts and Ed25519 keys make the bytes identical across deployments, so you can commit them to your own test suite. The server checks the reference verifier against every expectation before it publishes the set.
The agent-facing conformance fixtures (one status document per action state) are a different set; see Contract and fixtures.