Back to Blog
πŸ’°Fintech

Stripe Webhooks 2026: Handling, Signature Verification, Idempotency

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
13 min read
Updated 2026

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));
}

stripe - Stripe Webhooks 2026: Handling, Signature Verification, Idempotency

🏦 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

> **Quick answer.** Stripe webhook handlers must process each event exactly once and survive retries that continue for 72 hours. Verify the HMAC signature to reject forgeries, apply an idempotency check to skip duplicate deliveries, route by event type, then return 200, preventing double-charges and missed subscription changes under concurrent delivery.
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

Explore More


What Viprasol Offers

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 β†’

How to Make a Stripe Webhook Reliable in Production

A production-grade Stripe webhook needs three guarantees working together. First, signature verification: validate every incoming request against the signing secret from your Stripe webhook endpoint, so forged or replayed payloads are rejected before any business logic runs. Second, idempotency: store each event ID you process and skip duplicates, because Stripe retries delivery whenever your endpoint times out or returns a non-2xx status. Third, fast acknowledgement: respond 200 immediately, then hand the work to a background queue rather than blocking inside the request.

Treat the webhook handler as untrusted input and the database as your source of truth, re-fetching objects from the Stripe API when amounts matter. Our senior engineers build and fully own these integrations end to end, so your payment events stay consistent under retries, failures, and traffic spikes.

Stripe webhook secret, events and endpoint setup

To handle Stripe webhooks reliably you need three things wired correctly:

  • Webhook endpoint β€” a public HTTPS route (for example /api/stripe/webhook) registered in the Stripe Dashboard. This is your "webhook endpoint": the URL Stripe POSTs events to.
  • Stripe webhook secret β€” the signing secret (STRIPE_WEBHOOK_SECRET) Stripe gives you per endpoint. Use it to verify every request signature so attackers cannot forge events.
  • Event handling β€” subscribe only to the Stripe webhook events you need (checkout.session.completed, payment_intent.succeeded, invoice.paid, etc.) and make each handler idempotent, because Stripe can retry.

A robust handler verifies the signature with the webhook secret first, returns 200 fast, then processes asynchronously so a slow job never triggers a Stripe retry storm.

Stripe webhooks FAQ

What is a webhook endpoint in Stripe? A public HTTPS URL you register in Stripe; Stripe sends event payloads (POST requests) to it when things happen in your account.

What is the Stripe webhook secret for? It signs each event so you can verify the request genuinely came from Stripe and was not tampered with. Store it as STRIPE_WEBHOOK_SECRET.

Which Stripe webhook events should I listen to? Only the ones your app acts on β€” commonly checkout.session.completed, payment_intent.succeeded and invoice.paid. Fewer events means less noise and fewer bugs.

Why are my Stripe webhooks firing twice? Stripe retries until it gets a 200, and can deliver duplicates β€” make handlers idempotent by tracking processed event IDs.

Need payments built right? See our custom software development services.

stripewebhookstypescriptnode.jspaymentsbackendfintech

External Resources

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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.