Back to Blog
Stripepaymentstutorialintegration

The Complete Stripe Webhook Integration Guide (2025 Edition)

Step-by-step: set up Stripe webhooks locally with the Stripe CLI, implement HMAC-SHA256 verification, handle all critical payment events, and achieve 99.9% delivery reliability.

F
Finn Eriksson
Payments Engineer
August 10, 2025
13 min read

Stripe's webhook system is one of the most well-designed in the industry. It has clear documentation, built-in retry logic (it retries for 3 days), a solid test mode, and a CLI for local development.

But there are still plenty of ways to get it wrong — missed events, duplicate charges, ignored retries. This guide covers everything from local setup to production-grade reliability.


Why Stripe Webhooks Matter

Stripe's synchronous API calls (creating PaymentIntents, retrieving Customers) are deterministic. Webhooks are how Stripe tells you about asynchronous events:

Without webhooksWith webhooks
Poll for payment statusGet notified instantly
Miss failed paymentsReact to invoice.payment_failed immediately
Manually check subscription statusHandle customer.subscription.deleted
Delayed fulfillmentTrigger fulfillment on payment_intent.succeeded

The most critical webhooks for a typical SaaS:

EventWhy it matters
payment_intent.succeededProvision product, trigger fulfillment
payment_intent.payment_failedNotify customer, initiate dunning
customer.subscription.createdProvision subscription features
customer.subscription.updatedHandle plan changes
customer.subscription.deletedRevoke access
invoice.payment_succeededRecord payment, extend subscription
invoice.payment_failedStart dunning process
customer.createdSync to CRM
charge.dispute.createdAlert risk team, freeze account

Local Development Setup

Install the Stripe CLI

bash
# macOS
brew install stripe/stripe-cli/stripe

# Linux
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install stripe

# Windows
scoop install stripe

Forward Webhooks Locally

bash
# Log in to Stripe CLI
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:8080/webhooks/stripe

The CLI will print a webhook signing secret like:

> Ready! Your webhook signing secret is whsec_test_...

Use this as your STRIPE_WEBHOOK_SECRET env var in local development.

Trigger Test Events

bash
# Trigger a payment_intent.succeeded event
stripe trigger payment_intent.succeeded

# Trigger a subscription deletion
stripe trigger customer.subscription.deleted

# List all available events
stripe trigger --help

Implementing the Webhook Handler

Go Implementation

go
package handler

import (
    "encoding/json"
    "io"
    "net/http"
    "os"

    "github.com/stripe/stripe-go/v76/webhook"
)

type StripeHandler struct {
    webhookSecret string
    // ... your services
}

func NewStripeHandler() *StripeHandler {
    return &StripeHandler{
        webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
    }
}

func (h *StripeHandler) Handle(w http.ResponseWriter, r *http.Request) {
    // Read body — important: must be raw bytes for signature verification
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", 400)
        return
    }

    // Verify signature using Stripe's official library
    event, err := webhook.ConstructEvent(
        body,
        r.Header.Get("Stripe-Signature"),
        h.webhookSecret,
    )
    if err != nil {
        // Return 400 for signature mismatch
        // Note: Stripe treats 4xx as permanent failure (no retry)
        // But signature failure should 400 — don't retry fake events
        http.Error(w, "invalid signature", 400)
        return
    }

    // ⚡ RESPOND IMMEDIATELY — before processing
    w.WriteHeader(200)

    // Process asynchronously
    go h.processEvent(event)
}

func (h *StripeHandler) processEvent(event stripe.Event) {
    switch event.Type {
    case "payment_intent.succeeded":
        h.handlePaymentSucceeded(event)
    case "customer.subscription.deleted":
        h.handleSubscriptionCanceled(event)
    case "invoice.payment_failed":
        h.handleInvoicePaymentFailed(event)
    // ... etc
    default:
        // Log unknown events for monitoring
        log.Info("unhandled stripe event", "type", event.Type)
    }
}

Node.js / TypeScript Implementation

typescript
import Stripe from "stripe";
import express from "express";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// IMPORTANT: raw body middleware — must come before json() for this route
app.post(
	"/webhooks/stripe",
	express.raw({ type: "application/json" }),
	async (req, res) => {
		const sig = req.headers["stripe-signature"]!;

		let event: Stripe.Event;
		try {
			event = stripe.webhooks.constructEvent(
				req.body,
				sig,
				process.env.STRIPE_WEBHOOK_SECRET!,
			);
		} catch (err) {
			console.error("Signature verification failed:", err);
			return res.status(400).send("Invalid signature");
		}

		// Respond immediately
		res.status(200).json({ received: true });

		// Process in background
		await processEvent(event);
	},
);

Critical Node.js gotcha: You must use express.raw() for the webhook route — not express.json(). Stripe's signature verification requires the raw request body before JSON parsing.


Idempotency: Handling Duplicate Events

Stripe retries failed webhooks. You WILL receive duplicates. Here's how to handle them:

go
func (h *StripeHandler) handlePaymentSucceeded(event stripe.Event) {
    var pi stripe.PaymentIntent
    if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
        log.Error("failed to parse payment intent", "error", err)
        return
    }

    // Use Stripe Event ID as idempotency key
    key := "stripe_event:" + event.ID
    if already, _ := h.cache.Get(key); already != nil {
        log.Info("skipping duplicate event", "event_id", event.ID)
        return
    }

    // Mark as processing (24h TTL handles crash recovery)
    h.cache.SetEx(key, 24*time.Hour, "processing")

    // Do the work
    if err := h.db.CreateOrder(pi.ID, pi.Amount); err != nil {
        h.cache.Delete(key)  // Allow retry on DB failure
        return
    }

    h.cache.Set(key, "done")
}

