Handling Stripe Webhooks on a VPS: Secure Verification, Idempotency, and Retry Logic
Stripe webhooks are how Stripe tells your application about payment events — subscription renewals, failed payments, refunds, dispute openings. A VPS webhook handler that verifies signatures, processes events idempotently (so retries don’t cause duplicate actions), and handles failures gracefully is the backbone of any payment integration. This guide builds production-ready webhook handlers in both Node.js and Python.
Why Webhooks Require Special Care
- Stripe retries failed webhooks: If your endpoint returns a non-200 status, Stripe retries up to 72 hours with exponential backoff. Your code must handle receiving the same event multiple times without charging twice or provisioning twice.
- Signature verification is mandatory: Never process a webhook without verifying the
Stripe-Signatureheader — any attacker can POST fake events to your endpoint. - Process asynchronously: Return 200 immediately, process the event in the background — Stripe expects a response within 30 seconds.
Step 1: Configure Nginx for Webhook Endpoint
<code">sudo nano /etc/nginx/sites-available/myapp
<code"># Add to your existing server block:
location /webhooks/stripe {
# Stripe sends raw JSON body — don't interfere with body parsing
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Must be off — Stripe signature requires the raw, unmodified body
proxy_request_buffering on;
# Stripe webhooks are usually small, but set reasonable limit
client_max_body_size 1M;
}
Step 2: Node.js Webhook Handler
<code">npm install stripe express pg
<code">nano webhook-server.js
<code">import Stripe from 'stripe';
import express from 'express';
import { Pool } from 'pg';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
// Initialize idempotency table
await db.query(`
CREATE TABLE IF NOT EXISTS processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT,
processed_at TIMESTAMP DEFAULT NOW()
)
`);
// CRITICAL: Use express.raw() for Stripe webhooks — NOT express.json()
// express.json() parses the body, breaking signature verification
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify the webhook signature — rejects tampered or fake events
event = stripe.webhooks.constructEvent(
req.body, // Raw Buffer — must not be parsed
sig,
process.env.STRIPE_WEBHOOK_SECRET // From Stripe dashboard
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency check — skip if already processed
try {
await db.query(
'INSERT INTO processed_events (event_id, event_type) VALUES ($1, $2)',
[event.id, event.type]
);
} catch (err) {
if (err.code === '23505') { // Unique violation — already processed
console.log(`Duplicate event ${event.id} — skipping`);
return res.json({ received: true, status: 'duplicate' });
}
throw err;
}
// Return 200 IMMEDIATELY — process asynchronously
res.json({ received: true });
// Process in background (don't await here)
processEvent(event).catch(err => {
console.error(`Failed to process event ${event.id}:`, err);
});
}
);
async function processEvent(event) {
console.log(`Processing ${event.type}: ${event.id}`);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await handleSuccessfulPayment(session);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object;
await updateSubscription(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await cancelSubscription(subscription.customer);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
await handleFailedPayment(invoice);
break;
}
case 'charge.dispute.created': {
const dispute = event.data.object;
await handleDispute(dispute);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
async function handleSuccessfulPayment(session) {
const customerId = session.customer;
const userId = session.client_reference_id; // Set when creating checkout
await db.query(
`INSERT INTO orders (user_id, stripe_session_id, status, amount)
VALUES ($1, $2, 'completed', $3)
ON CONFLICT (stripe_session_id) DO NOTHING`,
[userId, session.id, session.amount_total]
);
// Provision access, send receipt email, update CRM, etc.
console.log(`Order provisioned for user ${userId}`);
}
async function updateSubscription(subscription) {
await db.query(
`INSERT INTO subscriptions (stripe_subscription_id, customer_id, status, current_period_end)
VALUES ($1, $2, $3, to_timestamp($4))
ON CONFLICT (stripe_subscription_id) DO UPDATE
SET status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end`,
[subscription.id, subscription.customer, subscription.status,
subscription.current_period_end]
);
}
async function handleFailedPayment(invoice) {
const customerId = invoice.customer;
const attemptCount = invoice.attempt_count;
if (attemptCount === 1) {
// First failure: send gentle reminder
console.log(`Payment failed for ${customerId} — sending reminder`);
} else if (attemptCount >= 3) {
// Multiple failures: downgrade or suspend account
console.log(`Multiple failures for ${customerId} — suspending`);
await db.query(
'UPDATE users SET subscription_status = $1 WHERE stripe_customer_id = $2',
['suspended', customerId]
);
}
}
app.listen(3000, '127.0.0.1');
console.log('Webhook server listening on port 3000');
Step 3: Python Version (FastAPI)
<code">pip install stripe fastapi uvicorn asyncpg
<code">nano webhook_handler.py
<code">import stripe
import os
import asyncpg
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
WEBHOOK_SECRET = os.environ['STRIPE_WEBHOOK_SECRET']
@app.post('/webhooks/stripe')
async def stripe_webhook(request: Request):
payload = await request.body() # Raw bytes — don't use request.json()
sig_header = request.headers.get('stripe-signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail='Invalid signature')
# Idempotency via database
db = await asyncpg.connect(os.environ['DATABASE_URL'])
try:
await db.execute(
'INSERT INTO processed_events (event_id, event_type) VALUES ($1, $2)',
event['id'], event['type']
)
except asyncpg.UniqueViolationError:
await db.close()
return {'received': True, 'status': 'duplicate'}
finally:
await db.close()
# Return immediately, let FastAPI's background tasks handle processing
import asyncio
asyncio.create_task(process_event(event))
return {'received': True}
async def process_event(event):
db = await asyncpg.connect(os.environ['DATABASE_URL'])
try:
if event['type'] == 'checkout.session.completed':
session = event['data']['object']
await db.execute(
"""INSERT INTO orders (stripe_session_id, status)
VALUES ($1, 'completed')
ON CONFLICT DO NOTHING""",
session['id']
)
# Handle other event types...
finally:
await db.close()
Step 4: Local Testing with Stripe CLI
<code"># Install Stripe CLI
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | \
gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] \
https://packages.stripe.dev/stripe-cli-debian-local stable main" | \
sudo tee /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install -y stripe
# Forward Stripe webhooks to your local dev server
stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
Getting Started
Stripe webhook handlers are lightweight — a Node.js or Python process using 50–100 MB RAM. They run alongside your main application on any Ubuntu VPS at VPS.DO. USA VPS placement minimizes round-trip time between Stripe’s servers (US-based) and your webhook endpoint, reducing event-to-processing latency.
Conclusion
A production Stripe webhook handler requires three things: signature verification (prevents fake events), idempotency (prevents duplicate processing on retries), and immediate 200 responses with async processing (prevents Stripe from timing out and retrying). The database-backed idempotency check is the critical component — without it, a Stripe retry after a temporary failure will create duplicate orders, charge customers twice, or provision access multiple times.