Dev Pattern Labs logo
Dev Pattern Labs Blog
Automation 12 min readOct 15, 2026

Webhook Automation with Next.js: Receive, Verify and Process Events

Build a robust webhook automation system with Next.js. Learn signature verification with HMAC-SHA256, idempotency, async queue processing, and integrating common providers like Stripe, GitHub, and WooCommerce.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

Developer automating webhook integrations on laptop
Executive Summary & Key Insight

Build a robust webhook automation system with Next.js. Learn signature verification with HMAC-SHA256, idempotency, async queue processing, and integrating common providers like Stripe, GitHub, and WooCommerce. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.

Advertisement

Modern web applications rarely live in isolation. They talk to payment gateways, version control platforms, e-commerce engines, and dozens of third-party services every day. The question is: how do you know when something important happens on those services? Two patterns compete for that job — polling and webhooks — and understanding the difference is the first step toward building a genuinely reliable automation layer in your Next.js application.

Polling is the naive approach: your server wakes up on a timer, fires a request at a remote API, checks whether anything changed, and goes back to sleep. It works, but it burns API rate limits, wastes compute cycles during quiet periods, and introduces latency proportional to your polling interval. If your interval is one minute, you can miss a time-sensitive payment event by up to 59 seconds.

Webhooks flip the model. Instead of you asking "did anything happen?", the remote service pushes an HTTP POST request directly to your endpoint the moment an event occurs. Stripe fires a payment_intent.succeeded event within milliseconds of a card charge completing. GitHub fires push events as soon as commits land. Your server reacts in near-real-time, with zero wasted polling cycles. Building a robust webhook receiver is therefore one of the highest-leverage automation skills a full-stack developer can possess.


Prerequisites

Before diving in, make sure you have:

  • Node.js 18+ and a Next.js 14+ project (App Router or Pages Router both work)
  • Basic familiarity with TypeScript and async/await
  • A Redis instance available locally or via a cloud provider (for the BullMQ queue section)
  • The following packages installed:
npm install bullmq ioredis stripe
npm install --save-dev @types/node

Section 1: Webhooks vs Polling – Why Webhooks Win

The architectural difference between polling and webhooks has real cost implications at scale. Consider a SaaS platform processing 10,000 orders per day. With a 30-second polling interval, your server makes 2,880 API calls per day — most returning an empty response. With webhooks, you receive exactly 10,000 targeted callbacks, each carrying a real event.

Webhooks also decouple your system from the remote service's data model. You subscribe only to the event types you care about (payment_intent.succeeded, not every Stripe event), so your handler code stays focused. The trade-off is that webhook endpoints must be publicly reachable HTTPS URLs — something polling doesn't require — which is why local development tooling like ngrok is essential (covered in Section 8).

For production automation systems, the verdict is clear: use webhooks for real-time event-driven workflows, and fall back to polling only when webhooks are unavailable (rare legacy APIs) or when you need to reconcile state after a potential missed delivery.


Section 2: Building a Next.js Webhook Receiver Route

Next.js App Router makes it straightforward to expose a webhook endpoint. Create a route handler at app/api/webhooks/[provider]/route.ts so a single file handles multiple providers cleanly.

// app/api/webhooks/[provider]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyStripeSignature } from "@/lib/webhooks/stripe";
import { verifyGitHubSignature } from "@/lib/webhooks/github";
import { enqueueWebhookJob } from "@/lib/queue/webhookQueue";

export const runtime = "nodejs"; // Required for crypto and Buffer APIs

