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.
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.
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.
curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER 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
ngrokorCloudflare Tunnelsfor secure local tunneling.
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.
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.
mkdir stripe-webhook-postgres && cd stripe-webhook-postgres
npm init -y
npm install express stripe pg dotenv
npm install --save-dev nodemon The official `stripe` Node SDK includes the necessary cryptographic verification methods, while `pg` provides reliable pooled connections to PostgreSQL.
Running `cat package.json` shows the required dependencies installed with type support.
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.
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); 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.
Querying `\d subscriptions` in `psql` shows columns `id`, `customer_id`, `status`, `price_id`, `current_period_end`, and `updated_at`.
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()`.
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')); 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.
The server runs on port 3000 and exposes `/api/webhooks/stripe` accepting raw bytes.
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`.
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 Separating configuration secrets into environment variables ensures API keys are never committed to version control.
Console outputs 'Stripe webhook listener running on port 3000' and connects to PostgreSQL without throwing an error.
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`.
# 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 The Stripe CLI emulates live webhooks locally, generating real cryptographic signatures against your temporary local signing secret without requiring public DNS or tunneling tools.
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.
Troubleshooting
Server returns HTTP 400 with 'Webhook Error: No signatures found matching the expected signature for payload'
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.
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
The webhook handler is performing synchronous long-running tasks (e.g. sending emails or generating PDFs) before returning a 200 response to Stripe.
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'
The SQL migration in Step 2 was not executed against the active database or connected to a different schema.
Run the table creation SQL script inside your database client and verify the table exists in the `public` schema.