Back to Blog
Tutorial11 min read2026-07-03

How to Deploy a Stripe Webhook Handler

Deploy a reliable Stripe webhook endpoint in Node: verify signatures with the raw body, make handlers idempotent, and handle the events that actually matter for billing.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

# 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 normally

Respond 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:

EventMeaningTypical action
checkout.session.completedCheckout finishedProvision access
invoice.paidSubscription invoice paidExtend access period
invoice.payment_failedCharge failedDunning / notify
customer.subscription.updatedPlan/status changeSync entitlements
customer.subscription.deletedCanceledRevoke 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

  1. 1Push to GitHub and create a container app in the [dashboard](https://dashboard.pandastack.io), connecting the repo.
  2. 2Provision a managed PostgreSQL for the events table — PandaStack auto-wires DATABASE_URL into your app's environment, so your code reads process.env.DATABASE_URL with zero connection-string copy-paste.
  3. 3Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as env vars.
  4. 4Take the resulting HTTPS URL and register https:///webhook in the Stripe Dashboard under Developers → Webhooks, selecting the events above. Copy the signing secret it generates into 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.completed

The 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).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also