Or use your database if you don't have Redis:

sql
-- Add idempotency table
CREATE TABLE processed_events (
    event_id    TEXT PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
go
_, err := db.Exec(`
    INSERT INTO processed_events (event_id) VALUES ($1)
    ON CONFLICT (event_id) DO NOTHING
`, event.ID)

if err == nil && rowsAffected == 0 {
    // Duplicate — already processed
    return
}

Critical Payment Events and How to Handle Them

payment_intent.succeeded

go
func handlePaymentSucceeded(event stripe.Event) {
    var pi stripe.PaymentIntent
    json.Unmarshal(event.Data.Raw, &pi)

    // Fulfill the order
    orderID := pi.Metadata["order_id"]  // set this when creating the PI
    db.UpdateOrderStatus(orderID, "paid")
    fulfillmentQueue.Enqueue(orderID)
    emailQueue.Enqueue("order_confirmation", pi.ReceiptEmail)
}

customer.subscription.deleted

This fires both when a subscription is cancelled immediately AND when a trial expires without payment.

go
func handleSubscriptionDeleted(event stripe.Event) {
    var sub stripe.Subscription
    json.Unmarshal(event.Data.Raw, &sub)

    customerID := sub.Customer.ID

    // Revoke feature access
    db.SetCustomerPlan(customerID, "free")

    // Send cancellation email (but not if trial expired — check sub.TrialEnd)
    if sub.TrialEnd == 0 {
        emailQueue.Enqueue("cancellation_confirmation", customerID)
    } else {
        emailQueue.Enqueue("trial_expired", customerID)
    }
}

invoice.payment_failed

go
func handleInvoicePaymentFailed(event stripe.Event) {
    var invoice stripe.Invoice
    json.Unmarshal(event.Data.Raw, &invoice)

    // Check how many times this has failed
    attemptCount := invoice.AttemptCount

    switch {
    case attemptCount == 1:
        emailQueue.Enqueue("payment_failed_first", invoice.CustomerEmail)
    case attemptCount == 2:
        emailQueue.Enqueue("payment_failed_second", invoice.CustomerEmail)
    case attemptCount >= 3:
        // Consider downgrading or pausing the account
        emailQueue.Enqueue("payment_failed_final", invoice.CustomerEmail)
        db.SetCustomerStatus(invoice.Customer.ID, "past_due")
    }
}

Stripe's Retry Behavior

Understanding Stripe's retry schedule helps you design your handler:

AttemptTiming
1Immediately after event
2~1 hour later
3~12 hours later
4~24 hours later
5–8Daily
Total3 days of retries

Stripe retries when your endpoint returns 5xx or times out. It does NOT retry on 4xx (except 429).

If your endpoint becomes permanently unavailable, Stripe will eventually disable it and email you.


Reliability: Using GetHook as a Webhook Gateway

The approach above works well for simple use cases. For production reliability — especially if you have multiple Stripe integrations, fan-out routing, or need event replay — using GetHook as a gateway gives you:

Stripe ──→ GetHook ingest endpoint ──→ (verified + queued) ──→ Your handler

Benefits:

  • Your server can restart — GetHook buffers events during deploys
  • Fan-out — route payment_intent.succeeded to billing, fulfillment, and analytics independently
  • Replay — re-deliver any event from the last 90 days
  • Delivery logs — per-event delivery timeline with HTTP responses

To set up Stripe with GetHook:

  1. Create a Source in GetHook with auth_mode: stripe (uses whsec_ format verification)
  2. Set the GetHook ingest URL as your Stripe webhook endpoint
  3. Create Destinations for each internal service
  4. Create Routes to map event types to destinations
bash
# Create a Stripe source
curl -X POST https://api.gethook.to/v1/sources \
  -H "Authorization: Bearer hk_..." \
  -d '{"name": "Stripe Production", "auth_mode": "stripe"}'

# Create a destination
curl -X POST https://api.gethook.to/v1/destinations \
  -H "Authorization: Bearer hk_..." \
  -d '{"name": "Billing Service", "url": "https://api.yourapp.com/internal/billing/webhook"}'

# Create a route
curl -X POST https://api.gethook.to/v1/routes \
  -H "Authorization: Bearer hk_..." \
  -d '{"source_id": "...", "destination_id": "...", "event_type_pattern": "payment_intent.*"}'

Your internal services now receive pre-verified events with GetHook's retry and dead-letter infrastructure behind them.


Testing in Production

Stripe's test mode is excellent for development, but you should also have a process for testing in production:

  1. Use Stripe's webhook test feature — Dashboard → Developers → Webhooks → Send test webhook
  2. Make idempotent test events — Create real test payments in live mode using Stripe's test cards
  3. Monitor dead-letter queue — Set up alerts for DLQ growth after any deployment
  4. Load test — Use stripe trigger in a loop to simulate burst delivery

Checklist

  • Stripe CLI set up for local development
  • STRIPE_WEBHOOK_SECRET set in environment (separate secrets for test/live)
  • Raw body middleware before JSON parser (Node.js)
  • Signature verification on every request
  • Respond 200 before processing
  • Idempotency keys for all mutation handlers
  • Handlers for all critical events (payment_intent.*, subscription.*, invoice.*)
  • Dead-letter queue with monitoring
  • Test webhook delivery after every deploy

Set up Stripe with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.