Raw bytes
The silent failure mode of every webhook integration. Why you must never JSON.stringify(JSON.parse(body)).
A webhook signature is computed over the exact bytes the producer sent. The string the receiver checks against is:
${webhook-id}.${webhook-timestamp}.${body-bytes}If anything between the network socket and verify changes those bytes — even by a single whitespace character — the signature will not match. The receiver will return SignatureInvalid and you'll spend half a day wondering why a perfectly correct producer keeps getting rejected.
The most common cause: middleware that parses JSON, hands you a typed object, and silently throws away the raw bytes.
The trap
This is broken:
// DON'T do this.
app.post("/webhooks", async (req, res) => {
const body = req.body; // parsed JSON object
const reserialized = JSON.stringify(body); // re-serialize as bytes
await verify(new TextEncoder().encode(reserialized), req.headers, secret);
});JSON.stringify(JSON.parse(x)) !== x for almost every non-trivial JSON. Whitespace, key ordering, number formatting (1.0 vs 1), trailing newlines — any of these differ between the producer's serializer and Node's, and the HMAC over them differs.
The fix
Every framework adapter Postel ships preserves the raw request body and passes it to verify byte-for-byte. The contract:
- Express, Fastify, Koa, Hono, Elysia — middleware buffers the raw
reqstream before any JSON parser sees it. The buffered bytes are whatverifyoperates on. - Bun.serve, Deno.serve, Cloudflare Workers, Vercel Edge, Next.js Route Handlers, SvelteKit, Astro, Nitro — the
Requestobject already exposes the raw bytes viaawait req.arrayBuffer()before any.json()call. Adapters use that.
If you're calling verify directly (no framework adapter), the rule is:
const body = new Uint8Array(await req.arrayBuffer()); // raw bytes
// const body = await req.json(); // WRONG — parsed
// const body = JSON.stringify(await req.json()); // WRONG — re-serialized
await verify(body, headers, secret);req.arrayBuffer() is destructive — you cannot call req.json() after it on the same Request. That's fine: verify parses the body once the signature checks out, and the parsed event is returned in result.event.
const { event } = await verify(body, headers, secret);
console.log(event.type, event.data); // parsed for you, signature already verifiedThe detector
Some adapters can detect re-serialization at runtime. If a framework adapter receives a Buffer or Uint8Array that looks like it was JSON-re-encoded (specific whitespace patterns, key reordering, number reformatting), it throws RawBytesMismatchDetected instead of silently producing SignatureInvalid. The error message names the suspected mutation — much faster to diagnose than a generic "bad signature."
This is best-effort: it catches the common cases (Express's default JSON parser, mass re-serialization) but cannot detect every possible mutation. The clean path is to always plumb the raw bytes; the detector is a safety net.
What the compliance suite checks
The receiver/raw-bytes/* vectors in @postel/compliance are exactly this:
- byte-identical-accept — the signed body and the request body are byte-equal. Receiver accepts.
- json-reserialized-reject — the signed body and the request body are semantically equal but differ in whitespace. A conformant receiver rejects with
SignatureInvalid. A lenient receiver that re-serializes JSON erroneously accepts — and fails this vector.
If you're building a port or a custom adapter, this is the single most important behavior to get right. Every receiver capability requirement in the spec is downstream of this one being correct.
Producer side
The same rule applies in reverse on the sender. The signed-content string is built once at signing time; if the body is mutated between signing and sending (a proxy re-serializes JSON, a transformer adds a trailing newline), the receiver will reject. Postel's v0.2.0 sender signs the bytes immediately before writing them to the network and stamps the signature in the outgoing headers. Nothing in the pipeline touches the body after the signature is computed.
What's next
- Signing and verification — the full verify pipeline.
- Edge runtimes —
req.arrayBuffer()on the edge.