Specification
Verify a payment receipt yourself
Everything needed to check a CertifiedData Agent Commerce receipt without asking us whether it is valid: the exact bytes that get signed, a verifier short enough to read in full, and test vectors — including a tampered one, so you can confirm your implementation fails when it should.
Read this before you trust anything below
This code came from us. Downloading a verifier from certifieddata.io and running it against certifieddata.io still trusts certifieddata.io — it is the self-attestation problem moved one step out, not solved.
What makes it worth anything is that the verifier is about ninety lines with no dependencies, so you can read the whole thing. Do that, or pull it from GitHub, or ignore our code entirely and write your own from the specification below. All three are better than taking our word for it.
Verify a receipt right here
Paste a receipt ID, or the JSON from /api/payments/verify/<id>. The canonical payload is rebuilt, hashed, and checked against the Ed25519 signature using the published key — all in your browser. The only network calls are the two unauthenticated GETs the specification names, and no response of ours is treated as the verdict.
Or from a terminal
No install, no account, no registry:
npx github:certifieddata/verify 2492a060-8fbc-40ae-beab-7258aefb0608 --type receipt✓ VALID receipt 2492a060-8fbc-40ae-beab-7258aefb0608
signature pass
payload hash pass
settlement succeeded_live
The verdict above was computed locally — not taken from the server.The github: form is deliberate — the package is not on the npm registry yet, so this installs from source and compiles on the way in.
What you need, and what you must ignore
Verification takes two unauthenticated inputs. Neither requires an account or a relationship with us.
| Input | Source | Trust required |
|---|---|---|
| Receipt envelope | GET /api/payments/verify/{id} | None — tampering is what the signature detects |
| Ed25519 public key | /.well-known/certifieddata-public-key.pem | That this key is ours |
The endpoint also returns valid, hashValid and signatureValid. These are not evidence. They are our opinion about our own signature. Compute your own verdict and use ours only as a cross-check — if the two disagree, one of us has a bug.
Which bytes are signed
The signed object is the envelope’s receipt field, exactly as returned, with nothing added or removed. Three fields are commonly mistaken for part of it:
signature— sits at the envelope level, not insidereceipt. It cannot be part of the payload it signs.sha256_hashanded25519_sig— appended byPOST /v1/transactions/{id}/captureto its inline receipt object as a convenience. They are not part of the canonical payload. Strip them if you are verifying a capture response; the verify endpoint does not include them.
That distinction was undocumented until August 2026, and getting it wrong is the most likely reason a correct-looking implementation fails to reproduce the hash.
Canonicalization: RFC 8785 (JCS)
Serialize the canonical payload per RFC 8785, the JSON Canonicalization Scheme, then encode UTF-8.
It is JCS — not json-stable-stringify. The two agree on key ordering for simple documents and disagree on string escaping and number formatting, so they can produce different bytes for the same document, and therefore different hashes.
| Object keys | Sorted ascending by UTF-16 code unit sequence. |
| Arrays | Order preserved exactly as received. |
| Whitespace | None. No spaces after separators, no trailing newline. |
| Strings | Minimal RFC 8259 §7 escapes (", \, \b, \f, \n, \r, \t) plus \u00XX for other control characters U+0000–U+001F. Non-ASCII is emitted literally, never \u-escaped. |
| Numbers | ECMAScript Number::toString — what JSON.stringify already emits for finite numbers. NaN and ±Infinity must never appear. |
The two checks
With C as those canonical bytes:
- Hash.
sha256(C), hex, prefixedsha256:, must equalstoredReceiptHash. - Signature. Base64-decode
signature— exactly 64 bytes — and verify it as Ed25519 overCdirectly. Do not pre-hash; Ed25519 hashes internally, and passing the digest will fail.
Reference implementation
Complete and dependency-free. This is the whole thing — read it rather than trusting it.
// Independent CertifiedData receipt verification.
// Node 20+. No dependencies. Nothing here trusts certifieddata.io's opinion.
const BASE = "https://certifieddata.io";
const id = process.argv[2];
// RFC 8785 (JCS): keys sorted by UTF-16 code unit, arrays in order,
// no insignificant whitespace.
function jcs(v) {
if (v === null || typeof v === "boolean" || typeof v === "number") {
return JSON.stringify(v);
}
if (typeof v === "string") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(jcs).join(",") + "]";
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + jcs(v[k])).join(",") + "}";
}
// 1. Fetch the receipt envelope.
const env = await (await fetch(`${BASE}/api/payments/verify/${id}`)).json();
// 2. The signed payload is env.receipt exactly as returned. The signature sits
// at the ENVELOPE level, so it cannot be inside what it signs.
const bytes = Buffer.from(jcs(env.receipt), "utf8");
// 3. Recompute the hash.
const digest = await crypto.subtle.digest("SHA-256", bytes);
const hash = "sha256:" + Buffer.from(digest).toString("hex");
const hashOk = hash === env.storedReceiptHash;
// 4. Verify the signature with the PUBLISHED key — not the server's verdict.
const pem = (await (await fetch(`${BASE}/.well-known/certifieddata-public-key.pem`)).text()).trim();
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64");
const key = await crypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
const sigOk = await crypto.subtle.verify(
"Ed25519", key, Buffer.from(env.signature, "base64"), bytes,
);
console.log("hash ", hashOk ? "pass" : "FAIL");
console.log("signature ", sigOk ? "pass" : "FAIL");
console.log(hashOk && sigOk ? "VALID" : "INVALID");
Using JSON.stringify for strings is JCS-correct for the ASCII content receipts carry today. For a fully general implementation see src/canonicalize.ts, which is written by hand so a reviewer can confirm there is no surprising behavior.
Test vectors
Three fixtures in the repository. Run your implementation against all three.
| Fixture | Must return | Why |
|---|---|---|
valid-receipt.json | VALID | Captured from production. Both checks pass. |
tampered-receipt.json | INVALID | amount altered, signature left byte-identical. This is the vector that matters: an implementation reporting VALID here is not verifying anything. |
malformed-receipt.json | MALFORMED | The signature is not a 64-byte Ed25519 value. Distinct from INVALID — the artifact is unusable, not merely wrong. |
You do not need to clone anything — pipe a fixture straight in:
curl -s https://raw.githubusercontent.com/certifieddata/verify/main/fixtures/tampered-receipt.json \
| npx github:certifieddata/verify - --type receipt
# → ✗ INVALID ed25519 signature does not verify against the RFC 8785 canonical payload
#
# If you get VALID here, your verifier is not verifying.Worked example
A real $0.99 certificate-linked dataset purchase on the live rail, captured 20 August 2026. Every value below is reproducible right now.
| receipt_id | 2492a060-8fbc-40ae-beab-7258aefb0608 |
| signing key | ed25519-prod-2025-02 |
| canonicalization | RFC8785-JCS |
| storedReceiptHash | sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22 |
| settlement_state | succeeded_live |
| artifact_hash | sha256:bd48985485c9a3e19838e29795bb89ddedd7f7e5c706b57c190cc6c46119a660 |
| certificate_id | fb914a90-b1b3-4355-8147-cc0194160e23 |
The artifact_hash is the SHA-256 of the delivered ZIP and also the digest inside certificate fb914a90-…. A buyer can chain downloaded bytes → hash → certificate → receipt without any step requiring our word for it.
Note the key id: ed25519-prod-2025-02. Some older documentation shows cd_root_2026, which is not what live receipts are signed with.
What a valid receipt does and does not prove
Proves. That a specific agent was authorized under a named policy (policy_hash, policy_version) to spend a specific amount on a specific rail; that the charge reached a terminal settlement state (settlement_state, settled_at, external_payment_intent_id, external_charge_id); and, when artifact_hash is present, precisely which artifact the payment was for.
Does not prove. That the artifact was delivered, or that the buyer received it. Delivery is a separate record.
integrity_notes. When present, we are stating that a binding the receipt would normally carry is absent, and why. Its absence means the pre-signature check found nothing to declare — not that no check ran. Receipts signed before that check existed carry neither the notes nor the bindings, and are annotated by separate append-only records rather than edited. Receipts are immutable; corrections are new records.