A receipt has one job: to be checked, by anyone, years from now, with code we did not write. That requirement shaped every decision in the format. This post walks through the envelope, the canonicalisation, the signature and the key set, and explains the choices behind each.
One envelope#
Receipts in this format share one envelope; only the body differs from one receipt type to the next. Signed-evidence records such as a Proof-of-Erasure use a sibling envelope, with subject and claims in place of body, under the same canonicalisation, signature and key set described below.
{
"version": "1",
"receiptId": "01K8QZ3M5V0X7R2N9T4W6YBCDE",
"issuedAt": "2026-10-24T14:03:21.482Z",
"type": "example.receipt",
"signatureKeyId": "7sXkP2nVdQ4yTb1LhWc9Ew",
"body": {
"type": "example.receipt",
"contentSha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"contentType": "text/plain",
"sizeBytes": 5
},
"signature": "base64url-ed25519-signature"
}The members, briefly:
| Member | What it is |
|---|---|
version | Always "1". The receipt format version, not an API version. |
receiptId | A 26-character ULID, sortable by creation time. |
issuedAt | ISO 8601 instant in UTC with milliseconds, set by Trustvice at signing. |
type | The receipt type. body.type always equals it. |
signatureKeyId | The kid of the signing key in the published key set: 22 characters of base64url. |
body | The type-specific facts. |
signature | The Ed25519 signature, base64url without padding: 86 characters. |
Two properties follow from how the signature is built, and both matter to anyone writing a verifier.
Every member except signature is signed. That includes any member a future version adds. A verifier must not drop members it does not recognise before checking, or valid receipts will fail. New optional fields are additive within version 1, so readers should ignore what they do not know, but keep it when verifying.
The receipt does not name your workspace or account. The workspace is the scope you list and fetch receipts under, but it is not written into the signed envelope. A receipt is a self-contained statement about a fact, not proof of who asked for it.
Receipt IDs are ULIDs#
A receipt ID is a ULID: 26 characters of Crockford base32. The first ten characters are a 48-bit millisecond timestamp and the remaining sixteen are random. IDs therefore sort by creation time.
The time inside the ID is minted a few milliseconds apart from issuedAt. Treat issuedAt as the signed time and the ID as an identifier.
Why canonical JSON#
A signature covers bytes, not meaning. JSON has many byte-level spellings of the same value: keys in any order, optional whitespace, 4 or 4.0, "é" or "\u00e9". If we signed the bytes we happened to send, a proxy that re-indents the response, or a library that reorders keys when you save it, would break a perfectly valid receipt.
So the signature covers a canonical form, defined by RFC 8785, the JSON Canonicalization Scheme (JCS). Every correct implementation turns the same parsed value into the same bytes. For a receipt, the rules come down to five:
- Keys are sorted at every level of nesting, by UTF-16 code unit.
- No whitespace between tokens.
- Numbers use the ECMAScript number-to-string form: integers print without a decimal point or exponent. Receipt bodies contain integers only.
- Strings use minimal JSON escaping; non-ASCII characters are emitted as raw UTF-8, never as
\uXXXX, and a forward slash is not escaped. - Arrays keep their order, and
nullandfalsevalues are kept, not dropped.
The signed message is the JCS form of the envelope with the signature member removed, encoded as UTF-8. On the signing side, values that cannot be represented identically everywhere, such as NaN, infinities, undefined or bigint, are rejected rather than coerced.
The practical consequence for you: you can pretty-print a receipt, store it in a database that reorders keys, or pass it through any JSON tool that preserves values, and it still verifies. What you cannot do is change a value, remove a null member, or round a number. Those are exactly the changes a verifier should catch.
Why Ed25519#
The signature is pure Ed25519 as specified in RFC 8032: no pre-hash and no context string. We chose it because it is small, fast, deterministic and implemented in the standard libraries people already have. Node 20 and later, current browsers, Deno, Bun and Cloudflare Workers can verify it with WebCrypto alone; Python can with the cryptography package; OpenSSL 3 can from the shell. A verifier for a Trustvice receipt should never need a Trustvice library.
A signature is 64 bytes, which is the 86 characters of base64url in the envelope. A public key is 32 bytes.
Trustvice holds the private keys in AWS KMS hardware security modules, one key per data region, and they never leave. The signer sends the canonical bytes to KMS and receives a signature back; it can sign but cannot export a key.
Key IDs are derived, not assigned#
The signatureKeyId in a receipt matches the kid of one key in the published set. That kid is not a label we chose. It is computed from the key:
kid = base64url( first 16 bytes of SHA-256( the 32 raw bytes of x ) )without padding, which gives 22 characters. Two useful consequences follow.
First, an entry in the key set is self-checking: anyone can recompute the kid from x, and an entry where they do not match is not a valid entry. In JavaScript:
const raw = Uint8Array.from(atob(x.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(x.length / 4) * 4, "=")), (c) => c.charCodeAt(0));
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw)).slice(0, 16);
const kid = btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");Second, because the signatureKeyId sits inside the signed bytes, a receipt cannot be re-pointed at a different key without invalidating it. And because the kid is derived from x, a changed x for a kid you already hold cannot be legitimate.
The published key set#
The public keys are a JSON Web Key Set at https://trustvice.com/.well-known/receipt-keys.json, which redirects with HTTP 308 to https://trustvice.com/api/v1/receipts/keys. It is served as JSON, cacheable for an hour, and with Access-Control-Allow-Origin: *, so a page on your own domain can fetch it and verify receipts in the browser.
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "…",
"kid": "…",
"alg": "EdDSA",
"use": "sig"
}
]
}To verify you need only kid, kty, crv and x. The set can hold several keys, and retired keys stay published. Receipts from one workspace can carry different key IDs over time, so always look the key up per receipt.
The set is built from published configuration, so verification never depends on a call to AWS. Only public fields ever appear in it.
Pin the keys you trust#
By default a verifier trusts whatever the key endpoint returns at the moment it asks, which is only as strong as that TLS connection. For evidence that matters, pin the set: download it once from a network you trust, recompute each kid from its x, store the pairs somewhere that changes only by review, and verify against that file. Refresh it on a schedule and review new keys before adding them.
Store the key set you used next to any receipt you keep as evidence. A receipt plus the key set it verified against is verifiable on its own.
Rotation, honestly#
A receipt names the key that signed it, so adding a key to the set never affects older receipts. Today there is no automatic rotation and keys carry no expiry. Rotation is a manual, out-of-band ceremony, and the set carries no retired or revoked marker. Rotation is additive: a retired key stays published so that receipts it signed remain verifiable. That is a policy, not something the key endpoint enforces, which is one more reason to keep your own pinned copy.
There is also no revocation list. If a signing key were ever compromised, the remedy would be to stop publishing it, which would make every receipt under it fail with unknown_key, genuine ones included. Holding your own copy of the key set from the time you accepted a receipt, and recording the receipt in a system you control, keeps your evidence usable regardless.
Where to go next#
- Receipts actions lists the Agentic API actions for receipts.
- Verify a receipt runs the same check in your browser, with nothing sent to us for the verdict.
- Verify a receipt offline builds a working verifier from that vector, step by step.