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¶
- Go to https://dashboard.stripe.com/webhooks
- Click Add endpoint
- Endpoint URL:
https://<app>.runtime.di2amp.com/webhooks/stripe - Select the events you care about (e.g.
checkout.session.completed,payment_intent.succeeded,invoice.paid) - Click Add endpoint — Stripe shows a signing secret like
whsec_AbCdEf...
Step 2 — Store the secret in PaaS¶
The next deploy injects both into your app's environment.
Step 3 — Handler code¶
Install:
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:
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¶
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 asSTRIPE_WEBHOOK_SECRET_CHECKOUTandSTRIPE_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).