Back to Blog
πŸ’°Fintech

Stripe Webhook Handling: Signature Verification, Idempotency, and Event Routing

Build production-grade Stripe webhook handling: HMAC signature verification, idempotent event processing, retry-safe handlers, event routing architecture, and testing strategies in TypeScript.

Viprasol Tech Team
November 13, 2026
13 min read

Stripe webhooks are the backbone of subscription billing. When a payment succeeds, fails, or a subscription renews, Stripe fires a webhook β€” and your handler must process it exactly once, survive retries, and not deadlock under concurrent delivery. Getting this wrong means double-charging customers, missing churn signals, or granting access after payment fails.

This post covers production-grade webhook handling: HMAC signature verification, the idempotency pattern that prevents double-processing, typed event routing, and a test strategy that doesn't require ngrok.

How Stripe Webhooks Work

Stripe servers ──POST──► /api/webhooks/stripe
                          β”‚
                          β”œβ”€β”€ 1. Verify signature (reject forgeries)
                          β”œβ”€β”€ 2. Check idempotency (skip duplicates)
                          β”œβ”€β”€ 3. Route to handler by event type
                          β”œβ”€β”€ 4. Process business logic
                          └── 5. Return 200 (or Stripe retries for 72 hours)

Stripe retry schedule on non-2xx:
  Attempt 1: Immediate
  Attempt 2: ~5 minutes
  Attempt 3: ~30 minutes
  Attempts 4-18: Exponentially increasing up to 72 hours

Critical rule: Your handler must be idempotent. Stripe guarantees at-least-once delivery β€” the same event may arrive multiple times.


1. Endpoint Setup with Raw Body

// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { handleStripeEvent } from '../../../../services/billing/webhook-router';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-10-28.acacia',
});

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  // Stripe requires the raw body for signature verification
  // Next.js App Router: req.text() gives us the raw string
  const rawBody = await req.text();
  const signature = req.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // constructEvent verifies HMAC-SHA256 signature + timestamp (prevents replay attacks)
    event = stripe.webhooks.constructEvent(rawBody, signature, WEBHOOK_SECRET);
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message);
    // Return 400 β€” do NOT return 200 for invalid signatures
    return NextResponse.json(
      { error: `Webhook signature invalid: ${err.message}` },
      { status: 400 }
    );
  }

  try {
    await handleStripeEvent(event);
    return NextResponse.json({ received: true });
  } catch (err: any) {
    // Log but return 200 to avoid infinite Stripe retries for non-recoverable errors
    // For recoverable errors, throw so Stripe retries
    console.error(`Webhook handler failed for ${event.type}:`, err);

    if (err.retryable === false) {
      // Non-recoverable: log and acknowledge
      return NextResponse.json({ received: true, warning: 'Handler failed but acknowledged' });
    }

    // Recoverable: return 500 so Stripe retries
    return NextResponse.json({ error: 'Handler failed' }, { status: 500 });
  }
}

// Disable body parsing β€” we need raw bytes for HMAC verification
export const config = {
  api: { bodyParser: false },
};

Express / Fastify Equivalent

// Express: use express.raw() for webhook routes specifically
import express from 'express';

const app = express();

// βœ… Raw body for webhook route
app.post(
  '/api/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  webhookHandler
);

// βœ… JSON body for all other routes
app.use(express.json());

πŸ’³ Fintech That Passes Compliance β€” Not Just Demos

Payment integrations, KYC/AML flows, trading APIs, and regulatory compliance β€” we build fintech that survives real audits, not just product demos.

  • PCI DSS, PSD2, FCA, GDPR-aware architecture
  • Stripe, Plaid, Rapyd, OpenBanking integrations
  • Real-time transaction monitoring and fraud flags
  • UK/EU/US compliance requirements mapped from day one

2. Idempotency: Process Each Event Exactly Once

-- Track processed webhook events
CREATE TABLE webhook_events (
  id              TEXT        PRIMARY KEY,  -- Stripe event ID (e.g., evt_xxx)
  event_type      TEXT        NOT NULL,
  processed_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  result          JSONB,                    -- Store result for debugging
  error           TEXT,                     -- Store error if processing failed
  
  INDEX idx_webhook_events_type (event_type, processed_at DESC)
);
// src/services/billing/webhook-idempotency.ts
import { db } from '../../lib/db';

export type ProcessingResult = 
  | { status: 'processed'; result?: object }
  | { status: 'skipped'; reason: 'duplicate' }
  | { status: 'failed'; error: string };

