Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 8 min read

Getting the Raw Request Body for Webhook Verification in 8 Frameworks

Most signature mismatches come from the framework parsing the JSON before you verify it. We show how to read the raw request body and verify a webhook in Express, Next.js, Hono, FastAPI, Django, Rails, Laravel and Go. A

Most signature mismatches come from the framework parsing the JSON before you verify it. We show how to read the raw request body and verify a webhook in Express, Next.js, Hono, FastAPI, Django, Rails, Laravel and Go.

A webhook signature is computed over the bytes the sender put on the wire. Parse the JSON and serialize it again and you change whitespace, key order or how numbers and Unicode are written, and the signature no longer matches. The fix is always the same: read the body of the webhook route before any JSON parser runs. This post shows how in eight frameworks, with minimal verification code for each.

The examples use the Standard Webhooks format that Webhook Admin sends (webhook-id, webhook-timestamp, webhook-signature). Node examples verify with the webhookadmin npm package; Python, Ruby and Go use the official Standard Webhooks libraries; PHP uses built-in functions. Reading the raw body works the same way for Stripe, GitHub or anyone else. Framework behavior was checked against official docs on 2026-09-27.

Quick reference

Framework Raw body Type
Express req.body on a route with express.raw() Buffer
Next.js (App Router) await request.text() string
Next.js (Pages Router) Set bodyParser: false, read the stream Buffer
Hono await c.req.text() string
FastAPI await request.body() bytes
Django request.body bytes
Rails request.raw_post String
Laravel $request->getContent() string
Go (net/http) io.ReadAll(r.Body) []byte

What changes when a body is re-serialized

Take {"amount": 1.50, "name": "caf\u00e9"}. After JSON.parse and JSON.stringify in JavaScript you get {"amount":1.5,"name":"cafΓ©"}: the spaces are gone, 1.50 became 1.5, and the escaped \u00e9 became a literal Γ©, which is two bytes in UTF-8 instead of six. Python's json.dumps produces yet another variant, with spaces after the separators and non-ASCII characters escaped by default. The data is identical; the bytes, and so the HMAC, are not. No amount of careful re-serialization reliably reproduces the sender's bytes, which is why every example below reads the body before anything parses it.

Express

body-parser's raw parser puts the body in req.body as a Buffer. Its type option defaults to application/octet-stream, so set it to application/json; limit defaults to 100kb.

import express from 'express';
import { Webhook } from 'webhookadmin';

const wh = new Webhook(process.env.WEBHOOK_SECRET!);
const app = express();

// Register the webhook route before express.json()
app.post('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  try {
    const event = await wh.verify(req.body, req.headers);
    res.sendStatus(204);
  } catch {
    res.sendStatus(400);
  }
});

app.use(express.json()); // for every other route

If app.use(express.json()) comes first, it parses the webhook route's body too. Stripe's troubleshooting guide gives the same advice about ordering.

Next.js (App Router)

Route Handlers receive a standard Web Request, so request.text() gives you the body as a string. The Next.js docs use this in their webhook example and note that, unlike API Routes in the Pages Router, no bodyParser configuration is needed (route.js).

// app/webhooks/route.ts
import { Webhook } from 'webhookadmin';

const wh = new Webhook(process.env.WEBHOOK_SECRET!);

export async function POST(request: Request) {
  const body = await request.text();
  try {
    const event = await wh.verify(body, request.headers);
    return new Response(null, { status: 204 });
  } catch {
    return new Response('invalid signature', { status: 400 });
  }
}

Calling request.json() first consumes the body, and you can't read it again from the same Request. Parse after verification, from body or from what verify returns.

Next.js (Pages Router)

API Routes parse the body automatically. The Next.js docs mention verifying a webhook's raw body as the reason to set bodyParser to false (API Routes).

// pages/api/webhooks.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { Webhook } from 'webhookadmin';

export const config = { api: { bodyParser: false } };
const wh = new Webhook(process.env.WEBHOOK_SECRET!);

async function readRaw(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
  return Buffer.concat(chunks);
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const raw = await readRaw(req);
  try {
    await wh.verify(raw, req.headers);
    res.status(204).end();
  } catch {
    res.status(400).end();
  }
}

Hono

c.req.text() returns the body as a string and c.req.arrayBuffer() as an ArrayBuffer (HonoRequest). The code is the same on Cloudflare Workers, Bun, Deno and Node.

import { Hono } from 'hono';
import { Webhook } from 'webhookadmin';

const app = new Hono<{ Bindings: { WEBHOOK_SECRET: string } }>();

app.post('/webhooks', async (c) => {
  const wh = new Webhook(c.env.WEBHOOK_SECRET); // on Workers, secrets come from c.env
  const body = await c.req.text();
  try {
    await wh.verify(body, c.req.raw.headers);
    return c.body(null, 204);
  } catch {
    return c.text('invalid signature', 400);
  }
});

export default app;

Don't attach a validator that reads the body (such as zValidator('json', …)) to the webhook route ahead of verification.

FastAPI

FastAPI's Request is Starlette's, and await request.body() returns bytes (FastAPI docs). If you declare the body as a Pydantic model parameter, you only ever see the parsed value.

import os
from fastapi import FastAPI, Request, Response
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["WEBHOOK_SECRET"])  # whsec_…
app = FastAPI()

