Receiving Webhooks in Next.js Safely

Build a webhook endpoint as a POST Route Handler, verify the signature against the unchanged body, answer quickly, and handle repeat deliveries.

8 min read

Next.js webhooks are ordinary Route Handlers: a third-party service sends a POST request to a URL you own when something happens, and your handler reacts to it. Because that URL is publicly reachable, anyone can send it a request that looks genuine.

Handling webhooks safely comes down to four things: proving who sent the request, blocking replays, answering fast, and surviving duplicates.

Here is the shape before any of that is added.

typescripttypescript
// app/api/webhooks/billing/route.ts
export async function POST(request: Request) {
  const payload = await request.text()
  const event = JSON.parse(payload)
 
  console.log('received event', event.type)
  return new Response(null, { status: 204 })
}

A POST to /api/webhooks/billing logs the event type and returns an empty 204 response. Reading the body with text rather than json is deliberate, and the next section explains why it matters.

Route Handlers hand you the raw request, so there is no body parser to disable. The configuration that Pages Router API Routes needed does not exist in the App Router.

Verify the signature against the raw body

Providers sign each delivery and put the result in a header, but the header format and signed input are provider-specific. Prefer the provider's official verification library. The helper below is only for a custom contract where x-signature contains the hexadecimal HMAC-SHA256 digest of the unchanged UTF-8 body.

typescripttypescript
// lib/verify-webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
 
export function isValidSignature(payload: string, signature: string) {
  const secret = process.env.WEBHOOK_SECRET
  if (!secret) throw new Error('WEBHOOK_SECRET is not configured')
  const received = Buffer.from(signature, 'hex')
  const expected = createHmac('sha256', secret).update(payload).digest()
  return received.length === expected.length && timingSafeEqual(received, expected)
}

Two details here are not optional. The comparison uses timingSafeEqual rather than a normal equality check, and the length check comes first because the constant-time function throws when the buffers differ in size. Constant-time comparison does not make the surrounding code automatically safe, which is another reason to prefer the provider's maintained verifier.

The secret comes from an environment variable that is never prefixed for the client, so it stays on the server. Environment variable rules are covered in server and client environment variables.

Now the handler can reject anything that fails the check before it looks at the payload.

typescripttypescript
// app/api/webhooks/billing/route.ts
import { isValidSignature } from '@/lib/verify-webhook'
export async function POST(request: Request) {
  const payload = await request.text()
  const signature = request.headers.get('x-signature') ?? ''
 
  if (!isValidSignature(payload, signature)) {
    return new Response('Invalid signature', { status: 400 })
  }
  return new Response(null, { status: 204 })
}

A forged request now gets a 400 and never reaches your business logic. This handler matches the custom contract stated above. Stripe, GitHub, and other providers use different header formats and signed inputs, so use their current documentation or official library instead of renaming x-signature in this example.

The body can only be read once. Store the text, verify that same value, then parse it. If two consumers genuinely need separate streams, clone the request before either one reads it, as described in reading the request body.

Block replayed deliveries

A valid signature proves the payload matches what the secret holder signed, not that this is the first time you have seen it. Someone who captures a genuine delivery can send it again later.

Replay defense depends on the provider. Stripe includes a signed timestamp and its libraries use a five-minute tolerance by default. GitHub instead supplies an X-GitHub-Delivery id that you record and reject when it repeats.

Use the mechanism documented by your provider rather than assuming every signature contains a timestamp.

Respond first, work later

Senders enforce a timeout and treat a slow response as a failed delivery, which triggers a retry. If your handler fulfills an order or sends an email before responding, one slow dependency turns into a queue of duplicate events.

The project-specific helpers below validate the billing event and resolve only after a durable queue accepts it. Put the handler in app/api/webhooks/billing/route.ts.