export async function processOnce(
  eventId: string,
  eventType: string,
  handler: () => Promise<object | void>
): Promise<ProcessingResult> {
  // Check if already processed
  const existing = await db.webhookEvent.findUnique({
    where: { id: eventId },
    select: { id: true, result: true, error: true },
  });

  if (existing) {
    console.log(`Skipping duplicate webhook: ${eventId} (${eventType})`);
    return { status: 'skipped', reason: 'duplicate' };
  }

  // Mark as processing (optimistic lock β€” prevents concurrent duplicates)
  try {
    await db.webhookEvent.create({
      data: { id: eventId, eventType },
    });
  } catch (err: any) {
    // Unique constraint violation = another process beat us to it
    if (err.code === 'P2002') {
      return { status: 'skipped', reason: 'duplicate' };
    }
    throw err;
  }

  // Execute handler
  try {
    const result = await handler();

    await db.webhookEvent.update({
      where: { id: eventId },
      data: { result: (result ?? {}) as object },
    });

    return { status: 'processed', result: result ?? undefined };
  } catch (err: any) {
    await db.webhookEvent.update({
      where: { id: eventId },
      data: { error: err.message },
    });
    throw err;
  }
}

3. Typed Event Router

// src/services/billing/webhook-router.ts
import Stripe from 'stripe';
import { processOnce } from './webhook-idempotency';
import { handleCheckoutSessionCompleted } from './handlers/checkout';
import { handleInvoicePaymentSucceeded, handleInvoicePaymentFailed } from './handlers/invoice';
import { handleSubscriptionUpdated, handleSubscriptionDeleted } from './handlers/subscription';
import { handlePaymentIntentSucceeded, handlePaymentIntentFailed } from './handlers/payment';

// Type-safe event handler map
type EventHandler<T extends Stripe.Event['type']> = (
  event: Stripe.Event & { type: T; data: { object: Extract<Stripe.Event['data']['object'], { object: string }> } }
) => Promise<void>;

const EVENT_HANDLERS: Partial<Record<Stripe.Event['type'], (event: Stripe.Event) => Promise<void>>> = {
  'checkout.session.completed': handleCheckoutSessionCompleted,
  'invoice.payment_succeeded': handleInvoicePaymentSucceeded,
  'invoice.payment_failed': handleInvoicePaymentFailed,
  'customer.subscription.updated': handleSubscriptionUpdated,
  'customer.subscription.deleted': handleSubscriptionDeleted,
  'payment_intent.succeeded': handlePaymentIntentSucceeded,
  'payment_intent.payment_failed': handlePaymentIntentFailed,
};

export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  const handler = EVENT_HANDLERS[event.type];

  if (!handler) {
    // Not an error β€” we just don't handle this event type
    console.info(`Unhandled Stripe event type: ${event.type}`);
    return;
  }

  await processOnce(event.id, event.type, () => handler(event));
}

🏦 Trading Systems, Payment Rails, and Financial APIs

From algorithmic trading platforms to neobank backends β€” Viprasol has built the full spectrum of fintech. Senior engineers, no junior handoffs, verified track record.

  • MT4/MT5 EA development for prop firms and hedge funds
  • Custom payment gateway and wallet systems
  • Regulatory reporting automation (MiFID, EMIR)
  • Free fintech architecture consultation

4. Handler Implementations

Checkout Session Completed (New Subscription)

// src/services/billing/handlers/checkout.ts
import Stripe from 'stripe';
import { db } from '../../../lib/db';
import { stripe } from '../../../lib/stripe';

export async function handleCheckoutSessionCompleted(
  event: Stripe.Event
): Promise<void> {
  const session = event.data.object as Stripe.Checkout.Session;

  if (session.mode !== 'subscription') return;
  if (!session.subscription || !session.customer) return;

  // Expand subscription to get plan details
  const subscription = await stripe.subscriptions.retrieve(
    session.subscription as string,
    { expand: ['items.data.price.product'] }
  );

  const priceItem = subscription.items.data[0];
  const price = priceItem.price;
  const product = price.product as Stripe.Product;

  // Find account by Stripe customer ID or metadata
  const accountId =
    session.metadata?.accountId ??
    (await db.account.findFirst({
      where: { stripeCustomerId: session.customer as string },
      select: { id: true },
    }))?.id;

  if (!accountId) {
    console.error(`No account found for Stripe customer: ${session.customer}`);
    return;
  }

  await db.$transaction([
    db.subscription.upsert({
      where: { accountId },
      update: {
        stripeSubscriptionId: subscription.id,
        stripePriceId: price.id,
        stripeItemId: priceItem.id,
        status: subscription.status,
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        cancelAtPeriodEnd: subscription.cancel_at_period_end,
        updatedAt: new Date(),
      },
      create: {
        accountId,
        stripeSubscriptionId: subscription.id,
        stripeCustomerId: session.customer as string,
        stripePriceId: price.id,
        stripeItemId: priceItem.id,
        status: subscription.status,
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        cancelAtPeriodEnd: subscription.cancel_at_period_end,
      },
    }),
    db.account.update({
      where: { id: accountId },
      data: {
        plan: product.metadata.plan ?? 'pro',
        stripeCustomerId: session.customer as string,
      },
    }),
  ]);

  console.info(`Subscription activated for account ${accountId}: ${product.name}`);
}