@app.post("/webhooks")
async def webhooks(request: Request):
    body = await request.body()  # bytes
    try:
        event = wh.verify(body, dict(request.headers))
    except Exception:
        return Response(status_code=400)
    return Response(status_code=204)

Django

HttpRequest.body is "the raw HTTP request body as a bytestring" (Django docs). If something reads the request stream first with request.read(), accessing request.body afterwards raises RawPostDataException. Exempt the webhook view from CSRF checks.

import os
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["WEBHOOK_SECRET"])

@csrf_exempt
@require_POST
def webhooks(request):
    try:
        event = wh.verify(request.body, dict(request.headers))
    except Exception:
        return HttpResponse(status=400)
    return HttpResponse(status=204)

Rails

request.raw_post returns the body as a string (ActionDispatch::Request), and the Standard Webhooks Ruby library's README passes exactly that in its Rails example. In controllers that inherit from ActionController::Base, exclude the webhook action from forgery protection.

class WebhooksController < ApplicationController
  protect_from_forgery except: :create

  def create
    webhook = StandardWebhooks::Webhook.new(ENV.fetch('WEBHOOK_SECRET'))
    payload = webhook.verify(request.raw_post, {
      'webhook-id' => request.headers['webhook-id'],
      'webhook-timestamp' => request.headers['webhook-timestamp'],
      'webhook-signature' => request.headers['webhook-signature'],
    })
    head :no_content
  rescue StandardWebhooks::WebhookVerificationError
    head :bad_request
  end
end

Laravel

Laravel's Request extends Symfony's, whose getContent() returns the raw body (Symfony HttpFoundation). Exclude the route from CSRF checks with validateCsrfTokens(except: [...]) in bootstrap/app.php; Laravel's own docs use Stripe webhooks as the example (Laravel CSRF).

use Illuminate\Http\Request;

Route::post('/webhooks', function (Request $request) {
    $body = $request->getContent();
    $id = (string) $request->header('webhook-id');
    $ts = (string) $request->header('webhook-timestamp');
    if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
        return response('', 400);
    }
    $key = base64_decode(substr(env('WEBHOOK_SECRET'), strlen('whsec_')));
    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$ts}.{$body}", $key, true));
    foreach (explode(' ', (string) $request->header('webhook-signature')) as $part) {
        [$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $sig)) {
            return response('', 204);
        }
    }
    return response('', 400);
});

Go (net/http)

For server requests r.Body is always non-nil and returns EOF immediately when there's no body. Wrap it in http.MaxBytesReader so an oversized body can't eat memory (net/http).

package main

import (
    "io"
    "net/http"
    "os"

    standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func main() {
    wh, err := standardwebhooks.NewWebhook(os.Getenv("WEBHOOK_SECRET"))
    if err != nil {
        panic(err)
    }
    http.HandleFunc("POST /webhooks", func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
        if err != nil {
            http.Error(w, "too large", http.StatusRequestEntityTooLarge)
            return
        }
        if err := wh.Verify(body, r.Header); err != nil {
            http.Error(w, "invalid signature", http.StatusBadRequest)
            return
        }
        w.WriteHeader(http.StatusNoContent)
    })
    http.ListenAndServe(":3000", nil)
}

Common pitfalls

  1. A global JSON parser. Express's app.use(express.json()), NestJS defaults, Hono or FastAPI validators - anything that reads the body before your route means the raw bytes are gone. In NestJS, start the app with NestFactory.create(AppModule, { rawBody: true }) and read req.rawBody (NestJS docs).
  2. Changing the body while converting it to a string. Decoding as something other than UTF-8, normalizing line endings or trimming whitespace all break the signature. Pass bytes, or decode as UTF-8 and leave it alone.
  3. CSRF protection. Rails, Django and Laravel require CSRF tokens on POSTs to protect forms. Webhook senders can't send one, so exempt the webhook route only. Stripe's docs carry the same warning.

Checking your setup

  1. Before verifying, log the body's length and SHA-256.
  2. Compute the SHA-256 of what was sent, from the sender's delivery log or your own test script.
  3. If they match, you have the raw body. If not, something is transforming it.
  4. If the bodies match but verification still fails, the secret or the signed content is wrong. Why webhook signature verification fails walks through that.

To send signed test payloads to a local handler, see testing webhooks locally. Webhook Admin's signature format and libraries for other languages are in signature verification and the verify step of getting started.

If you want to send webhooks that receivers can verify with an official library in any language, you can try Webhook Admin for free (50,000 messages a month): https://app.webhookadmin.com/signup

Quick FAQ

How do I get the raw body in the Next.js App Router?

Call await request.text() in the Route Handler. The Next.js docs use exactly that in their webhook example and note that, unlike the Pages Router, no bodyParser configuration is needed.

How do I use a raw body for just one Express route?

Give that route express.raw({ type: 'application/json' }) as its middleware and register app.use(express.json()) after it. req.body is then a Buffer you can pass straight to your verification function.

In Python, should I pass bytes or str to the verifier?

Pass the bytes you received. FastAPI's await request.body() and Django's request.body both return bytes, and the Standard Webhooks Python library decodes bytes as UTF-8 itself.

Thanks for reading! Which framework gave you the most trouble getting the raw body? Let us know in the comments.

This post first appeared on the Webhook Admin blog.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.