export async function POST(
  request: NextRequest,
  { params }: { params: { provider: string } }
) {
  const { provider } = params;

  // Read the raw body ONCE – vital for signature verification
  const rawBody = await request.text();
  const headers = Object.fromEntries(request.headers.entries());

  let verified = false;
  let payload: Record<string, unknown> = {};

  try {
    switch (provider) {
      case "stripe":
        payload = await verifyStripeSignature(rawBody, headers);
        verified = true;
        break;
      case "github":
        payload = await verifyGitHubSignature(rawBody, headers);
        verified = true;
        break;
      default:
        return NextResponse.json({ error: "Unknown provider" }, { status: 400 });
    }
  } catch (err) {
    console.error(`[Webhook] Signature verification failed for ${provider}:`, err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  if (!verified) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Enqueue for async processing – respond immediately to the provider
  await enqueueWebhookJob({ provider, payload, receivedAt: new Date().toISOString() });

  return NextResponse.json({ received: true }, { status: 200 });
}

Critical rule: always respond with 200 OK quickly. Most webhook providers retry on any non-2xx response or on timeouts. Offloading work to a queue (Section 5) ensures your endpoint stays fast.


Section 3: Signature Verification with HMAC-SHA256

Every serious webhook provider signs its payloads using HMAC-SHA256. Without verification, a malicious actor can POST fabricated events to your endpoint and trigger arbitrary automation — a critical security vulnerability.

The pattern is always the same: the provider computes HMAC-SHA256(secret, rawBody) and sends the result in a header. Your server recomputes the same hash and performs a timing-safe comparison to prevent timing attacks.

// lib/webhooks/stripe.ts
import crypto from "crypto";

const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300; // 5 minutes

export async function verifyStripeSignature(
  rawBody: string,
  headers: Record<string, string>
): Promise<Record<string, unknown>> {
  const sigHeader = headers["stripe-signature"];
  if (!sigHeader) throw new Error("Missing stripe-signature header");

  // Parse the header: t=timestamp,v1=signature
  const parts = Object.fromEntries(
    sigHeader.split(",").map((p) => p.split("=") as [string, string])
  );

  const timestamp = parseInt(parts["t"], 10);
  const signature = parts["v1"];

  if (!timestamp || !signature) throw new Error("Malformed stripe-signature header");

  // Reject stale events to prevent replay attacks
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
    throw new Error("Webhook timestamp too old – possible replay attack");
  }

  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSig = crypto
    .createHmac("sha256", STRIPE_WEBHOOK_SECRET)
    .update(signedPayload, "utf8")
    .digest("hex");

  // Timing-safe comparison – never use === for secrets
  const isValid = crypto.timingSafeEqual(
    Buffer.from(expectedSig, "hex"),
    Buffer.from(signature, "hex")
  );

  if (!isValid) throw new Error("Signature mismatch");

  return JSON.parse(rawBody) as Record<string, unknown>;
}
// lib/webhooks/github.ts
import crypto from "crypto";

const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET!;

export async function verifyGitHubSignature(
  rawBody: string,
  headers: Record<string, string>
): Promise<Record<string, unknown>> {
  const sigHeader = headers["x-hub-signature-256"];
  if (!sigHeader) throw new Error("Missing x-hub-signature-256 header");

  const [algorithm, signature] = sigHeader.split("=");
  if (algorithm !== "sha256") throw new Error("Unexpected algorithm");

  const expectedSig = crypto
    .createHmac("sha256", GITHUB_WEBHOOK_SECRET)
    .update(rawBody, "utf8")
    .digest("hex");

  const isValid = crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(signature)
  );

  if (!isValid) throw new Error("GitHub signature mismatch");

  return JSON.parse(rawBody) as Record<string, unknown>;
}

Section 4: Implementing Idempotency for Duplicate Events

Webhook providers guarantee at-least-once delivery, not exactly-once. Network timeouts can cause a provider to retry an event your server already processed. Without idempotency, you might charge a customer twice or trigger a deployment pipeline multiple times.

The solution is simple: store the event ID (every provider assigns one) in a fast key-value store — Redis works perfectly — and skip processing if you've seen it before.

// lib/webhooks/idempotency.ts
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);
const IDEMPOTENCY_TTL_SECONDS = 86400; // 24 hours

/**
 * Returns true if this event ID is new and was successfully registered.
 * Returns false if the event was already processed (duplicate).
 */
export async function registerEventIdempotencyKey(
  provider: string,
  eventId: string
): Promise<boolean> {
  const key = `webhook:idempotency:${provider}:${eventId}`;

  // NX = only set if Not eXists; returns null if key already existed
  const result = await redis.set(key, "1", "EX", IDEMPOTENCY_TTL_SECONDS, "NX");
  return result === "OK";
}