typescripttypescript
import { enqueueBillingEvent, parseBillingEvent } from '@/lib/billing-events'
import { isValidSignature } from '@/lib/verify-webhook'
export async function POST(request: Request) {
  const payload = await request.text()
  const signature = request.headers.get('x-signature') ?? ''
  if (!isValidSignature(payload, signature))
    return new Response('Invalid signature', { status: 400 })
  const event = parseBillingEvent(payload)
  await enqueueBillingEvent(event)
  return new Response(null, { status: 202 })
}

This handler verifies the unchanged body, validates the parsed event, and waits only until a durable queue accepts it. The 202 response tells the sender that processing will continue asynchronously.

Next.js after is useful for best-effort logging after the response, but critical fulfillment should not rely on an in-process callback that can fail after acknowledgement. The queue worker performs the slow work and can retry it independently.

Expect the same event twice

Delivery is at-least-once. Retries after a timeout, manual resends, and network failures all produce repeat deliveries, and providers do not guarantee ordering either.

The fix is to record the event id from the payload as you process it and skip ids you have seen before. Storing the id in the same transaction as the work it triggers is what makes the operation genuinely idempotent, rather than merely usually correct.

A common use of webhooks is refreshing cached content when a CMS publishes an update, which pairs this pattern with the invalidation approach in how cache revalidation works.

The risks and what handles them

Each risk here has one specific countermeasure, and skipping any of them leaves a real hole rather than a theoretical one.

RiskWhat handles it
Forged request from anyone who knows the URLSignature verification against the raw body
Digest guessed one character at a timeConstant-time comparison
Old but genuine payload replayedCheck the provider's signed timestamp or delivery id
Sender times out and retriesDurably queue the event before returning 2xx
Same event processed twiceSkip event ids already recorded
Secret leaked into client codeKeep it in a server-only environment variable

Rate limiting sits alongside these, since a public endpoint can be flooded with invalid requests that each cost a signature check.

Common mistakes

  • Parsing the body to JSON and signing the re-serialized string, which produces a different digest than the sender computed.
  • Comparing digests with a plain equality operator.
  • Returning a 500 for an event type you do not handle, which makes the provider retry it forever. Acknowledge it and ignore it.
  • Doing the real work before responding, so a slow dependency causes duplicate deliveries.
  • Trusting the payload shape or contents. Validate the event, and confirm sensitive amounts or statuses against the provider API before anything irreversible happens.

The short version

Read the unchanged body once, verify it with the provider's exact scheme, apply that provider's replay defense, and validate the parsed event. Durably accept work before returning 2xx, then process it idempotently because the same delivery can arrive again. The endpoint itself is an ordinary Route Handler, explained in route handlers and route.js.

Rune AI

Rune AI

Key Insights

  • A webhook endpoint is a POST Route Handler and is publicly reachable.
  • Read the body once as text and verify it before parsing.
  • Follow the provider's exact signature format or official library.
  • Use the provider's timestamp or delivery id replay defense.
  • Return 2xx only after work is durably accepted.
  • Make processing idempotent by event or delivery id.
RunePowered by Rune AI

Frequently Asked Questions

Do I need to disable body parsing like in the Pages Router?

No. Route Handlers let you read the unchanged UTF-8 request body with request.text(). The bodyParser configuration from Pages Router API Routes does not exist here.

Why must I verify the signature before parsing the body?

Providers sign the body they transmitted. Parsing to JSON and re-serializing can change key order or whitespace, which produces different signed input and a failed check.

What status code should a webhook endpoint return?

Return a 2xx status once you have durably accepted the event. Retry behavior varies by provider, so follow that provider's status and timeout documentation.

How do I handle the same event arriving twice?

Store the provider's event or delivery id and ignore ids you have already processed. Retries and manual redeliveries make duplicate deliveries normal.

Conclusion

A webhook endpoint is a public POST Route Handler, so safety comes from following the provider's signature scheme, verifying the unchanged request body, applying its replay defense, durably accepting work before returning 2xx, and treating repeat delivery ids as normal.