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.
// 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.
// 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.
// 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.
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.
| Risk | What handles it |
|---|---|
| Forged request from anyone who knows the URL | Signature verification against the raw body |
| Digest guessed one character at a time | Constant-time comparison |
| Old but genuine payload replayed | Check the provider's signed timestamp or delivery id |
| Sender times out and retries | Durably queue the event before returning 2xx |
| Same event processed twice | Skip event ids already recorded |
| Secret leaked into client code | Keep 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
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.
Frequently Asked Questions
Do I need to disable body parsing like in the Pages Router?
Why must I verify the signature before parsing the body?
What status code should a webhook endpoint return?
How do I handle the same event arriving twice?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.