Webhook handlers on serverless fail in a specific and repeatable set of ways, and almost none of them look like the bug they are. The signature check fails on a payload that's provably correct. Work scheduled after the response never runs. A handler that passes every local test times out only in production, only under load.
None of this is Vercel being difficult. It's the consequence of a request-scoped execution model meeting a protocol that assumes a long-lived server. Here are the five that come up most, in the order they'll find you.
TL;DR
await req.text()before anything parses the body, or every signature check fails- Don't reach for
runtime = "edge". Node.js on Fluid Compute is the right default waitUntilextends the function past the response, but it is not durable. It is not a queue- Cold starts are charged against the provider's timeout, and Shopify only gives you 5 seconds
- Your handler's real timeout budget is the strictest provider's, not Vercel's
1. The raw body problem
This is the first one everybody hits, and the error message is maximally unhelpful because the payload looks perfect in the logs.
Every provider signs the exact bytes it sent. Stripe HMACs the raw body with a timestamp prefix, Shopify HMACs the raw body, GitHub HMACs the raw body. If anything between the wire and your verification call parses the JSON and re-serializes it, key order or whitespace shifts by one byte and the HMAC no longer matches. The data is identical but the signature is not.
In the App Router, read text first and parse from the string you already have:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const raw = await req.text(); // exact bytes, before any parsing
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("invalid signature", { status: 400 }); // no details to the caller
}
await queue.publish(event);
return new Response(null, { status: 200 });
}
Three things to keep straight:
Never call await req.json() first. You cannot then recover the original bytes. The stream is consumed, and what you'd re-serialize is not what was signed.
Pages Router needs the body parser disabled explicitly. If you're still on pages/api, export const config = { api: { bodyParser: false } } and read the raw stream yourself. Most "it works locally but not deployed" reports trace back to a missing version of this line.
Middleware counts as "anything". A middleware.ts that inspects request bodies will consume the stream before your route sees it. Exclude webhook paths from the matcher.
For the per-provider signature formats (Stripe's t=/v1= scheme, GitHub's sha256= prefix, Shopify's base64), see Verifying Stripe, GitHub, and Shopify Webhook Signatures. And use the provider SDK's verify function where one exists rather than hand-rolling: it does constant-time comparison and timestamp tolerance for you, both of which are easy to get subtly wrong.
2. Don't put webhook handlers on the edge runtime
There's a persistent instinct that a webhook receiver should be runtime = "edge" because it must be fast. It's outdated advice.
Vercel's default is Node.js on Fluid Compute, which reuses instances across concurrent requests and has largely closed the cold-start gap that made edge attractive. Meanwhile the edge runtime costs you real things a webhook handler wants: full node:crypto, the Node APIs most provider SDKs assume, and longer execution windows. Streaming responses, the other reason people cite, work fine on Node.js and have for a while.
The concrete failure: a provider SDK that calls crypto.createHmac throws on edge, so you hand-roll verification with WebCrypto, and the hand-rolled version is where the timing-safe comparison quietly gets dropped. You traded a real security property for a cold-start difference that no longer exists.
Stay on the default.
If you're already staring at a handler that works locally and fails in production, Debugging Failed Webhooks in Production has the decision tree for narrowing down which layer is eating the request.
3. waitUntil is not a queue
The natural fix for "return fast, do work after" on Vercel is waitUntil:
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const event = await verify(req);
waitUntil(syncToSalesforce(event)); // continues after the response
return new Response(null, { status: 200 });
}
This is genuinely useful and it is genuinely not durability. The distinction matters more than it sounds:
- If the instance is recycled or crashes mid-flight, the work is gone. There is no retry, no dead-letter, no record
- You already told the provider 200. It will never resend. That event now exists nowhere
- Failures inside
waitUntildon't fail the request, so unless you're explicitly catching and reporting, they're invisible
The failure profile is the worst kind: rare, silent, and impossible to reconstruct after the fact, because the only copy of the event was in memory.
waitUntil is the right tool for fire-and-forget work you can afford to lose: analytics pings, cache warming, a Slack notification. It is the wrong tool for anything a customer would notice missing.
The rule that keeps you out of trouble: persist before you acknowledge. Write the event to Postgres, Redis, or a real queue, then return 200, then process. Now the 200 is a truthful statement, because you do have the event, and the processing can fail and retry as many times as it needs.
export async function POST(req: Request) {
const event = await verify(req);
await db.insert(events).values({ id: event.id, payload: event }); // durable
waitUntil(processNow(event.id)); // fast path; a cron sweeps anything left behind
return new Response(null, { status: 200 });
}
The fast path handles the normal case. The sweep handles the instance that died. Neither loses the event.
4. Cold starts are charged to the provider's clock
Vercel's default execution limit is 300 seconds, which sounds like it makes timeouts a non-issue. It doesn't, because you are not being timed by Vercel. You're being timed by whoever is calling you, and their budget is much tighter:
| Provider | Response budget |
|---|---|
| Shopify | 5 seconds |
| Twilio | ~15 seconds |
| GitHub | ~10 seconds |
| Stripe | tens of seconds |
Your handler's real timeout is the strictest provider you receive from. Against Shopify's five seconds, a 2-second cold start has already spent 40% of your budget before your first line executes.
What actually helps:
- Keep the route's import graph small. A webhook route that only verifies and enqueues should not transitively import your ORM, your admin SDK, and your email client. Every one of those is parsed at cold start
- Lazy-import the heavy things inside the branch that needs them, not at module scope
- Set
maxDurationlow on webhook routes.export const maxDuration = 10won't make you faster, but it converts a runaway handler into a fast failure the provider will retry, instead of a 300-second hang that burns compute and gets you no closer
5. Concurrency is not your friend at 3am
Serverless scales out, which is exactly what you want during a flash sale and exactly what breaks the assumption most idempotency code is written under.
A hundred concurrent instances mean a hundred concurrent handlers, each opening database connections. Postgres has a connection limit and it is lower than you think. This is what connection pooling on the platform is for, and skipping it is how a traffic spike turns into too many connections on every request including the ones from real users.
The subtler one: two retries of the same event can land on two instances in the same millisecond. Idempotency logic that reads-then-writes has a race there that a sequential test will never catch. It needs to be a unique constraint doing the work, not an if:
// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);
// Safe: the database arbitrates
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[event.id],
);
if (rowCount === 0) return ok();
await doWork(event);
More on this, including out-of-order delivery, in You Received the Same Webhook Twice.
Where AnyHook fits
Every item above except the raw-body one is really the same problem: a request-scoped runtime is being asked to make a durability promise it can't keep on its own.
AnyHook moves that promise out of the function. Providers POST to in.anyhook.net/you/app, and:
- The 200 goes back in under 50ms from an edge worker whose only job is to persist and acknowledge. Shopify's five seconds stops being your constraint
- The event is durably stored before we acknowledge it, encrypted at rest, so "the instance died" is a retry rather than a loss
- Delivery to your Vercel function is retried on exponential backoff, for far longer than the provider would
- One
AnyHook-Signatureheader replaces per-provider verification schemes, re-signed on every attempt with a fresh timestamp so retries don't fail the tolerance window the way forwarded provider signatures do - Replay over a time range when a deploy window ate an hour of events
Your handler still needs to be idempotent. Nothing removes that. But it no longer needs to be durable, fast, and always-warm simultaneously.
Takeaway
Read the raw body first, stay on the Node.js runtime, and treat waitUntil as best-effort rather than a queue. If you take one structural idea: never return 200 for an event you have not durably written down. Everything else on this list is a performance problem. That one is a data-loss problem, and it's the only one you can't debug after the fact.