Skip to main content

Stripe Webhooks Integration Example: Handle Payments with Signature Verification

A Stripe webhooks integration lets your application receive real-time notifications when events happen in your Stripe account — payments succeed, subscriptions renew, disputes are opened, and more. Instead of polling the Stripe API, webhooks push real-time event data to your endpoint as a JSON payload.

Stripe webhooks integration with Codehooks.io

The problem? Setting up Stripe webhooks correctly takes time. You need to verify signatures, handle retries, manage event ordering, and ensure reliability. Building custom retry logic alone can take days.

The solution? Stop building custom retry logic—Codehooks handles webhook reliability at the platform level. Deploy a production-ready Stripe webhook handler in under 5 minutes using our ready-made template, with automatic retries and signature verification built in.

New to webhooks?

If you're new to webhooks, check out our guide on What are webhooks? to understand the fundamentals before diving into this Stripe-specific implementation.

Prerequisites

Before you begin, sign up for a free Codehooks.io account and install the CLI:

npm install -g codehooks

Quick Deploy with Template

The fastest way to get started is using the Codehooks Stripe webhook template:

coho create mystripehandler --template webhook-stripe-minimal

You'll see output like:

Creating project: mystripehandler
Project created successfully!

Your API endpoints:
https://mystripehandler-a1b2.api.codehooks.io/dev

Then install, deploy, and configure:

cd mystripehandler
npm install
coho deploy
coho set-env STRIPE_SECRET_KEY sk_xxxxx --encrypted
coho set-env STRIPE_WEBHOOK_SECRET whsec_xxxxx --encrypted

That's it! The template includes signature verification, event handling, and database storage out of the box.

Use the Template

The webhook-stripe-minimal template is production-ready with proper signature verification, error handling, and event storage. Browse all available templates in the templates repository.

Custom Implementation

If you prefer to build from scratch or customize further, create a new project and add your Stripe webhook handler code to index.js:

import { app } from 'codehooks-js';
import Stripe from 'stripe';

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

// Stripe webhook endpoint with signature verification
app.post('/stripe/webhooks', async (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

let event;

try {
// Verify the webhook signature
event = stripe.webhooks.constructEvent(
req.rawBody,
sig,
endpointSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).json({ error: 'Invalid signature' });
}

// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log('Payment succeeded:', paymentIntent.id);
// Update your database, send confirmation email, etc.
break;

case 'invoice.paid':
const invoice = event.data.object;
console.log('Invoice paid:', invoice.id);
// Extend subscription, update records
break;

case 'customer.subscription.created':
const subscription = event.data.object;
console.log('New subscription:', subscription.id);
// Provision access, welcome email
break;

case 'customer.subscription.deleted':
const canceledSub = event.data.object;
console.log('Subscription canceled:', canceledSub.id);
// Revoke access, send win-back email
break;

default:
console.log(`Unhandled event type: ${event.type}`);
}

res.json({ received: true });
});

export default app.init();

Deploy:

coho deploy
Project: mystripehandler-a1b2  Space: dev
Deployed Codehook successfully! 🙌
Finding your API endpoint

Run coho info to see your API endpoints and tokens. Your webhook URL will be: https://mystripehandler-a1b2.api.codehooks.io/dev/stripe/webhooks

Configure Stripe Dashboard

  1. Go to Stripe Dashboard → Webhooks
  2. Click Add endpoint
  3. Enter your Codehooks URL: https://your-app.api.codehooks.io/dev/stripe/webhooks
  4. Select events to listen for (or choose "All events")
  5. Copy the Signing secret and add it to your Codehooks secrets:
coho set-env STRIPE_WEBHOOK_SECRET whsec_xxxxx --encrypted
coho set-env STRIPE_SECRET_KEY sk_xxxxx --encrypted

Finding Your STRIPE_WEBHOOK_SECRET

