Verification
The signed receipt specification — payload format, hash definitions, key retrieval, and verification code.
Every response from Draftworks is accompanied by an Ed25519-signed receipt. This page is the normative specification.
Why receipts exist
An API reseller sits between you and the model. Nothing structurally prevents a reseller from quietly routing your traffic to a cheaper model, truncating context, or inflating token counts — and from the response alone, you often could not tell. Receipts remove the trust assumption: for every request, Draftworks signs a statement binding the request bytes, the response bytes, the model id the upstream reported, and the token counts you were billed. If we ever served you something other than what the receipt says, the receipt is the evidence — signed by us and verifiable by anyone.
What is signed
The signed message is a JSON payload with exactly these fields, in exactly this order:
| # | Field | Type | Meaning |
|---|---|---|---|
| 1 | v | integer | Receipt format version. Currently 1. |
| 2 | id | string | Receipt/request id. Also in the x-draftworks-receipt-id header. |
| 3 | endpoint | string | chat.completions or responses. |
| 4 | model | string | Model id as reported by the upstream, unmodified (e.g. gpt-5.5-2026-04-24). |
| 5 | created | integer | Unix timestamp (seconds) at signing. |
| 6 | stream | boolean | Whether the response was streamed. Determines the response_sha256 definition. |
| 7 | prompt_sha256 | string | Hex SHA-256 of the raw request body bytes. |
| 8 | response_sha256 | string | Hex SHA-256 of the response; definition below. |
| 9 | input_tokens | integer | Billed input tokens (including cached). |
| 10 | cached_input_tokens | integer | Subset of input tokens billed at the cached rate. |
| 11 | output_tokens | integer | Billed output tokens (including reasoning tokens). |
| 12 | total_tokens | integer | input_tokens + output_tokens. |
The signature is computed over the exact serialized payload bytes as transmitted. Verify against those bytes directly — do not re-serialize the JSON, since key order and whitespace would not survive a round-trip through most JSON libraries.
Hash definitions
prompt_sha256— SHA-256 of the raw HTTP request body bytes, exactly as you sent them. Byte-identical retransmission is required to reproduce it: whitespace, key order, and encoding all matter. If you need to re-derive this later, persist the outgoing body bytes at send time.response_sha256, non-streamed (stream: false) — SHA-256 of the raw HTTP response body bytes.response_sha256, streamed (stream: true) — SHA-256 of the concatenation of every SSEdatapayload, each followed by a single\n(0x0A). The terminal[DONE]sentinel is excluded. SSE comments (including the receipt line itself) andevent:lines are not part of the hash. Equivalently: for eachdata: <payload>line in order, append<payload>and\nto the hash input.
Where receipts appear
| Context | Transport |
|---|---|
| Non-streamed response | x-draftworks-receipt header: base64url(payload) + "." + base64url(signature). x-draftworks-receipt-id carries the id separately. |
| Streamed response | An SSE comment injected just before the stream ends: : draftworks-receipt <base64url-payload>.<base64url-signature>. Spec-compliant SSE parsers ignore comment lines — see streaming. No x-draftworks-receipt or x-draftworks-receipt-id header is set on streamed responses; use the request id from the response body to fetch the receipt later. |
| Any time afterward | GET /v1/receipts/{id} — public, no authentication. Receipts are retained indefinitely. |
Receipts are addressed by the request id — the same id you already have from the response body (chatcmpl-… for Chat Completions, resp_… for Responses).
curl https://www.draftworks.dev/v1/receipts/chatcmpl-Dz46ISfIOZxiNscVZZWRlrZzoTuZl{
"object": "receipt",
"id": "chatcmpl-Dz46ISfIOZxiNscVZZWRlrZzoTuZl",
"created": 1783446144,
"endpoint": "chat.completions",
"model": "gpt-5.5-2026-04-24",
"stream": true,
"prompt_sha256": "c308e2663b36ac1b0280dc1c483a5715303e5b9f1cea2f307e166d2eed1b5cd5",
"response_sha256": "7b63d1528ec3b998890d967b8d5abdc949d8ce48f36d9ffe9cb0f6d7816647f5",
"input_tokens": 13,
"cached_input_tokens": 0,
"output_tokens": 48,
"payload": "{\"v\":1,\"id\":\"chatcmpl-Dz46ISfIOZxiNscVZZWRlrZzoTuZl\",\"endpoint\":\"chat.completions\",\"model\":\"gpt-5.5-2026-04-24\",\"created\":1783446142,\"stream\":true,\"prompt_sha256\":\"c308…5cd5\",\"response_sha256\":\"7b63…47f5\",\"input_tokens\":13,\"cached_input_tokens\":0,\"output_tokens\":48,\"total_tokens\":61}",
"signature": "yBxI5bZUJVK7U6e3KNYskb1SwjwL21AQNUsrEoDzwDIQg9NDK0cfLwRicsDqV5A5q5k74WqQrbnPC6ilXxDFDA",
"public_key": "JSl3dcdGcMcr0A92fXtJEdIRLYbJ9yHp-AQUixzqJO8",
"algorithm": "Ed25519"
}Two forms, one signature: the header/SSE form is base64url(payload) + "." + base64url(signature); the GET endpoint returns the same payload as a raw JSON string (the exact signed bytes — do not re-serialize it) alongside the base64url signature. The endpoint field is chat.completions or responses; model is the string the upstream reported, passed through unmodified.
The public key
Receipts are signed with a single Ed25519 key. The public key is published as the raw 32-byte key, base64url-encoded, and is available from two places:
- The
public_keyfield of anyGET /v1/receipts/{id}response, as above. - The /verify page, which also verifies receipts in the browser.
Pin the key in your own configuration rather than fetching it per verification — a verifier that fetches the key from the same party it is auditing proves less. Key rotation, should it ever occur, will be announced with an overlap period and versioned via the v field.
Verifying: Node.js
No dependencies; Node 18+.
import crypto from "node:crypto";
// Raw 32-byte Ed25519 public key, base64url. Pin this value.
const PUBLIC_KEY = "JSl3dcdGcMcr0A92fXtJEdIRLYbJ9yHp-AQUixzqJO8";
const publicKey = crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: PUBLIC_KEY },
format: "jwk",
});
/**
* @param receipt "<base64url-payload>.<base64url-signature>" — from the
* x-draftworks-receipt header or the SSE comment.
*/
function verifyReceipt(receipt) {
const [payloadB64, sigB64] = receipt.split(".");
const payloadBytes = Buffer.from(payloadB64, "base64url");
const signature = Buffer.from(sigB64, "base64url");
const ok = crypto.verify(null, payloadBytes, publicKey, signature);
if (!ok) throw new Error("signature verification failed");
return JSON.parse(payloadBytes.toString("utf8"));
}
/** GET /v1/receipts/{id} returns the two parts separately. */
function verifyFetchedReceipt(r) {
const ok = crypto.verify(
null,
Buffer.from(r.payload, "utf8"), // the exact signed bytes — never re-serialize
publicKey,
Buffer.from(r.signature, "base64url"),
);
if (!ok) throw new Error("signature verification failed");
return JSON.parse(r.payload);
}
// Example: verify, then check the response hash against the bytes you received.
const payload = verifyReceipt(process.argv[2]);
console.log("signature valid for", payload.model, "request", payload.id);
// For a non-streamed response you saved to disk:
// const body = fs.readFileSync("response.json");
// const hash = crypto.createHash("sha256").update(body).digest("hex");
// console.log("response hash matches:", hash === payload.response_sha256);For a streamed response, reconstruct the hash input from your captured SSE data payloads:
const hash = crypto.createHash("sha256");
for (const dataPayload of capturedDataPayloads) {
if (dataPayload === "[DONE]") continue;
hash.update(dataPayload, "utf8");
hash.update("\n");
}
console.log(hash.digest("hex")); // compare to payload.response_sha256Verifying: Python
Requires pynacl.
import base64
import hashlib
import json
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
PUBLIC_KEY = "JSl3dcdGcMcr0A92fXtJEdIRLYbJ9yHp-AQUixzqJO8" # raw 32-byte key, base64url. Pin it.
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def verify_receipt(receipt: str) -> dict:
payload_b64, sig_b64 = receipt.split(".")
payload_bytes = b64url_decode(payload_b64)
signature = b64url_decode(sig_b64)
verify_key = VerifyKey(b64url_decode(PUBLIC_KEY))
try:
verify_key.verify(payload_bytes, signature)
except BadSignatureError as e:
raise SystemExit("signature verification failed") from e
return json.loads(payload_bytes)
payload = verify_receipt(receipt_string)
print("signature valid for", payload["model"], "request", payload["id"])
# Check the request hash against the exact bytes you sent:
# assert hashlib.sha256(request_body_bytes).hexdigest() == payload["prompt_sha256"]What a receipt proves — and what it does not
Honest limits, stated plainly:
- A valid receipt proves that the Draftworks gateway signed this exact statement for this request id: these request bytes went in, these response bytes came out, this model id was reported, these token counts were billed. Combined with your own copies of the request and response bytes, it proves we cannot later dispute or alter any of those facts.
- The
modelfield is passed through from Azure unmodified — the gateway never rewrites it. The receipt therefore proves what the upstream claimed to be, on the record and signed. It does not, by itself, prove what weights Azure actually executed; no reseller can prove that cryptographically today. - A receipt does not attest to response quality, only to integrity and attribution of the bytes and counts.
Because the claims are on the record, they are cheap to audit. Recommended independent spot-checks: run identical prompts against Azure OpenAI directly and compare the model field, system_fingerprint, and the latency profile (usage.latency_checkpoint — see chat completions). A reseller substituting models fails these checks quickly, and with receipts, provably.