Integrate it in your queue worker before any business logic:

const isNew = await registerEventIdempotencyKey(provider, eventId);
if (!isNew) {
  console.log(`[Webhook] Duplicate event ${eventId} from ${provider} – skipping`);
  return; // Exit worker gracefully; no error
}
// ... proceed with processing

Section 5: Async Processing with Queues (BullMQ)

Keeping your HTTP handler thin and delegating work to a background queue is the professional approach. BullMQ (backed by Redis) gives you retries, concurrency control, delayed jobs, and a visual dashboard via Bull Board.

// lib/queue/webhookQueue.ts
import { Queue, Worker, Job } from "bullmq";
import { Redis } from "ioredis";
import { registerEventIdempotencyKey } from "@/lib/webhooks/idempotency";
import { handleStripeEvent } from "@/lib/handlers/stripeHandler";
import { handleGitHubEvent } from "@/lib/handlers/githubHandler";

const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });

export const webhookQueue = new Queue("webhooks", {
  connection,
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: "exponential", delay: 2000 },
    removeOnComplete: { count: 500 },
    removeOnFail: { count: 100 },
  },
});

export async function enqueueWebhookJob(data: {
  provider: string;
  payload: Record<string, unknown>;
  receivedAt: string;
}) {
  await webhookQueue.add("process", data, {
    jobId: `${data.provider}-${(data.payload as any).id ?? Date.now()}`,
  });
}

// Worker – run this in a separate long-lived process
export const webhookWorker = new Worker(
  "webhooks",
  async (job: Job) => {
    const { provider, payload } = job.data as {
      provider: string;
      payload: Record<string, unknown>;
    };

    const eventId = (payload.id as string) ?? job.id!;
    const isNew = await registerEventIdempotencyKey(provider, eventId);
    if (!isNew) return { skipped: true, eventId };

    switch (provider) {
      case "stripe":
        await handleStripeEvent(payload);
        break;
      case "github":
        await handleGitHubEvent(payload);
        break;
      default:
        throw new Error(`No handler registered for provider: ${provider}`);
    }

    return { processed: true, eventId };
  },
  { connection, concurrency: 10 }
);

webhookWorker.on("completed", (job) =>
  console.log(`[Queue] Job ${job.id} completed:`, job.returnvalue)
);
webhookWorker.on("failed", (job, err) =>
  console.error(`[Queue] Job ${job?.id} failed:`, err.message)
);

Section 6: Common Webhook Providers – Stripe, GitHub, WooCommerce

Here is a practical comparison of how the three most common webhook providers handle delivery and verification:

ProviderSignature HeaderAlgorithmTimestamp Replay ProtectionRetry PolicyEvent ID Field
StripeStripe-SignatureHMAC-SHA256✅ Yes (t= prefix)Up to 3 days, exponential backoffevent.id (evt_...)
GitHubX-Hub-Signature-256HMAC-SHA256❌ No built-inUp to 3 attempts over 7 daysX-GitHub-Delivery header UUID
WooCommerceX-WC-Webhook-SignatureHMAC-SHA256❌ No built-inUp to 5 retries, then disablesX-WC-Webhook-ID header

WooCommerce computes its signature over the JSON body using base64(HMAC-SHA256(secret, body)):

// lib/webhooks/woocommerce.ts
import crypto from "crypto";

const WOO_SECRET = process.env.WOOCOMMERCE_WEBHOOK_SECRET!;

export async function verifyWooCommerceSignature(
  rawBody: string,
  headers: Record<string, string>
): Promise<Record<string, unknown>> {
  const sigHeader = headers["x-wc-webhook-signature"];
  if (!sigHeader) throw new Error("Missing x-wc-webhook-signature header");

  const expectedSig = crypto
    .createHmac("sha256", WOO_SECRET)
    .update(rawBody, "utf8")
    .digest("base64"); // WooCommerce uses base64, not hex

  if (expectedSig !== sigHeader) {
    throw new Error("WooCommerce signature mismatch");
  }

  return JSON.parse(rawBody) as Record<string, unknown>;
}

Section 7: Proper Error Responses and Status Codes

