Handling Stripe Webhooks on a VPS: Secure Verification, Idempotency, and Retry Logic

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-Signature header — 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.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!