"Raw body required" is the error message from Stripe's, GitHub's, and Shopify's verification SDKs when you've passed them a parsed JSON object instead of the raw bytes. It's not a wire-level error from a provider — it's a developer-facing error from your verification code, and it's the single most common gotcha in webhook integration.
Why Raw Body Matters
HMAC signature verification computes a hash over the exact byte sequence the provider sent. Any change — a single whitespace difference, a different key order, a re-encoded character — changes the HMAC. JavaScript objects don't preserve key order. JSON re-serialization adds whitespace differently. The bytes you reconstruct from a parsed object are not the bytes the provider signed.
Root Causes
1. express.json() ran before your verification code
Most common case. express.json() as global middleware parses every request body. By the time your webhook handler runs, req.body is already a JS object. JSON.stringify(req.body) produces different bytes than the provider sent.
2. Framework default behavior
Next.js App Router, NestJS, Hono — all parse bodies automatically. You have to opt out per-route to get raw bytes. The opt-out mechanism varies by framework.
3. Logging middleware that reads the body
A request-logging middleware that calls req.body consumes the stream. The webhook handler downstream sees an empty body. This is invisible until you debug specifically — common in setups where the team added structured logging without realizing it would break webhook routes.
4. Reverse proxy body modification
Some reverse proxies modify request bodies (Cloudflare's "minify" features, certain WAF rules, content-encoding negotiation). Disable any body-modification features on webhook routes.
Fix It — Per Framework
Express
// Mount express.raw() ONLY on the webhook route, not globally
app.use('/api', express.json()) // for normal API routes
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
// req.body is now a Buffer of the exact request bytes
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
res.status(200).send('ok')
}
)
Next.js (App Router)
// app/api/webhooks/stripe/route.ts
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const rawBody = await req.text() // raw bytes as string
const sig = req.headers.get('stripe-signature')
const event = stripe.webhooks.constructEvent(
rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET
)
return new Response('ok')
}
Fastify
// Add a content-type parser that hands you the raw buffer
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => done(null, body)
)
fastify.post('/webhooks/stripe', async (req, reply) => {
// req.body is a Buffer
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], secret
)
return reply.code(200).send('ok')
})
Hono / Bun / Deno
app.post('/webhooks/stripe', async (c) => {
const rawBody = await c.req.text() // raw, not c.req.json()
const sig = c.req.header('stripe-signature')
const event = stripe.webhooks.constructEvent(rawBody, sig, secret)
return c.text('ok')
})
Python (Flask)
@app.post('/webhooks/stripe')
def stripe_webhook():
raw = request.get_data() # bytes — NOT request.json
sig = request.headers.get('stripe-signature')
event = stripe.Webhook.construct_event(raw, sig, secret)
return 'ok'
Sanity Check
// Add a debug log to confirm what your handler sees
import crypto from 'node:crypto'
console.log({
bodyHash: crypto.createHash('sha256').update(req.body).digest('hex').slice(0, 12),
bodyLen: req.body.length,
bodyType: req.body.constructor.name, // should be 'Buffer'
})
If bodyType is 'Object' instead of 'Buffer', your raw-body setup didn't take. If two identical requests show different bodyHash values, something between the wire and your handler is mutating bytes.
How to Reproduce
Send the same fixed payload (from WebhookWhisper's Test Sender) twice to your handler. Both should produce the same body hash and the same HMAC. If they don't, find the middleware that's mutating the body.
Frequently Asked Questions
Can I use express.json() and then JSON.stringify the result for HMAC?
No — JSON.stringify produces different bytes than the original (key order, whitespace). Always read the raw body before any parser.
Why does Vercel / Next.js have so many tutorials with raw-body workarounds?
Because the App Router's auto-parsing makes raw access tricky. The clean fix is `await req.text()` in App Router routes — most workarounds predate this API.
Should I store the raw body permanently for replay?
Storing raw bytes for ≥7 days is a good idea — when something breaks, you'll need them. Compress (gzip) before storing; webhook payloads compress well.