How your endpoint responds matters. Providers use HTTP status codes to decide whether to retry:

// lib/webhooks/responses.ts
import { NextResponse } from "next/server";

export const webhookResponses = {
  /** Event received and queued successfully */
  ok: () => NextResponse.json({ received: true }, { status: 200 }),

  /**
   * Signature invalid – provider should NOT retry (the payload is forged or
   * the secret is misconfigured). 401 signals a permanent auth failure.
   */
  unauthorized: (message = "Invalid signature") =>
    NextResponse.json({ error: message }, { status: 401 }),

  /**
   * Event type not handled – acknowledge receipt so the provider stops
   * retrying. Return 200 even if you don't process the event.
   */
  unhandledEvent: (type: string) =>
    NextResponse.json({ received: true, note: `Event type '${type}' not handled` }, { status: 200 }),

  /**
   * Internal error – return 500 to trigger a provider retry.
   * Only use when you WANT the provider to resend the event.
   */
  internalError: () =>
    NextResponse.json({ error: "Internal server error" }, { status: 500 }),
};

Key rules: return 200 for unhandled-but-valid events (so providers don't retry endlessly), return 401 for bad signatures (not 403, since the resource exists but the caller is not authenticated), and return 500 only when you want a retry.


Section 8: Testing Webhooks Locally with ngrok

Webhook providers need a publicly reachable HTTPS URL, which your localhost:3000 is not. ngrok solves this by creating a secure tunnel.

Step 1: Install and authenticate ngrok from ngrok.com, then run:

ngrok http 3000

ngrok prints a forwarding URL like https://a1b2c3d4.ngrok-free.app. Use this as your webhook endpoint base URL when registering with Stripe or GitHub.

Step 2: Use the Stripe CLI for an even tighter local loop — it forwards Stripe events directly without exposing a public URL:

# Install Stripe CLI, then:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a temporary webhook signing secret (whsec_...). Set it as STRIPE_WEBHOOK_SECRET in your .env.local:

STRIPE_WEBHOOK_SECRET=whsec_your_local_secret_here
GITHUB_WEBHOOK_SECRET=your_github_secret_here
WOOCOMMERCE_WEBHOOK_SECRET=your_woo_secret_here
REDIS_URL=redis://localhost:6379

Step 3: Trigger test events:

# Trigger a Stripe test payment event
stripe trigger payment_intent.succeeded

# Use ngrok's web UI at http://localhost:4040 to inspect all incoming requests,
# replay them, and debug your handler logic without waiting for real events.

ngrok's dashboard at http://localhost:4040 is invaluable — it shows every request body, headers, your server's response, and lets you replay any request with a single click.


Conclusion

Building a production-grade webhook receiver in Next.js is a matter of layering the right patterns on top of a simple API route:

  • Verify every signature with HMAC-SHA256 and a timing-safe comparison — never trust unverified payloads.
  • Respond immediately with 200 OK and offload work to a background queue to avoid timeouts and unwanted retries.
  • Enforce idempotency using Redis and the provider's event ID to safely handle duplicate deliveries.
  • Use BullMQ (or a similar queue) for retries, concurrency control, and observability.
  • Return the right status codes — 401 for bad signatures, 200 for unhandled events, 500 only when you want a retry.
  • Test locally with ngrok or the Stripe CLI before deploying — you'll catch signature mismatches and payload shape issues early.

Webhooks are the backbone of modern integrations. Master this pattern once, and you can connect any third-party service — payments, source control, e-commerce, CRMs — to your Next.js application with confidence, security, and real-time responsiveness.

Tags:#Webhooks#Next.js#Automation#HMAC#Integration
Dev Pattern Labs Engineering Team

Written by Dev Pattern LabsCore Engineering

Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.

Advertisement

Recommended Companion Articles

Payment processing dashboard showing Stripe integration
Automation

Stripe Webhooks in Next.js 15: Payment Event Handling

Integrate Stripe webhooks with Next.js 15 App Router. Learn stripe.webhooks.constructEvent, raw body handling, payment event processing, fulfillment logic, and testing with the Stripe CLI.

10 min read