CertifiedData.io

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.

InputSourceTrust required
Receipt envelopeGET /api/payments/verify/{id}None — tampering is what the signature detects
Ed25519 public key/.well-known/certifieddata-public-key.pemThat 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 inside receipt. It cannot be part of the payload it signs.
  • sha256_hash and ed25519_sig — appended by POST /v1/transactions/{id}/capture to 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 keysSorted ascending by UTF-16 code unit sequence.
ArraysOrder preserved exactly as received.
WhitespaceNone. No spaces after separators, no trailing newline.
StringsMinimal 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.
NumbersECMAScript 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, prefixed sha256:, must equal storedReceiptHash.
  • Signature. Base64-decode signature — exactly 64 bytes — and verify it as Ed25519 over C directly. 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.

FixtureMust returnWhy
valid-receipt.jsonVALIDCaptured from production. Both checks pass.
tampered-receipt.jsonINVALIDamount altered, signature left byte-identical. This is the vector that matters: an implementation reporting VALID here is not verifying anything.
malformed-receipt.jsonMALFORMEDThe 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_id2492a060-8fbc-40ae-beab-7258aefb0608
signing keyed25519-prod-2025-02
canonicalizationRFC8785-JCS
storedReceiptHashsha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22
settlement_statesucceeded_live
artifact_hashsha256:bd48985485c9a3e19838e29795bb89ddedd7f7e5c706b57c190cc6c46119a660
certificate_idfb914a90-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.

Verify a Payment Receipt Yourself — CertifiedData | CertifiedData