# Building and Deploying a Webhook Listener on PandaStack
Webhooks come up constantly — Stripe sending payment events, GitHub firing on PR merges, Twilio posting SMS delivery receipts. They all need the same thing: a persistent public HTTPS endpoint that can receive POST requests and do something with them.
This guide uses Node.js/Express, but the same pattern works with any language.
The Basic Listener
// webhook.js
const express = require('express')
const crypto = require('crypto')
const app = express()
// Raw body needed for signature verification
app.use(express.raw({ type: 'application/json' }))
function verifyStripeSignature(payload, sig, secret) {
const computedSig = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(`sha256=${computedSig}`),
Buffer.from(sig)
)
}
app.post('/webhooks/stripe', (req, res) => {
const sig = req.headers['stripe-signature']
if (!verifyStripeSignature(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET)) {
return res.status(400).send('Invalid signature')
}
const event = JSON.parse(req.body)
switch (event.type) {
case 'payment_intent.succeeded':
console.log('Payment succeeded:', event.data.object.id)
// your business logic here
break
case 'customer.subscription.deleted':
console.log('Subscription cancelled:', event.data.object.id)
break
default:
console.log('Unhandled event:', event.type)
}
res.json({ received: true })
})
app.listen(process.env.PORT || 3000)Why Signature Verification Matters
Without signature verification, anyone who knows your webhook URL can post fake events and trigger your business logic. Always verify. Stripe, GitHub, and most other services include a signature header — check their docs for the specific header name and algorithm.
Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "webhook.js"]Deploy on PandaStack
- 1Push to GitHub
- 2Dashboard → Projects → New Project → Container App
- 3Connect your repo
- 4Add environment variables:
- STRIPE_WEBHOOK_SECRET — from your Stripe dashboard
- PORT=3000
- 1PandaStack gives you a public HTTPS URL like
https://my-webhook.pandastack.io
Register the URL with Stripe (or GitHub, etc.)
In Stripe dashboard: Developers → Webhooks → Add Endpoint, paste your URL.
For GitHub: Repo Settings → Webhooks → Add webhook, paste the URL, set content type to application/json.
Handling Slow Processing
If your webhook handler needs to do slow things (database writes, sending emails), return the 200 immediately and process asynchronously:
app.post('/webhooks/stripe', async (req, res) => {
// verify first
if (!isValid(req)) return res.status(400).end()
res.json({ received: true }) // respond immediately
// then process
const event = JSON.parse(req.body)
await processEvent(event) // runs after response is sent
})This prevents Stripe from thinking your endpoint timed out and retrying the same event multiple times.
Monitoring Failed Webhooks
Set up a PandaStack alert for HTTP 4xx/5xx responses from your webhook container. If signature verification is failing consistently, something has changed in the signing secret — you want to know about that fast.
Dashboard → Monitoring → Alerts → HTTP Error Rate, select your webhook project.
Using PandaStack's Managed Redis for Deduplication
Webhook providers retry on failure. To avoid processing the same event twice, store the event ID in Redis:
const redis = require('redis')
const client = redis.createClient({ url: process.env.REDIS_URL })
app.post('/webhooks/stripe', async (req, res) => {
const event = JSON.parse(req.body)
const key = `webhook:processed:${event.id}`
const already = await client.get(key)
if (already) return res.json({ received: true }) // duplicate, skip
await client.set(key, '1', { EX: 86400 }) // remember for 24h
res.json({ received: true })
await processEvent(event)
})Provision a Redis instance under Databases → New Database → Redis in PandaStack and add the connection URL as REDIS_URL.
Full docs: [docs.pandastack.io](https://docs.pandastack.io).