Skip to content

Stripe Webhook

Receive Stripe events securely on PaaS — signature verification, idempotency, retries. This recipe shows the full path: webhook secret → handler code → live test.

Overview

Stripe POSTs JSON events to a URL you control whenever something happens (payment succeeded, customer created, subscription cancelled). The HTTP body is signed with a shared secret so you can verify it came from Stripe and wasn't tampered with.

sequenceDiagram
  participant Stripe
  participant PaaS
  participant App
  participant Postgres
  Stripe->>PaaS: POST /webhooks/stripe (signed)
  PaaS->>App: forwarded
  App->>App: verify signature
  App->>Postgres: idempotency check
  App-->>Stripe: 200 OK
  App->>Postgres: process event

Prerequisites

  • A PaaS app exposing an HTTPS URL (https://<app>.runtime.di2amp.com)
  • A Stripe account with API access
  • A request body parser that preserves the raw body (signature verification needs the raw bytes)

Step 1 — Configure the webhook in Stripe

  1. Go to https://dashboard.stripe.com/webhooks
  2. Click Add endpoint
  3. Endpoint URL: https://<app>.runtime.di2amp.com/webhooks/stripe
  4. Select the events you care about (e.g. checkout.session.completed, payment_intent.succeeded, invoice.paid)
  5. Click Add endpoint — Stripe shows a signing secret like whsec_AbCdEf...

Step 2 — Store the secret in PaaS

paas secrets:set STRIPE_WEBHOOK_SECRET=whsec_AbCdEf...
paas secrets:set STRIPE_API_KEY=sk_live_...

The next deploy injects both into your app's environment.

Step 3 — Handler code

Install:

npm install stripe express

app.js:

const express = require('express');
const Stripe = require('stripe');

const stripe = Stripe(process.env.STRIPE_API_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();

// Stripe needs the RAW body, not parsed JSON.
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
    } catch (err) {
      console.error('signature mismatch:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Idempotency: persist event.id, skip if already processed.
    const handled = await wasAlreadyProcessed(event.id);
    if (handled) return res.status(200).send('already processed');

    switch (event.type) {
      case 'checkout.session.completed':
        await fulfillOrder(event.data.object);
        break;
      case 'payment_intent.payment_failed':
        await alertCustomer(event.data.object);
        break;
      default:
        console.log(`unhandled type ${event.type}`);
    }

    await markProcessed(event.id);
    return res.status(200).send('ok');
  }
);

app.listen(process.env.PORT || 3000);

Install:

pip install stripe flask

app.py:

import os
import stripe
from flask import Flask, request, abort

stripe.api_key = os.environ['STRIPE_API_KEY']
webhook_secret = os.environ['STRIPE_WEBHOOK_SECRET']
app = Flask(__name__)

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.data           # raw bytes
    sig_header = request.headers.get('Stripe-Signature')
    try:
        event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
    except stripe.error.SignatureVerificationError as e:
        app.logger.error(f"signature mismatch: {e}")
        abort(400)

    if was_already_processed(event['id']):
        return 'already processed', 200

    if event['type'] == 'checkout.session.completed':
        fulfill_order(event['data']['object'])
    elif event['type'] == 'payment_intent.payment_failed':
        alert_customer(event['data']['object'])
    else:
        app.logger.info(f"unhandled type {event['type']}")

    mark_processed(event['id'])
    return 'ok', 200

Step 4 — Idempotency

Stripe retries a webhook up to 3 days if you don't return 2xx. Without idempotency, you'll process the same checkout.session.completed 3+ times.

Persist the event.id (varchar, primary key) in your database the first time you process it. On every subsequent call, check existence first:

CREATE TABLE stripe_events (
  id            VARCHAR(255) PRIMARY KEY,
  type          VARCHAR(64)  NOT NULL,
  processed_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);
async function wasAlreadyProcessed(eventId) {
  const r = await pool.query(
    'SELECT 1 FROM stripe_events WHERE id = $1', [eventId]);
  return r.rowCount > 0;
}

async function markProcessed(eventId) {
  await pool.query(
    'INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING',
    [eventId, event.type]);
}

Step 5 — Deploy + test

git add . && git commit -m "feat: stripe webhook handler"
git push paas main
paas logs --tail

Trigger a test event from Stripe:

stripe trigger checkout.session.completed \
  --add request:url=https://<app>.runtime.di2amp.com/webhooks/stripe

Or from the Stripe dashboard: Webhooks → endpoint → Send test webhook.

In your paas logs --tail output, you should see:

2026-05-04T14:12:32Z stripe webhook event evt_1Aa... (checkout.session.completed)
2026-05-04T14:12:32Z fulfilled order order_xyz

Debugging webhook delivery

Stripe shows every delivery attempt + payload + response in the dashboard:

Developers → Webhooks → <endpoint> → Recent events

Common failure modes:

Symptom Cause Fix
400 Webhook Error: No signatures found Body parser stripped raw bytes Use express.raw() (Node) / request.data (Flask) — not express.json()
400 signature mismatch Wrong STRIPE_WEBHOOK_SECRET Re-copy from Stripe dashboard, paas secrets:set again
Stripe retries 3+ times Handler returns 5xx Check logs, fix the bug; use idempotency to absorb retries safely
502 Bad Gateway from PaaS App crashed during handler Wrap handler in try/catch; return 200 + log async error

Caveats

  • Test mode (sk_test_, whsec_test_) and live mode (sk_live_, whsec_live_) have different webhook secrets. Set both in PaaS staging vs production.
  • Multiple endpoints: if you have 2 Stripe endpoints (e.g. one for checkout, one for billing), each has its own whsec_*. Store as STRIPE_WEBHOOK_SECRET_CHECKOUT and STRIPE_WEBHOOK_SECRET_BILLING.
  • Replay protection: Stripe signatures include a timestamp. By default, the SDK rejects events older than 5 minutes — keep your server clock synced (PaaS nodes use NTP).

See also