§ blog · 2026-04-24

Anatomy of a Plumb receipt

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.

The shape

Here's a real receipt, as returned by GET /v1/receipts/:id:

GET /v1/receipts/c7f3... · json
{
"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.

What's signed

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:

canonical v1 — the exact bytes signed
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?

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.

Why hashes, not bodies

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.

Verifying one, three lines of Python

example · verify_receipt
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:

  1. Rebuild the canonical payload from the receipt fields (same format as above).
  2. Fetch the public key for rcpt.signer_key_id from /v1/verification/keys.
  3. Run 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:

What happens on-chain

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.

What you can build with this

Receipts compose. A few examples:

Where to go next

— Dustin · 2026-04-24