The problem receipts solve
Most crypto gateways hand you a JSON payload and a webhook HMAC, and that's the end of their attestation surface. If the dispute is "the payment happened, you're just not showing it in your dashboard anymore," you end up emailing a CSV and hoping. If the gateway ever goes out of business, every historical record becomes non-verifiable. Crypto is supposed to fix exactly this — payments are public on-chain, attestations shouldn't depend on the gateway's survival.
A signed receipt is a specific artifact: a payload describing the payment (amount, address, tx hash, timestamps, merchant id) plus an Ed25519 signature from the gateway. The merchant — or an auditor, or a lawyer in 2040 — can take the receipt, our public key, and verify the signature locally. No network call to us. No dependency on us still existing.
Why Ed25519
Short version: it's the right cryptographic default in 2026. Signatures are 64 bytes, public keys are 32 bytes, signing is deterministic, verification is fast, and the algorithm has held up well against post-quantum concerns for what we need (receipts don't need to be secret or forward-secret — just verifiable).
ECDSA-secp256k1 would have been fine. Ed25519 is cleaner to implement correctly (no ephemeral-nonce footgun), and Node's built-in crypto module supports PEM-encoded Ed25519 keys out of the box:
import { createPrivateKey, createPublicKey, sign } from "crypto";
const key = createPrivateKey(process.env.RECEIPT_SIGNING_KEY);
const signature = sign(null, Buffer.from(canonical), key); // 64 bytes
Canonical JSON is the hard part
The naive approach — sign(JSON.stringify(receipt)) — breaks the moment two parties disagree on key ordering or number formatting. We stabilize the payload with a sorted-keys stringifier that also normalizes number output. That's in src/services/receipts.ts; it's ~20 lines and it's the only place the canonical format is defined.
The payload itself is deliberately flat — one level of keys, no nested objects, no optional-sometimes-present fields. Hard to get wrong at verify time.
The 10-line verifier
Here's the entire receiver side, in Node.js:
import { createPublicKey, verify } from "crypto";
async function verifyReceipt(receipt) {
const pubPem = await (await fetch(
"https://orbchain.io/v1/common/receipt-public-key"
)).text();
const pubKey = createPublicKey(pubPem);
const canonical = canonicalize(receipt.body); // sorted-keys JSON
const sig = Buffer.from(receipt.signature, "base64");
return verify(null, Buffer.from(canonical), pubKey, sig);
}
Python is the same shape with cryptography; Go with crypto/ed25519; Rust with ed25519-dalek. The public key endpoint (/v1/common/receipt-public-key) is cacheable — the key rotates only on our say-so, and rotations publish a new endpoint version.
What we refused to ship
- Nested receipt payloads. Some designs include the full webhook body, the full address row, and a snapshot of merchant settings. Every additional field is a surface for disagreement between signer and verifier. Flat is safer.
- Custom signature envelopes (JWS, CMS). Both add encoder / decoder complexity that has no upside here. We sign raw canonical JSON; the signature travels as a sibling field, base64-encoded. That's it.
- Ephemeral keys. Receipts need to verify in 2040. Ephemeral keys would require us to maintain a key-registry service in 2040. We use a single long-lived key, with a documented rotation procedure.
When we sign
Receipts are produced on demand — we don't store them. When a merchant requests GET /v1/payment/:track_id/receipt, the current signing key signs the current canonical body and returns. This keeps the database simple (no receipt table), makes key rotation trivial (re-sign any future request with the new key), and means every verifier gets a fresh signature they can trust.
The receipts survive us. That's the whole point.