Handling Webhooks
Verify webhook signatures (e.g. Stripe's stripe.webhooks.constructEvent) before processing. Offload slow work to a queue (Inngest, Trigger.dev, or QStash) so your Route Handler responds within the timeout window.
Webhooks & Background Jobs
A production application must frequently handle async notifications triggered by third-party systems—such as Stripe processing a successful subscription payment or Clerk creating a new user account. These notifications arrive as inbound HTTP requests called webhooks. Because webhooks must respond to the sender quickly to prevent connection retries, and because serverless functions have strict execution timeout limits (typically 10 to 30 seconds), you cannot execute slow processes (like sending welcome emails or compiling PDF invoices) directly inside the handler. In this lesson, we will cover how to verify cryptographic webhook signatures, read raw request bodies, and defer heavy operations to background queues using serverless-friendly task runners.
1. Webhook Security: Mitigating Payload Spoofing
Because webhooks are simple public URLs exposed on your domain (e.g. /api/webhooks/stripe), anyone can send POST requests containing fake transaction data to your endpoint.
To prevent this, third-party providers sign the request payload using a shared secret key, hashing it inside HTTP headers (typically Stripe-Signature or X-Signature). Your server must recreate this hash using the raw request body and verify it matches the header signature before updating any user databases.
2. The Raw Request Body Requirement
Signature verification algorithms require the exact, unparsed string representation of the incoming request body.
CRITICAL NEXT.JS RULE: Do not parse the request body using request.json() before verifying signatures. Parsing the JSON alters whitespace and property ordering, causing the cryptographic hash check to fail. Instead, read the payload as raw text using request.text():
// Reading raw request body in Next.js Route Handlers
export async function POST(request: Request) {
const rawBody = await request.text();
// Pass rawBody string directly to your signature validator
}
3. Implementing a Secure Stripe Webhook Endpoint
Stripe handles signature construction automatically using its SDK client. Here is a complete handler showing how to verify signatures and process successful checkouts:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-01-24' as any,
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '';
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing stripe signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
// Verify payload integrity cryptographically
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return NextResponse.json({ error: 'Signature verification failed' }, { status: 400 });
}
// Handle target events
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
// Extract metadata values assigned during checkout session creation
const userId = session.metadata?.userId;
const courseId = session.metadata?.courseId;
// Trigger database updates or defer background jobs here...
console.log('Payment success for user: ' + userId + ', Course: ' + courseId);
}
// Always return a 200 OK response immediately to acknowledge receipt
return NextResponse.json({ received: true });
}
4. The Webhook Timeout and Socket Limits
Standard webhooks require your server to return an HTTP status code (like 200 OK) quickly (typically within 3 seconds). If your handler blocks response execution to run slow operations, the sender (e.g. Stripe) will assume the request failed due to a network timeout, abort the connection, and retry the webhook repeatedly.
Furthermore, serverless platforms limit maximum execution times:
- Vercel Hobby Tier: 10 seconds timeout.
- Vercel Pro Tier: 15 to 30 seconds default.
To solve this, you must acknowledge receipt instantly and offload work asynchronously to a background queue.
5. Deferring Tasks with Serverless Queues (Inngest)
Traditional background job libraries (like BullMQ on Redis) require a persistent server node to run background workers. Serverless frameworks use event-driven queues like Inngest, Trigger.dev, or QStash.
Inngest operates by hosting a centralized router that triggers serverless endpoints on your application via HTTP post requests, executing step functions asynchronously.
6. Defining an Inngest Step Function Workflow
Create an Inngest client and define background steps. For example, a flow that sends a welcome email and grants database access:
// lib/inngest/client.ts
import { Inngest } from 'inngest';
// Create a client to send and receive events
export const inngest = new Inngest({ id: 'progrumar-app' });
Now define the step handler functions:
// lib/inngest/functions.ts
import { inngest } from './client';
export const handleCheckoutSuccess = inngest.createFunction(
{ id: 'handle-checkout-success' },
{ event: 'shop/checkout.completed' },
async ({ event, step }) => {
const { userId, courseId } = event.data;
// Step 1: Grant database permissions
await step.run('grant-access', async () => {
await fetch('https://api.progrumar.com/permissions', {
method: 'POST',
body: JSON.stringify({ userId, courseId }),
});
});
// Step 2: Delay execution for 5 minutes before sending email
await step.sleep('wait-before-email', '5m');
// Step 3: Trigger external email service
await step.run('send-welcome-email', async () => {
await fetch('https://api.progrumar.com/emails', {
method: 'POST',
body: JSON.stringify({ userId, template: 'course-welcome' }),
});
});
return { status: 'complete' };
}
);
7. Offloading Events inside the Webhook Handler
Instead of executing database writes directly inside the webhook, use the Inngest client to push a trigger event to the queue and return a response immediately:
// Inside your Stripe Webhook handler:
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
// Send trigger payload to Inngest background router
await inngest.send({
name: 'shop/checkout.completed',
data: {
userId: session.metadata?.userId,
courseId: session.metadata?.courseId,
},
});
}
8. Mount the Inngest API Route Handler
To let the Inngest cloud service trigger your step functions, expose an API endpoint that registers and serves your defined background tasks:
// app/api/inngest/route.ts
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest/client';
import { handleCheckoutSuccess } from '@/lib/inngest/functions';
// Serve Inngest handlers (handles GET and POST requests)
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [handleCheckoutSuccess],
});
9. Common Gotchas
-
Confusing Webhook Secret Keys: Stripe provides two separate secret keys: the API secret key (starts with
sk_) and the Webhook Signing secret key (starts withwhsec_). Using the API key insideconstructEvent()will consistently throw signature verification errors. - Processing Duplicate Webhook Messages: Webhook providers guarantee message delivery, meaning they will sometimes send the same payment notification multiple times. Always make your handlers idempotent by checking if a database permission record already exists before running mutations.
-
Ignoring Local Signature Testing Limits: Since localhost routes are not reachable from external Stripe servers, you must use the Stripe CLI proxy tool (
stripe listen --forward-to localhost:3000/api/webhooks/stripe) to test integrations locally.
Key Takeaways
- Verify cryptographic signatures in webhooks to prevent malicious payload spoofing.
- Read raw request bodies as plain text using
request.text()(JSON stringifying invalidates hashes). - Acknowledge webhook receipts with an HTTP 200 response immediately to avoid connection retries.
- Utilize event-driven, serverless background queues (Inngest, Trigger.dev) to offload slow workflows.
- Ensure webhook handlers are idempotent to defend against duplicate delivery runs.
Building API endpoints and background functions completes the data transport layers of your application. But managing state across client and server remains a complex design decision. In the next module, we explore **State Management & Client Patterns** with a lesson on **When (and When Not) to Use Client State**, learning how to structure query state and client-side stores efficiently.