Draftworks

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:

#FieldTypeMeaning
1vintegerReceipt format version. Currently 1.
2idstringReceipt/request id. Also in the x-draftworks-receipt-id header.
3endpointstringchat.completions or responses.
4modelstringModel id as reported by the upstream, unmodified (e.g. gpt-5.5-2026-04-24).
5createdintegerUnix timestamp (seconds) at signing.
6streambooleanWhether the response was streamed. Determines the response_sha256 definition.
7prompt_sha256stringHex SHA-256 of the raw request body bytes.
8response_sha256stringHex SHA-256 of the response; definition below.
9input_tokensintegerBilled input tokens (including cached).
10cached_input_tokensintegerSubset of input tokens billed at the cached rate.
11output_tokensintegerBilled output tokens (including reasoning tokens).
12total_tokensintegerinput_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 SSE data payload, each followed by a single \n (0x0A). The terminal [DONE] sentinel is excluded. SSE comments (including the receipt line itself) and event: lines are not part of the hash. Equivalently: for each data: <payload> line in order, append <payload> and \n to the hash input.

Where receipts appear

ContextTransport
Non-streamed responsex-draftworks-receipt header: base64url(payload) + "." + base64url(signature). x-draftworks-receipt-id carries the id separately.
Streamed responseAn 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 afterwardGET /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).

Fetch a receipt later
curl https://www.draftworks.dev/v1/receipts/chatcmpl-Dz46ISfIOZxiNscVZZWRlrZzoTuZl
Response
{
  "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_key field of any GET /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+.

verify-receipt.mjs
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:

stream-hash.mjs
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_sha256

Verifying: Python

Requires pynacl.

verify_receipt.py
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 model field 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.

On this page