Skip to main content
All postsIntegrations

Stripe Webhooks 101: Building Reliable Payment Automations

Webhooks are how every serious Stripe integration handles payment state. Here's how to build them reliably, plus the four production-grade patterns we use on every client engagement.

Zach McMorrough
May 6, 2026 10 min read

If you've ever wondered why "payment succeeded" doesn't always update your CRM in time, the answer is almost always a poorly-built webhook handler.

Webhooks are how Stripe (and Shopify, GitHub, Slack, etc.) tell your systems that something happened in real-time. Done well, they're the backbone of every reliable payment automation. Done badly, they're the source of every "we never charged that customer" support ticket.

Here's how to build them properly, and the four production-grade patterns we use on every Stripe integration we ship.

What a Stripe webhook actually is

When something happens in Stripe — a charge succeeds, a subscription renews, an invoice goes overdue — Stripe POSTs a JSON payload to a URL you've configured. The payload describes the event:

{
  "id": "evt_1Abc123...",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_...",
      "amount_total": 5000,
      "customer_details": {
        "email": "buyer@example.com"
      },
      ...
    }
  },
  "created": 1745300000
}

You receive that POST, parse it, and act on it. The "act on it" part is where most webhook integrations go sideways.

The five rules of reliable webhooks

Production-grade webhook handlers, regardless of vendor, share five characteristics. We won't ship a Stripe integration without all five.

1. Verify the signature

Stripe signs every webhook with your endpoint's signing secret. Verify the signature before doing anything else. If you don't, anyone who knows your URL can send forged payloads.

// Node example using stripe-node
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const sig = req.headers["stripe-signature"];
const event = stripe.webhooks.constructEvent(
  rawBody,
  sig,
  process.env.STRIPE_WEBHOOK_SECRET
);

Critical: you need the raw request body, not parsed JSON. Many frameworks parse the body automatically; you have to opt out for webhook routes.

2. Return 200 fast

Stripe expects a 200 response within ~30 seconds. If you don't respond fast, Stripe assumes the delivery failed and retries. If your handler is doing slow work (sending emails, updating multiple systems, calling third-party APIs), you'll start getting duplicate deliveries.

The pattern: acknowledge fast, process async.

export async function POST(req) {
  const event = verifySignature(req);
  // Enqueue the work, don't do it inline
  await queue.add("process-stripe-event", event);
  return new Response("ok", { status: 200 });
}

For low-volume integrations, "queue" can be as simple as a database table you poll. For higher volumes, use SQS, BullMQ, or n8n's webhook replay queue.

3. Handle duplicates (idempotency)

Stripe will deliver the same event more than once. Network blips, your server returning 500, your server taking too long — all trigger retries. Your handler must process the same event twice without creating duplicate records.

The cleanest pattern:

const existing = await db.events.findUnique({ where: { stripe_event_id: event.id } });
if (existing) return new Response("already processed", { status: 200 });

await db.events.create({ data: { stripe_event_id: event.id, type: event.type } });
await actuallyProcessEvent(event);

Stripe's event.id is unique and stable. Use it as the idempotency key.

4. Process out-of-order safely

Webhooks don't always arrive in the order events happened. You might receive invoice.paid before you receive invoice.created. Don't assume sequential ordering.

The fix: always re-fetch the canonical object from the Stripe API before acting, rather than trusting the payload alone.

// Don't trust the payload's amount
// Re-fetch the invoice to get the current state
const invoice = await stripe.invoices.retrieve(event.data.object.id);
if (invoice.status === "paid") { /* ... */ }

This is also how you survive Stripe issuing corrections (rare but it happens).

5. Alert on failures

A webhook that silently fails is worse than one that doesn't exist — you think payments are syncing when they aren't.

Every webhook handler should:

  • Log every event ID + outcome to an immutable store
  • Alert (Slack, email, PagerDuty) on processing failures
  • Surface a dashboard showing webhook lag and error rate

The four patterns we use on every Stripe build

Beyond the rules, these four patterns cover ~80% of real-world Stripe webhook needs.

Pattern 1: Payment → close the loop

checkout.session.completed or invoice.payment_succeeded arrives → update the CRM opportunity / customer record with the paid amount + date → trigger downstream actions (provisioning, kickoff email, Slack ping). The classic "payment received project unlock" play.

Pattern 2: Subscription lifecycle sync

customer.subscription.created, customer.subscription.updated, customer.subscription.deleted → update the customer's CRM record with subscription status, plan, MRR, renewal date. Powers customer health scoring and renewal alerts.

Pattern 3: Failed payment dunning

invoice.payment_failed → start a structured dunning sequence: customer email at day 1, escalation at day 3, account-level alert at day 7. Keeps revenue from leaking quietly.

Pattern 4: Dispute orchestration

charge.dispute.created → create a case in your ops tool (Linear, Jira, Notion), assign the right team member, attach the relevant invoice + customer history. Forces structured response instead of someone seeing the email a week later.

When you need the secret key vs the publishable key

Quick reference, since we get this question every week:

  • Publishable key (pk_test_… or pk_live_…): safe to expose in client-side code. Used by Stripe.js / Elements / Checkout client libraries to identify your account.
  • Secret key (sk_test_… / sk_live_…): server-side only. Required to create Checkout Sessions, refund charges, modify subscriptions — anything that writes to Stripe.
  • Restricted key (rk_test_… / rk_live_…): server-side, but scoped to specific permissions. Use these in production wherever possible. Limits blast radius if the key leaks.

For webhook signature verification, you use the webhook endpoint's signing secret, which is different from either of the above. You find it in dashboard.stripe.com/webhooks → click your endpoint.

What we build

We've shipped Stripe integrations for B2B SaaS companies, professional services firms, marketplaces, and subscription businesses. The patterns above are battle-tested across all four.

If you've got a Stripe integration that breaks at month-end, or you're standing up Stripe for the first time and want to do it right, browse the catalogue for the specific plays we ship — or book a free 30-minute discovery call and we'll scope yours.

For a deeper dive on connecting Stripe to a CRM specifically, our Salesforce-Stripe invoicing post walks through every approach.

Want us to automate this for you?

Book a 30-minute discovery call — no pressure, no commitment.