Invoice Payment Failed (Dunning)

// src/services/billing/handlers/invoice.ts
import Stripe from 'stripe';
import { db } from '../../../lib/db';
import { emailService } from '../../../lib/email';

export async function handleInvoicePaymentFailed(event: Stripe.Event): Promise<void> {
  const invoice = event.data.object as Stripe.Invoice;

  if (!invoice.subscription || !invoice.customer_email) return;

  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: invoice.subscription as string },
    include: { account: { include: { owner: true } } },
  });

  if (!subscription) {
    console.error(`No subscription found for Stripe ID: ${invoice.subscription}`);
    return;
  }

  const attemptCount = invoice.attempt_count;
  const nextAttempt = invoice.next_payment_attempt
    ? new Date(invoice.next_payment_attempt * 1000)
    : null;

  // Update subscription status
  await db.subscription.update({
    where: { id: subscription.id },
    data: { status: 'past_due' },
  });

  // Send dunning email based on attempt count
  const emailTemplate =
    attemptCount === 1 ? 'payment-failed-first' :
    attemptCount === 2 ? 'payment-failed-second' :
    'payment-failed-final';

  await emailService.send({
    to: invoice.customer_email,
    template: emailTemplate,
    data: {
      customerName: subscription.account.owner.name,
      amount: (invoice.amount_due / 100).toFixed(2),
      currency: invoice.currency.toUpperCase(),
      nextAttempt: nextAttempt?.toLocaleDateString() ?? 'no further attempts',
      updatePaymentUrl: `${process.env.APP_URL}/billing/payment-method`,
    },
  });

  console.info(
    `Payment failed (attempt ${attemptCount}) for subscription ${subscription.id}`
  );
}

export async function handleInvoicePaymentSucceeded(
  event: Stripe.Event
): Promise<void> {
  const invoice = event.data.object as Stripe.Invoice;

  if (!invoice.subscription) return;

  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: invoice.subscription as string },
  });

  if (!subscription) return;

  // Reset to active status (may have been past_due)
  await db.subscription.update({
    where: { id: subscription.id },
    data: {
      status: 'active',
      // Update period end from invoice
      currentPeriodEnd: new Date((invoice.lines.data[0]?.period.end ?? 0) * 1000),
    },
  });

  console.info(`Payment succeeded for subscription ${subscription.id}`);
}

Subscription Updated (Plan Change / Cancellation Scheduled)

// src/services/billing/handlers/subscription.ts
import Stripe from 'stripe';
import { db } from '../../../lib/db';

export async function handleSubscriptionUpdated(event: Stripe.Event): Promise<void> {
  const subscription = event.data.object as Stripe.Subscription;
  const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription>;

  const dbSub = await db.subscription.findFirst({
    where: { stripeSubscriptionId: subscription.id },
  });

  if (!dbSub) {
    console.error(`Subscription not found in DB: ${subscription.id}`);
    return;
  }

  // Build update payload
  const updates: Record<string, unknown> = {
    status: subscription.status,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
    updatedAt: new Date(),
  };

  // Track plan change
  if (subscription.items.data[0]?.price.id !== dbSub.stripePriceId) {
    updates.stripePriceId = subscription.items.data[0].price.id;
    updates.stripeItemId = subscription.items.data[0].id;

    console.info(
      `Plan changed for subscription ${subscription.id}: ` +
      `${dbSub.stripePriceId} β†’ ${subscription.items.data[0].price.id}`
    );
  }

  await db.subscription.update({
    where: { id: dbSub.id },
    data: updates,
  });
}

export async function handleSubscriptionDeleted(event: Stripe.Event): Promise<void> {
  const subscription = event.data.object as Stripe.Subscription;

  await db.subscription.updateMany({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status: 'canceled',
      canceledAt: new Date(),
    },
  });

  // Downgrade account to free tier
  const sub = await db.subscription.findFirst({
    where: { stripeSubscriptionId: subscription.id },
    select: { accountId: true },
  });

  if (sub) {
    await db.account.update({
      where: { id: sub.accountId },
      data: { plan: 'free' },
    });
  }

  console.info(`Subscription canceled: ${subscription.id}`);
}

5. Testing Without ngrok

