APIs
Intermediate 18 min READ

Secure Stripe webhook verification and sync to PostgreSQL

Verify cryptographic Stripe webhook signatures, process checkout events, and idempotently upsert customer subscription records into PostgreSQL.

Target Architecture

A secure Node.js & Express webhook microservice that exposes a `/api/webhooks/stripe` endpoint. The service extracts the raw request buffer to verify Stripe's `Stripe-Signature` header, processes `checkout.session.completed`, `customer.subscription.updated`, and `customer.subscription.deleted` events, and idempotently updates a normalized `subscriptions` table in PostgreSQL using transactional SQL upserts.

INTEGRATED RUNTIMES:

Tools used

Stripe PostgreSQL Node.js Express
Table of Contents
8 Sections

Overview

This guide provides a production-grade blueprint for capturing asynchronous Stripe webhook events, verifying cryptographic HMAC SHA-256 signatures, and maintaining accurate billing state in a PostgreSQL database. It prevents common production pitfalls like duplicate charges, race conditions, and unverified payload spoofing. Every technical term is explained in the Glossary before use.

Before you begin

Budget 25–40 minutes to install project dependencies, launch a local or cloud PostgreSQL instance (such as Supabase or Neon), install the Stripe CLI for local event forwarding, and execute the test suite. This guide is UNVERIFIED by Workflow Vault's automated testing suite — test against Stripe Test Mode credentials before accepting live production payments.

Glossary

Stripe Webhook
An asynchronous HTTP POST request sent by Stripe to your server whenever an event occurs in your Stripe account (e.g. successful charge, subscription renewal, invoice payment failure).
HMAC SHA-256 Signature Verification
A cryptographic mechanism where Stripe signs the webhook payload with a shared secret key, allowing your backend to verify that the request originated from Stripe and was not tampered with.
Raw Request Buffer
The exact, unmodified binary byte representation of the incoming HTTP request body. Webhook signature verification fails if the body is parsed by JSON body parsers before verification.
Idempotent Upsert
A database operation (`INSERT ... ON CONFLICT DO UPDATE`) that creates a new row or updates an existing row without producing duplicates when receiving the same webhook event multiple times.
Stripe CLI
The official command-line developer tool provided by Stripe to trigger mock events, listen to webhooks, and securely forward them to `localhost:3000` during development.
Stripe Customer ID
A unique identifier formatted as `cus_...` that represents a payer or account in Stripe's ecosystem.

