Every API response from the Plumb gateway comes back with a signed receipt. This post is a tour of what's in one, why each field matters, and how you verify one yourself.
Here's a real receipt, as returned by GET /v1/receipts/:id:
{
"id": "c7f3e0a4-5b82-4a1c-9c8e-1d2e3f4a5b6c",
"addr": "0xdc9f523664d90056b014560e68a8108c8e16694e",
"requestHash": "0x7a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"responseHash": "0xabcdef01234567899876543210fedcbaabcdef01234567899876543210fedcba",
"model": "anthropic/claude-sonnet-4.5",
"promptTokens": 412,
"completionTokens": 1823,
"costMicro": "482000",
"issuedAt": "2026-04-22T18:30:15.120Z",
"signatureHex": "3045022100...",
"keyId": "srv-2026-04-prod",
"verificationMode": "vanilla",
"settledOnchainAt": "2026-04-22T18:32:02.000Z",
"settlementTxHash": "0x8f9c..."
}Ten-ish fields. Four of them are on the signed surface. The rest are metadata.
The bytes that actually ran through ed25519.sign are not this JSON. They're a fixed, newline-separated string we call the canonical v1 payload:
v1 request_hash=0x7a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9 response_hash=0xabcdef01234567899876543210fedcbaabcdef01234567899876543210fedcba model=anthropic/claude-sonnet-4.5 cost_micro=482000 issued_at=2026-04-22T18:30:15.120Z
That's it. Six lines, no trailing newline, no JSON, no whitespace sensitivity beyond newlines. The spec is frozen by the v1 prefix; a future format bumps the prefix and verifiers dispatch.
Why these five fields?
request_hash — sha256 of the canonical-JSON of your request body. Commits the signer to a specific prompt shape without retaining the prompt text.response_hash — same for the response body. Commits to what was served.model — the fully-qualified provider/model id. anthropic/claude-sonnet-4.5 is a different signed byte string than claude-sonnet-4.5.cost_micro — bigint as a plain decimal string. 482000 μPLMB = 0.482 PLMB.issued_at — millisecond-precision UTC. Order matters in the signed surface even if you don't care about wall-clock time.What's not signed: the receipt UUID, the caller address, token counts, and the settlement tx hash. Those are metadata for querying and display. The signed commitment is the request-response-cost-time tuple; everything else is bookkeeping.
The obvious alternative — sign the full request and response — is wrong for two reasons.
First, prompt text can be huge. A long code-review prompt might be 50kb; a generated completion is often similar. Signing raw bytes means long-tail receipts cost real CPU. Hashing reduces the signed surface to a fixed 32 bytes per side regardless of the underlying size.
Second, hashing keeps your prompts out of the signed commitment. The gateway doesn't have to retain your prompt forever to keep the receipt verifiable. You retain it yourself; when you want to prove the response came from a specific request, you hash your copy and check that the resulting request_hash matches what the signature committed to. The gateway doesn't have to cooperate with the proof.
from plumb_sdk import Client
c = Client(base_url="https://api.plumbtech.xyz")
rcpt = c.receipts.get("c7f3e0a4-5b82-4a1c-9c8e-1d2e3f4a5b6c")
assert c.verify_receipt(rcpt), "signature did not match"Under the hood:
rcpt.signer_key_id from /v1/verification/keys.ed25519.verify(signature, payload_bytes, pubkey_bytes).If the math holds, the receipt came from a signer key the operator publishes. That's strong for what Plumb claims: this response came from this signer at this cost at this time.
Three things it doesn't prove:
id./v1/verification/keys publishes historical keys with validity windows; pinning keys or fingerprints at the client is the current mitigation.The receipt is eventually Merkle-batched and the root is committed to Base Sepolia. Every ~2 minutes the Plumb worker sweeps unsettled rows, builds an OpenZeppelin StandardMerkleTree of (id, requestHash, responseHash, costMicro) leaves, and calls Settlement.checkpointReceipts(root, count, totalCost, sig) on Base. Each receipt in the batch gets stamped with its settlement_tx_hash.
You can verify the on-chain commitment independently of the SDK: pull the ReceiptsCheckpointed event for your settlement_tx_hash from any RPC, compare the batchRoot field to what the SDK returns. Matching bytes = the receipt is committed to a specific root on-chain, which is committed to a specific block, which means the operator can't retroactively edit it without a reorg.
Receipts compose. A few examples: