# Raw bytes



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 [#the-trap]

This is broken:

```ts title="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 postel.inbound.vendor.verify(
    new TextEncoder().encode(reserialized),
    req.headers,
  );
});
```

`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 [#the-fix]

Every framework adapter Postel ships preserves the raw request body and passes it into `postel.inbound.<source>.verify` byte-for-byte. The contract:

* **Express, Fastify, Koa, Hono, Elysia** — middleware buffers the raw `req` stream before any JSON parser sees it. The buffered bytes are what `verify` operates on.
* **Bun.serve, Deno.serve, Next.js Route Handlers, SvelteKit, Astro, Nitro** — the `Request` object already exposes raw bytes via `await req.arrayBuffer()` before any `.json()` call. Adapters use that.

If you're calling `verify` directly (no framework adapter), the rule is:

```ts title="app/webhooks/route.ts"
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 postel.inbound.vendor.verify(body, headers);
```

`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`.

```ts
const { event } = await postel.inbound.vendor.verify(body, headers);
console.log(event.type, event.data);  // parsed for you, signature already verified
```

## What the compliance suite checks [#what-the-compliance-suite-checks]

The `receiver/raw-bytes/*` vectors in [`@postel/compliance`](https://github.com/postel-sh/postel/tree/main/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 other receiver requirement is downstream of this one being correct.

## Producer side [#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 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 [#whats-next]

* [Signing schemes](/docs/inbound/signing) — the full verify pipeline.
* [Web adapters](/docs/web-adapters) — wire verification into your framework or runtime.
