# How to Deploy a Stripe Webhook Handler
Stripe webhooks are how you find out what *actually* happened: a payment succeeded, a subscription renewed, a card was declined. The client-side confirmPayment call is not the source of truth — the webhook is. Get this endpoint wrong and you'll either miss revenue events or process them twice. Here's how to build and deploy one correctly.
The one mistake everyone makes first
Stripe signs each webhook with a secret. To verify the signature you need the raw, unparsed request body. If you've globally enabled express.json(), the body is already parsed by the time it reaches your handler and verification fails with a cryptic error. The fix is to use the raw body parser *only* on the webhook route:
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
// Raw body ONLY for the webhook route — declare it before express.json()
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
handleEvent(event).then(() => res.json({ received: true }));
});
app.use(express.json()); // everything else parses JSON normallyRespond fast, work later
Stripe expects a 2xx quickly. If your handler does slow work inline (sending emails, calling third-party APIs), you risk a timeout, after which Stripe retries — and now you have a duplicate. The pattern: acknowledge immediately, process asynchronously.
async function handleEvent(event) {
// Persist the raw event first, then return 200.
await db.insertEventIfNew(event.id, event);
// Heavy work happens off the request path (queue/worker).
}Returning 200 before the work completes is fine as long as you've durably recorded the event so a worker can pick it up.
Idempotency is mandatory
Stripe guarantees *at-least-once* delivery, not exactly-once. The same event can arrive multiple times. Dedupe on event.id:
CREATE TABLE stripe_events (
id TEXT PRIMARY KEY, -- event.id, e.g. evt_123
type TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ
);INSERT ... ON CONFLICT (id) DO NOTHING makes re-delivery a no-op. This single table saves you from double-fulfilling orders.
Which events to actually handle
Don't subscribe to everything. For most SaaS billing, these are the core ones:
| Event | Meaning | Typical action |
|---|---|---|
checkout.session.completed | Checkout finished | Provision access |
invoice.paid | Subscription invoice paid | Extend access period |
invoice.payment_failed | Charge failed | Dunning / notify |
customer.subscription.updated | Plan/status change | Sync entitlements |
customer.subscription.deleted | Canceled | Revoke access |
For subscriptions, treat invoice.paid and customer.subscription.* as the truth for entitlements rather than trusting the client.
Containerize
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]Deploy on PandaStack
- 1Push to GitHub and create a container app in the [dashboard](https://dashboard.pandastack.io), connecting the repo.
- 2Provision a managed PostgreSQL for the events table — PandaStack auto-wires
DATABASE_URLinto your app's environment, so your code readsprocess.env.DATABASE_URLwith zero connection-string copy-paste. - 3Add
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETas env vars. - 4Take the resulting HTTPS URL and register
https://in the Stripe Dashboard under Developers → Webhooks, selecting the events above. Copy the signing secret it generates into/webhook STRIPE_WEBHOOK_SECRET.
Because billing is revenue-critical, run the webhook service on a paid compute tier rather than the free scale-to-zero tier — you don't want a cold start during a retry storm. The auto-wired database means your idempotency table is one provisioning click away.
Test locally and in prod
The Stripe CLI forwards real events to your machine and can fire test events:
stripe login
stripe listen --forward-to localhost:3000/webhook
# in another terminal:
stripe trigger checkout.session.completedThe stripe listen command prints a temporary signing secret — use that one locally. In production, use the secret from the Dashboard endpoint. After deploying, send a test event from the Dashboard and watch PandaStack's live logs to confirm a clean 200 and a single inserted row.
Common failure modes
- 400 signature errors: almost always the raw-body issue, or you're using the CLI secret in prod.
- Duplicate fulfillment: missing idempotency check on
event.id. - Missed events: your endpoint returned 5xx and you didn't inspect the Dashboard's webhook delivery log, which shows every attempt and response.
References
- [Stripe: Webhooks overview](https://stripe.com/docs/webhooks)
- [Stripe: Verifying signatures](https://stripe.com/docs/webhooks#verify-events)
- [Stripe: Best practices for using webhooks](https://stripe.com/docs/webhooks/best-practices)
- [Stripe CLI](https://stripe.com/docs/stripe-cli)
A correct Stripe webhook handler is small but it guards your revenue. PandaStack auto-wires a managed Postgres for idempotency and gives you HTTPS + env vars out of the box — spin one up free at [dashboard.pandastack.io](https://dashboard.pandastack.io).