2025-12-05 | PreviewProof Team
Webhook Handling When Your Preview URL Changes Every Commit
Webhooks in preview environments have the same root problem as OAuth callbacks — a third party needs to deliver to a URL that didn’t exist five minutes ago — but a different shape. OAuth is one round trip per user. Webhooks are continuous, asynchronous, sometimes high-volume, and the third party doesn’t care whether your preview is reachable; it just retries with exponential backoff and eventually gives up.
There are three categories of solution. None of them are good in all cases. Picking the right one for your situation matters more than picking “the best one.”
Category 1: Tunneling Tools
The first thing every developer reaches for: ngrok, Cloudflare Tunnel, Tailscale Funnel, or the official stripe listen CLI. A persistent tunnel from a public URL to wherever the preview is running. The third party hits the tunnel; the tunnel forwards to the preview.
When this fits: local dev where the developer is actively driving the preview, single-developer feature work, or demos where you need a public URL for 30 minutes.
Why it’s wrong for unattended previews: tunneling tools are connection-oriented. Something has to run the tunnel client continuously. For a preview that “just works” when a reviewer clicks the URL three days later, the tunnel has to live inside the preview’s container — which means each preview is now responsible for its own tunnel auth, URL allocation, and teardown. Free-tier ngrok URLs change every restart, which puts you back where you started.
If you go this route, use Cloudflare Tunnel with named hostnames per preview, or self-hosted frp. The cost is operational — you’re now running a tunnel control plane.
Category 2: Webhook Proxy Services
Services that exist for this problem: Hookdeck, Webhook Relay, Svix’s playground, Pipedream’s webhook source. Register a stable URL with the third party once, then forward (or fan out) to whatever destinations you configure dynamically.
This is the right answer for many teams. The third party registers https://acme-stripe-webhooks.hookdeck.app/v1 once and forever. The proxy decides where to forward based on metadata you control — a value in the payload, or a URL parameter the proxy adds.
The tricky part is routing. Proxies don’t know which preview an event “belongs to.” For Stripe, you can derive routing from customer or account ID — but only if your previews use different Stripe customers from production, and the proxy has been told the mapping.
The case that doesn’t work cleanly is webhooks for events your preview itself initiated. A Stripe payment created by the preview, processed in test mode, generates a payment_intent.succeeded event that needs to come back to that specific preview. The proxy needs to know which preview created the intent — usually via metadata you attach at creation:
const intent = await stripe.paymentIntents.create({ amount: 1000, currency: 'usd', metadata: { preview_id: process.env.PREVIEW_ID, },});Then the proxy routes based on event.data.object.metadata.preview_id. Build this in from the start — retrofitting it after a webhook proxy is wired up is painful.
Category 3: Webhook Router Architecture
A pattern, not a product. You run a small webhook ingestion service in your own infrastructure on a stable URL, decoupled from any specific preview. It accepts every webhook, validates signatures, persists the event, and then dispatches to whichever preview should handle it — either by HTTP push to the preview’s URL or by leaving the event in a queue the preview polls.
The build cost is real but contained: maybe a day of work for a single-service shop, more if you want signature validation, replay protection, and dead-letter handling done well. The benefit is that you own the routing logic and can do whatever you want with it.
The push variant looks like this:
// webhook-router service, on stable URLapp.post('/webhooks/stripe', async (req, res) => { const event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], WEBHOOK_SECRET );
await db.webhookEvents.insert(event);
const previewId = event.data.object.metadata?.preview_id; if (!previewId) { res.status(200).send('no preview routing'); return; }
const preview = await previewRegistry.lookup(previewId); if (!preview) { // preview was torn down — log and ack res.status(200).send('preview gone'); return; }
await fetch(`${preview.baseUrl}/webhooks/stripe`, { method: 'POST', headers: { 'X-Original-Signature': req.headers['stripe-signature'] }, body: req.body, });
res.status(200).send('forwarded');});The pull variant is even better for unreliable previews — events sit in a queue and the preview pulls them on its own schedule. Lossless, resilient to preview restarts, debuggable. Slightly more work to wire up.
A subtle benefit: the router becomes the canonical event log. When a reviewer says “Stripe didn’t fire the webhook,” you have a definitive record of whether Stripe fired it, when, what the payload was, and whether the preview was reachable. That alone justifies the build for many teams.
Stripe-Specific Notes
Stripe’s stripe listen --forward-to is effectively a tunneling tool with Stripe-specific niceties. Fine locally, not for unattended previews.
Stripe also lets you register multiple webhook endpoints per account (16 in test mode at last count). For small teams with a handful of concurrent previews, registering and deregistering endpoints via the Stripe API as previews come and go is workable. At scale it’s not.
See also Stripe, Twilio, and SendGrid test mode.
Security and Idempotency
Three things that get missed:
Signature validation. The third party signs the payload with a shared secret. If your router validates the signature and forwards a modified body, the preview can’t re-validate. Either forward the original raw body byte-for-byte, or have the router sign a new envelope. Don’t strip signatures — that means anyone who can reach the preview’s webhook endpoint can forge events.
Idempotency. Webhooks retry. Your preview tears down, the third party retries, the new preview receives the same event. If your preview database is fresh on every boot, dedupe state is too — meaning the preview will process the same event twice. Either persist dedupe state outside the preview or write idempotent handlers.
Replay attacks. Store-and-forward proxies enable replay by design. Validate timestamps against a freshness window for security-relevant events.
Which Fits Which Use Case
For local-only, attended development: tunneling tools.
For most preview environment fleets: a webhook proxy service (Category 2). Build cost is low, operational story is fine, routing flexibility scales further than teams expect.
For strict data-handling requirements, multiple third parties, or webhook volume that justifies owning the infrastructure: a webhook router (Category 3). Pays back as soon as you’re debugging your second incident.
The combination that doesn’t work: ad-hoc tunnels opened by humans on demand. That’s how teams discover six months in that “previews work” depends on a developer remembering to start ngrok every morning.
If your stack hits Stripe, Slack, GitHub, or any other webhook-heavy integration in previews and you’d rather not stand up your own router, PreviewProof handles per-preview webhook routing with signature preservation and replay protection out of the box. If you’d rather build it yourself, Category 3 above is the architecture we’d recommend — and it generalizes to whatever third parties you need to plug in next.