Your Stripe webhook signing secret (starting with whsec_) is required for signature verification. Here's how to find it:

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Click on your webhook endpoint (or create one if you haven't already)
  3. In the endpoint details, find Signing secret
  4. Click Reveal to show the secret (it starts with whsec_)
  5. Copy the secret and add it to your Codehooks environment:
coho set-env STRIPE_WEBHOOK_SECRET whsec_your_secret_here --encrypted
Keep Your Secret Secure

Never commit your STRIPE_WEBHOOK_SECRET to version control. The --encrypted flag ensures it's stored securely in Codehooks. Each webhook endpoint has its own unique signing secret—test and live mode endpoints have different secrets.

invoice.paid vs checkout.session.completed

One of the most common questions when building Stripe integrations is which event to listen for: invoice.paid or checkout.session.completed? Here's when to use each:

checkout.session.completed

Use this event for one-time payments and initial Checkout sessions:

  • Triggers when a customer completes Stripe Checkout
  • Best for one-time purchases, donations, or initial subscription signups
  • Contains the full Checkout Session object with customer and payment details
case 'checkout.session.completed':
const session = event.data.object;
if (session.mode === 'payment') {
// One-time payment - fulfill the order
await fulfillOrder(session);
} else if (session.mode === 'subscription') {
// Initial subscription - provision access
await provisionSubscription(session);
}
break;

invoice.paid

Use this event for subscriptions and recurring payments:

  • Triggers every time an invoice is successfully paid
  • Covers both initial subscription payments AND renewals
  • Essential for SaaS apps with recurring billing
case 'invoice.paid':
const invoice = event.data.object;
// Extend or renew subscription access
await extendSubscriptionAccess(invoice.subscription, invoice.customer);
break;

Recommendation for SaaS Apps

For most SaaS applications, listen to invoice.paid as your primary event. It fires for:

  • Initial subscription payments
  • Monthly/yearly renewals
  • Plan upgrades that generate prorated invoices
  • Manual invoice payments

If you need to capture the exact moment a customer completes checkout (for analytics, welcome emails, etc.), also listen to checkout.session.completed.

Typical SaaS setup:

  • checkout.session.completed → Send welcome email, track conversion
  • invoice.paid → Extend subscription access, update billing records
  • customer.subscription.deleted → Revoke access

Common Stripe Webhook Events

EventDescriptionCommon Use Case
payment_intent.succeededPayment completed successfullyUpdate order status, send receipt
payment_intent.payment_failedPayment attempt failedNotify customer, retry logic
invoice.paidInvoice payment succeededExtend subscription access
invoice.payment_failedInvoice payment failedSend dunning email
customer.subscription.createdNew subscription startedProvision access
customer.subscription.updatedSubscription changedUpdate plan limits
customer.subscription.deletedSubscription canceledRevoke access
checkout.session.completedCheckout completedFulfill order
charge.dispute.createdDispute openedAlert team, gather evidence

Event Payload Types: Snapshot vs Thin Events

Stripe offers two event payload formats:

  • Snapshot events (default): Include a full copy of the affected object at the time of the event. This is the traditional format most integrations use.
  • Thin events: Include only the event type and object ID. Your handler must fetch the current object state via the Stripe API.

For most use cases, snapshot events are recommended as they provide all the data you need without additional API calls. Use thin events when you always need the latest state or want to reduce payload size.

Stripe Delivery and Retry Behavior

Stripe attempts to deliver webhooks for up to 3 days with exponential backoff:

  • If your endpoint returns a non-2xx response, Stripe retries
  • Events may arrive out of order (use created timestamp for ordering)
  • The same event may be delivered multiple times (use event.id for idempotency)

Stripe Webhook Signature Verification

Stripe signs all webhook payloads with a signature header (Stripe-Signature). Always verify this signature to ensure the webhook came from Stripe:

import Stripe from 'stripe';

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

function verifyStripeWebhook(req) {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

try {
return stripe.webhooks.constructEvent(
req.rawBody, // Raw request body (not parsed JSON)
sig,
endpointSecret
);
} catch (err) {
throw new Error(`Webhook Error: ${err.message}`);
}
}

Important: Use req.rawBody (the raw string), not req.body (parsed JSON). Signature verification requires the exact bytes Stripe sent.

Monitoring Stripe Webhooks

Effective monitoring helps you catch issues before they impact customers. Here's how to monitor your Stripe webhooks:

Stripe Dashboard Monitoring

The Stripe Dashboard provides built-in webhook monitoring:

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Click on your endpoint to see:
    • Delivery success rate — percentage of successfully delivered webhooks
    • Recent deliveries — list of recent webhook attempts with status codes
    • Failed events — webhooks that failed and their retry status

Failed webhooks show the HTTP status code and response body, making debugging straightforward.

Real-Time Logs with Codehooks

Codehooks automatically logs all incoming webhooks. Monitor them in real-time:

coho logs --follow

This streams all webhook activity, including:

  • Incoming requests with timestamps
  • Your application's console.log output
  • Errors and stack traces

For a specific number of recent log entries:

coho logs --tail 50

What to Monitor

Key metrics to watch:

  • Signature verification failures — may indicate misconfigured secrets or attack attempts
  • Processing errors — exceptions in your event handling code
  • Response times — ensure you return 200 within 20 seconds
  • Event types — unexpected events may indicate Stripe configuration changes

Best Practices for Stripe Webhooks

1. Return 200 Quickly

Stripe expects a 2xx response within 20 seconds. Store the event first, then return immediately. Process complex logic later:

import { app, Datastore } from 'codehooks-js';

app.post('/stripe/webhooks', async (req, res) => {
// Verify signature first
const event = verifyStripeWebhook(req);

const conn = await Datastore.open();

// Check if we already have this event (idempotency)
try {
await conn.getOne('stripe_events', event.id);
return res.json({ received: true }); // Already exists
} catch (e) {
// Not found, continue to store
}

// Store new event
await conn.insertOne('stripe_events', {
_id: event.id,
type: event.type,
rawBody: req.rawBody, // Store raw body for replay capability
created: event.created,
processedAt: null
});

res.json({ received: true });
});

2. Handle Duplicate Events

Stripe may send the same event multiple times. Use idempotency:

import { Datastore } from 'codehooks-js';

async function handleStripeEvent(event) {
const conn = await Datastore.open();

// Check if we've already processed this event
try {
await conn.getOne('processed_events', event.id);
console.log('Event already processed:', event.id);
return;
} catch (e) {
// Not found, continue to process
}

// Process the event
await processEvent(event);

// Mark as processed
await conn.insertOne('processed_events', {
_id: event.id,
type: event.type,
processedAt: new Date().toISOString()
});
}

3. Handle Event Ordering

Events may arrive out of order. Check timestamps or use created field:

case 'customer.subscription.updated':
const conn = await Datastore.open();
const subscription = event.data.object;

let existingRecord = null;
try {
existingRecord = await conn.getOne('subscriptions', subscription.id);
} catch (e) {
// Not found, will create new record
}

// Only update if this event is newer
if (!existingRecord || event.created > existingRecord.lastEventTime) {
await conn.updateOne('subscriptions', subscription.id, {
$set: {
status: subscription.status,
lastEventTime: event.created
}
});
}
break;

Why Use Codehooks.io for Stripe Webhooks?

FeatureDIY SetupCodehooks.io
Setup timeHours/Days5 minutes
Signature verificationManual implementationBuilt-in support
ScalingConfigure infrastructureAutomatic
RetriesBuild retry logicPlatform handles
MonitoringSet up loggingBuilt-in logs
CostServer costs + maintenanceFree tier available

Stripe Webhooks Integration FAQ

Common questions about Stripe webhook integration

How do I build a Stripe webhooks integration?
A Stripe webhooks integration requires three things: (1) An endpoint URL that receives POST requests from Stripe, (2) Signature verification using your webhook signing secret to ensure requests are genuine, and (3) Event handling logic for the specific events you care about (like payment_intent.succeeded). With Codehooks.io, you can deploy a complete integration in under 5 minutes using our ready-made template.
How do I verify Stripe webhook signatures?
Use the stripe.webhooks.constructEvent() method with your webhook signing secret. This verifies the Stripe-Signature header matches the payload. Always use the raw request body (not parsed JSON) for verification. The signing secret is found in your Stripe Dashboard under Webhooks → your endpoint → Signing secret.
Why am I getting 'No signatures found matching the expected signature' error?
This usually happens when: (1) You're using the wrong webhook secret (test vs live mode), (2) The request body was parsed before verification - use req.rawBody not req.body, or (3) The webhook was sent to a different endpoint than configured. Double-check your STRIPE_WEBHOOK_SECRET environment variable.
How do I test my Stripe webhooks integration?
With Codehooks.io, you don't need local testing or tunneling tools. Just run coho deploy and you have a live public URL in seconds. Configure that URL in the Stripe Dashboard, then use Stripe's 'Send test webhook' button or trigger real test events in test mode. Check logs with coho logs.
What happens if my webhook endpoint is down?
Stripe retries failed webhook deliveries for up to 3 days using exponential backoff. The retry schedule is: immediately, 5 min, 30 min, 2 hours, 5 hours, 10 hours, then every 12 hours. You can also manually retry from the Stripe Dashboard under Webhooks → Failed events.
Should I handle all Stripe webhook events?
No, only subscribe to events your application needs. Common essential events include payment_intent.succeeded, invoice.paid, customer.subscription.created/updated/deleted, and checkout.session.completed. Handling unnecessary events wastes resources and adds complexity.
How do I handle duplicate Stripe webhook events?
Stripe may send the same event multiple times. Implement idempotency by storing processed event IDs: check if event.id exists in your database before processing, and save it after successful handling. This prevents duplicate order fulfillment or email sends.
What's the difference between test and live webhook endpoints?
You need separate webhook endpoints for test mode (using test API keys) and live mode (using live API keys). Each has its own signing secret. Configure both in the Stripe Dashboard. Use test mode for development and staging environments.
How quickly must I respond to Stripe webhooks?
Return a 2xx response within 20 seconds, or Stripe considers the delivery failed and will retry. For complex processing, return 200 immediately after signature verification, then process the event asynchronously using a queue or background job.
What's the difference between invoice.paid and checkout.session.completed?
Use checkout.session.completed for one-time payments and capturing the initial checkout moment. Use invoice.paid for subscriptions and recurring payments—it fires for both initial and renewal payments. For SaaS apps, listen to invoice.paid as your primary event since it covers all subscription billing scenarios. See the invoice.paid vs checkout.session.completed section above for detailed guidance.
How do I find my Stripe webhook signing secret?
Your signing secret starts with whsec_. Find it in Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret → Reveal. Add it to Codehooks with: coho set-env STRIPE_WEBHOOK_SECRET whsec_xxx --encrypted. See Finding Your STRIPE_WEBHOOK_SECRET for step-by-step instructions.
Does Codehooks handle Stripe webhook retries automatically?
Codehooks provides platform-level reliability for your webhook endpoint—automatic scaling, high availability, and built-in logging. Stripe handles its own retry logic (up to 3 days with exponential backoff) for failed deliveries. Together, you get reliable webhook handling without building custom retry infrastructure.