Prerequisites

  • [You'll need this already] A Stripe account with access to the Stripe Developer Dashboard (dashboard.stripe.com).
  • [You'll need this already] Node.js (version 18+) and npm installed on your development machine.
  • [You'll need this already] A PostgreSQL database instance (local, Supabase, Neon, or Docker) with connection string credentials.
  • [You'll need this already] Stripe CLI installed (`brew install stripe/stripe-cli/stripe` or downloaded from stripe.com/docs/stripe-cli).
  • [Optional, not required] ngrok or Cloudflare Tunnels if you want to expose a local server to live Stripe test webhooks without the Stripe CLI.

Where to Run This Automation

Where should you run this automation?

Don't have 24/7 hardware at home? Choose the setup that fits your budget and technical comfort.

Hardware & Cloud Guide

Best for Freelancers, Agencies, and 24/7 Client Pipelines

If you don't have a dedicated server running at home, a Virtual Private Server (VPS) gives you a dedicated Linux server in the cloud that stays online 24/7 even when your personal laptop is closed.

  • Cost: ~$4 to $6 / month (predictable flat fee, no per-execution surprises).
  • Providers: Hetzner Cloud, DigitalOcean, Linode / Akamai, Railway, or OVH.
  • Key advantage: You get a permanent public IP address and free SSL certificates, which are essential for receiving incoming Stripe, Airtable, or GitHub webhooks reliably.
Quick 1-liner to install Docker on any Ubuntu/Debian VPS: curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER
RECOMMENDED SPECS
RAM: 1GB – 2GB
vCPU: 1 Core
Storage: 20GB SSD
Uptime: 99.9%

Best for Hobbyists, Testing & Zero-Cost Experimentation

You can run this automation completely free on your everyday computer (Mac, Windows, or Linux) or on low-cost home hardware like a Raspberry Pi 4/5 or repurposed mini PC.

  • Cost: $0 / month (100% free).
  • Software needed: Docker Desktop (free for personal use) or native Node.js / Python.
  • Consideration: Automations will only execute while your computer is powered on and awake. To receive incoming webhooks locally, use tools like ngrok or Cloudflare Tunnels for secure local tunneling.
LOCAL CHECKLIST
Docker Desktop: Installed
RAM Overhead: ~400MB
Webhooks: Tunnel required

Best for Zero-Maintenance 1-Click Operations

If you don't want to manage Docker containers or terminal commands, you can run automations directly on managed serverless platforms or official SaaS plans:

  • Cloudflare Workers: Free tier includes 100,000 requests per day with 0ms cold starts.
  • n8n Cloud / Make / Zapier: Fully managed hosted platforms with automated backups and visual scenario builders.
  • Railway / Render: 1-click Git container deploys with auto-healing and managed PostgreSQL databases.
SERVERLESS TIERS
Cloudflare: 100k req/day free
GitHub CI: 2,000 min/mo free
Maintenance: Zero OS updates

Step-by-step implementation

Click the numbered phase buttons as you complete each step to track your live progress.

Phase Progress 0/5 completed (0%)
PHASE 1

Initialize the Node.js project and install dependencies

Create a new project directory and initialize `package.json`. Install `express`, `stripe`, `pg` (node-postgres), and `dotenv`, along with development types.

bash
mkdir stripe-webhook-postgres && cd stripe-webhook-postgres
npm init -y
npm install express stripe pg dotenv
npm install --save-dev nodemon
Why this matters

The official `stripe` Node SDK includes the necessary cryptographic verification methods, while `pg` provides reliable pooled connections to PostgreSQL.

Expected Output

Running `cat package.json` shows the required dependencies installed with type support.

PHASE 2

Create the PostgreSQL subscriptions table schema

Execute the SQL migration below on your PostgreSQL database to create the `customers` and `subscriptions` tables with primary keys and unique indexes for idempotency.

sql
CREATE TABLE IF NOT EXISTS subscriptions (
  id VARCHAR(255) PRIMARY KEY, -- e.g. sub_1N4...
  customer_id VARCHAR(255) NOT NULL, -- e.g. cus_1N4...
  status VARCHAR(50) NOT NULL, -- active, trialing, past_due, canceled
  price_id VARCHAR(255) NOT NULL, -- e.g. price_1N4...
  quantity INTEGER DEFAULT 1,
  current_period_start TIMESTAMP WITH TIME ZONE,
  current_period_end TIMESTAMP WITH TIME ZONE,
  cancel_at_period_end BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id);
Why this matters

Setting `stripe_subscription_id` as a unique primary key enables atomic `ON CONFLICT (id) DO UPDATE` statements, preventing race conditions if Stripe retries deliveries rapidly.

Expected Output

Querying `\d subscriptions` in `psql` shows columns `id`, `customer_id`, `status`, `price_id`, `current_period_end`, and `updated_at`.

PHASE 3

Build the Express server with raw body capture

Create `server.js`. Configure Express to use `express.raw({ type: 'application/json' })` strictly on the `/api/webhooks/stripe` route before applying global `express.json()` to other endpoints. This preserves the original payload buffer required by `stripe.webhooks.constructEvent()`.

javascript
import express from 'express';
import Stripe from 'stripe';
import pg from 'pg';
import dotenv from 'dotenv';

dotenv.config();

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// CRITICAL: Raw body parser must be mounted before standard json parser
app.post(
  '/api/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,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error(`Webhook signature verification failed: ${err.message}`);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Handle supported event types
    try {
      switch (event.type) {
        case 'customer.subscription.created':
        case 'customer.subscription.updated': {
          const sub = event.data.object;
          await upsertSubscription(sub);
          break;
        }
        case 'customer.subscription.deleted': {
          const sub = event.data.object;
          await markSubscriptionCanceled(sub.id);
          break;
        }
        default:
          console.log(`Unhandled event type: ${event.type}`);
      }

      // Acknowledge receipt to Stripe
      res.json({ received: true });
    } catch (err) {
      console.error(`Failed processing webhook event: ${err.message}`);
      res.status(500).json({ error: 'Database processing error' });
    }
  }
);

async function upsertSubscription(sub) {
  const query = `
    INSERT INTO subscriptions (
      id, customer_id, status, price_id, quantity,
      current_period_start, current_period_end, cancel_at_period_end, updated_at
    )
    VALUES ($1, $2, $3, $4, $5, to_timestamp($6), to_timestamp($7), $8, NOW())
    ON CONFLICT (id) DO UPDATE SET
      status = EXCLUDED.status,
      price_id = EXCLUDED.price_id,
      quantity = EXCLUDED.quantity,
      current_period_start = EXCLUDED.current_period_start,
      current_period_end = EXCLUDED.current_period_end,
      cancel_at_period_end = EXCLUDED.cancel_at_period_end,
      updated_at = NOW();
  `;

  const values = [
    sub.id,
    sub.customer,
    sub.status,
    sub.items.data[0]?.price?.id || '',
    sub.quantity || 1,
    sub.current_period_start,
    sub.current_period_end,
    sub.cancel_at_period_end
  ];

  await pool.query(query, values);
}

async function markSubscriptionCanceled(subId) {
  await pool.query(
    `UPDATE subscriptions SET status = 'canceled', updated_at = NOW() WHERE id = $1`,
    [subId]
  );
}

app.listen(3000, () => console.log('Stripe webhook listener running on port 3000'));
Why this matters

Standard Express body parsers mutate JSON whitespace and character encodings. Stripe cryptographic signature checks compare against the exact byte string sent across the wire; any variation causes signature verification failure.

Expected Output

The server runs on port 3000 and exposes `/api/webhooks/stripe` accepting raw bytes.

PHASE 4

Configure environment variables and start the server

Create `.env` file containing `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `DATABASE_URL`. Start your development server with `node server.js`.

bash
cat << 'EOF' > .env
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
EOF

node server.js
Why this matters

Separating configuration secrets into environment variables ensures API keys are never committed to version control.

Expected Output

Console outputs 'Stripe webhook listener running on port 3000' and connects to PostgreSQL without throwing an error.

PHASE 5

Forward test events using the Stripe CLI

In a separate terminal, authenticate with the Stripe CLI (`stripe login`) and run `stripe listen --forward-to localhost:3000/api/webhooks/stripe`. Copy the webhook signing secret output by the CLI (`whsec_...`) into your `.env` file, then trigger a test event with `stripe trigger customer.subscription.created`.

bash
# 1. Listen and forward events to local port 3000
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# 2. In another terminal, trigger a test subscription event
stripe trigger customer.subscription.created
Why this matters

The Stripe CLI emulates live webhooks locally, generating real cryptographic signatures against your temporary local signing secret without requiring public DNS or tunneling tools.

Expected Output

The Stripe CLI terminal logs `--> POST /api/webhooks/stripe [200]`, and querying `SELECT * FROM subscriptions;` in PostgreSQL reveals the newly created test subscription record.

Workflow architecture

Stripe servers -> HTTP POST to /api/webhooks/stripe with Stripe-Signature header -> Express raw buffer middleware -> stripe.webhooks.constructEvent() verifies HMAC SHA-256 signature -> Event router handles subscription lifecycle events -> PostgreSQL connection pool executes ON CONFLICT upsert -> HTTP 200 returned to Stripe.

Final result

A battle-tested webhook handler that securely validates Stripe signatures and maintains synchronous subscription billing states in PostgreSQL with zero duplicate records. As with all Workflow Vault guides, this guide is UNVERIFIED — test thoroughly in Stripe Test Mode before handling live transactions.

Recommended Extensions & Scaling

  • Implement handling for `invoice.payment_failed` and `invoice.payment_action_required` to notify customers of billing problems.
  • Add a Redis message queue (BullMQ) to process high-concurrency event bursts asynchronously during major sales launches.
  • Configure Stripe Customer Portal session generation so users can manage their subscriptions self-serve.

Troubleshooting

⚠ Server returns HTTP 400 with 'Webhook Error: No signatures found matching the expected signature for payload'
Root Cause

The `STRIPE_WEBHOOK_SECRET` in your `.env` does not match the secret key used to sign the event, or `express.json()` parsed the body before `express.raw()` could capture the raw buffer.

Solution

Ensure `express.raw({ type: 'application/json' })` is mounted before any other body parsing middleware, and verify the `whsec_...` key matches the active Stripe CLI session.

⚠ Stripe webhook times out after 20 seconds with HTTP 504 / connection error
Root Cause

The webhook handler is performing synchronous long-running tasks (e.g. sending emails or generating PDFs) before returning a 200 response to Stripe.

Solution

Acknowledge receipt immediately with `res.json({ received: true })` after queuing the database task or offload heavy processing to a background worker.

⚠ PostgreSQL throws 'relation "subscriptions" does not exist'
Root Cause

The SQL migration in Step 2 was not executed against the active database or connected to a different schema.

Solution

Run the table creation SQL script inside your database client and verify the table exists in the `public` schema.