// src/services/billing/__tests__/webhook-router.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Stripe from 'stripe';
import { handleStripeEvent } from '../webhook-router';
import { db } from '../../../lib/db';

vi.mock('../../../lib/db', () => ({
  db: {
    webhookEvent: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
    subscription: { findFirst: vi.fn(), update: vi.fn(), upsert: vi.fn(), updateMany: vi.fn() },
    account: { findFirst: vi.fn(), update: vi.fn() },
    $transaction: vi.fn((fns: any[]) => Promise.all(fns)),
  },
}));

vi.mock('../../../lib/email', () => ({
  emailService: { send: vi.fn().mockResolvedValue({ messageId: 'msg-123' }) },
}));

// Helper: build a mock Stripe event
function makeMockEvent<T extends Stripe.Event.Type>(
  type: T,
  object: object,
  previousAttributes?: object
): Stripe.Event {
  return {
    id: `evt_test_${Date.now()}`,
    type,
    object: 'event',
    api_version: '2025-10-28.acacia',
    created: Math.floor(Date.now() / 1000),
    livemode: false,
    pending_webhooks: 1,
    request: null,
    data: {
      object: object as any,
      previous_attributes: previousAttributes,
    },
  } as Stripe.Event;
}

describe('handleStripeEvent', () => {
  beforeEach(() => {
    vi.mocked(db.webhookEvent.findUnique).mockResolvedValue(null); // Not duplicate
    vi.mocked(db.webhookEvent.create).mockResolvedValue({} as any);
    vi.mocked(db.webhookEvent.update).mockResolvedValue({} as any);
  });

  it('processes invoice.payment_failed and sends dunning email', async () => {
    const { emailService } = await import('../../../lib/email');

    vi.mocked(db.subscription.findFirst).mockResolvedValue({
      id: 'sub-db-1',
      stripeSubscriptionId: 'sub_stripe_1',
      account: {
        owner: { name: 'Test User', email: 'test@example.com' },
      },
    } as any);

    vi.mocked(db.subscription.update).mockResolvedValue({} as any);

    const event = makeMockEvent('invoice.payment_failed', {
      id: 'in_test_1',
      subscription: 'sub_stripe_1',
      customer_email: 'test@example.com',
      amount_due: 9900,
      currency: 'usd',
      attempt_count: 1,
      next_payment_attempt: Math.floor(Date.now() / 1000) + 86400,
    });

    await handleStripeEvent(event);

    expect(emailService.send).toHaveBeenCalledWith(
      expect.objectContaining({ template: 'payment-failed-first' })
    );
    expect(db.subscription.update).toHaveBeenCalledWith(
      expect.objectContaining({ data: { status: 'past_due' } })
    );
  });

  it('skips duplicate events', async () => {
    vi.mocked(db.webhookEvent.findUnique).mockResolvedValue({
      id: 'evt_existing',
      result: {},
      error: null,
    } as any);

    const event = makeMockEvent('invoice.payment_succeeded', {
      subscription: 'sub_stripe_1',
    });

    await handleStripeEvent(event);

    // No DB mutations for subscription should happen
    expect(db.subscription.update).not.toHaveBeenCalled();
  });

  it('ignores unhandled event types gracefully', async () => {
    const event = makeMockEvent('balance.available', { object: 'balance' });
    await expect(handleStripeEvent(event)).resolves.toBeUndefined();
  });
});

Stripe CLI for Local Testing

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login and forward webhooks to local dev server
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger specific events for manual testing
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated

# Test with custom payload
stripe trigger invoice.payment_failed \
  --override invoice:customer_email=test@example.com \
  --override invoice:amount_due=9900

Cost Reference: Webhook Infrastructure

SetupMonthly CostReliability
Single endpoint, no queue$0 extraFragile β€” any downtime = lost events
Webhook idempotency table only$0 extraGood β€” survives retries
+ SQS/Redis queue buffer$5–20/moExcellent β€” decouples processing
+ Dead letter queue + alerting$10–30/moProduction-ready
Managed webhook platform (Svix)$99–499/moBest DX, highest reliability

See Also


Working With Viprasol

Building a SaaS product on Stripe and need webhook handling that survives duplicate delivery, retries, and race conditions? We implement production-grade webhook pipelines with signature verification, idempotent handlers, typed event routers, and full test coverage β€” so your billing logic is reliable from day one.

Talk to our team β†’ | Explore our fintech services β†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building Fintech Solutions?

Payment integrations, trading systems, compliance β€” we build fintech that passes audits.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Trading Software

Building fintech or trading infrastructure?

Viprasol delivers custom trading software β€” MT4/MT5 EAs, TradingView indicators, backtesting frameworks, and real-time execution systems. Trusted by traders and prop firms worldwide.