Never Lose a Webhook Again
Buffer incoming webhooks from Stripe, GitHub, and any service. Forward them to your app with retries, serial delivery, and full event replay.
The problem
When Stripe sends you a
payment_intent.succeeded
webhook, your server has to be up, healthy, and fast enough to respond. If you're mid-deploy,
scaling down, or having a bad minute — that webhook is gone. Stripe retries a few times over
72 hours, but other services are less forgiving. Some don't retry at all.
This is especially painful on serverless platforms. Vercel cold starts can cause timeouts. Cloudflare Workers have a 30-second CPU limit. Supabase Edge Functions might be cold when the webhook arrives. You need a buffer between the sender and your app.
How inbound endpoints work
Runlater gives you a stable URL that's always up. Point external services at that URL instead of your app directly. Runlater accepts the webhook instantly (200 OK), stores the full payload, and forwards it to your app with retries.
Stripe/GitHub/Shopify
|
v
runlater.eu/in/ep_xxx <-- always up, returns 200 instantly
|
| stores payload, then forwards with retries
v
your-app.com/webhooks <-- can be down, slow, or mid-deploy
If your app is down when the webhook arrives, Runlater retries with exponential backoff — up to 10 attempts. Every event is stored with full headers and body, so you can replay any event from the dashboard or API.
Quick start
1. Create an endpoint
Tell Runlater where to forward webhooks. You can do this from the dashboard or the API:
const res = await fetch("https://runlater.eu/api/v1/endpoints", { method: "POST", headers: { "Authorization": `Bearer ${process.env.RUNLATER_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Stripe webhooks", forward_urls: ["https://myapp.com/webhooks/stripe"], retry_attempts: 5, use_queue: true, // Forward events one at a time (preserves order) }), }) const { data } = await res.json() console.log(data.inbound_url) // https://runlater.eu/in/ep_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
curl -X POST https://runlater.eu/api/v1/endpoints \
-H "Authorization: Bearer pk_xxx.sk_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Stripe webhooks",
"forward_urls": ["https://myapp.com/webhooks/stripe"],
"retry_attempts": 5,
"use_queue": true
}'
2. Point Stripe at the inbound URL
Copy the inbound_url
from the response
and paste it into your Stripe webhook settings (or GitHub, Shopify, Twilio — any service that
sends webhooks):
https://runlater.eu/in/ep_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The URL accepts any HTTP method (POST, PUT, etc.) and any content type. No authentication is required — the slug itself is the secret.
3. Handle webhooks in your app
Your app receives the forwarded webhook at the
forward_urls
you configured. The original method, headers, and body are preserved:
import Stripe from "stripe" const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) export async function POST(request: Request) { const body = await request.text() const sig = request.headers.get("stripe-signature")! // Stripe signature verification still works — // Runlater forwards the original headers const event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ) switch (event.type) { case "payment_intent.succeeded": await handlePaymentSuccess(event.data.object) break case "customer.subscription.deleted": await handleSubscriptionCanceled(event.data.object) break } return Response.json({ received: true }) }
Serial vs. parallel delivery
Endpoints have two delivery modes:
| Mode | Behavior | Use when |
|---|---|---|
| Serial (default) | Events are forwarded one at a time, in order | Stripe, Shopify — order matters, duplicate processing is dangerous |
| Parallel | Events are forwarded concurrently | Analytics, logging, notifications — order doesn't matter |
Serial mode prevents race conditions. If Stripe sends
invoice.paid
and invoice.payment_failed
in quick
succession, serial delivery ensures you process them in the correct order.
Event replay
Every inbound event is stored with the full request — method, headers, body, source IP, and timestamp. You can replay any event from the dashboard or via the API:
# List recent events for an endpoint curl https://runlater.eu/api/v1/endpoints/ENDPOINT_ID/events \ -H "Authorization: Bearer pk_xxx.sk_xxx" # Replay a specific event curl -X POST https://runlater.eu/api/v1/endpoints/ENDPOINT_ID/events/EVENT_ID/replay \ -H "Authorization: Bearer pk_xxx.sk_xxx"
This is useful when:
- You deployed a bug and need to reprocess recent webhooks after fixing it
- You want to test your webhook handler with real production payloads
- An event failed all retries and you've fixed the underlying issue
Example: Stripe + Next.js
Set up reliable Stripe webhook processing for a Next.js app on Vercel in 3 steps:
# Create an endpoint for Stripe (serial delivery — order matters) curl -X POST https://runlater.eu/api/v1/endpoints \ -H "Authorization: Bearer $RUNLATER_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Stripe", "forward_urls": ["https://myapp.vercel.app/api/webhooks/stripe"], "retry_attempts": 10, "use_queue": true }' # Create an endpoint for GitHub (parallel — order doesn't matter) curl -X POST https://runlater.eu/api/v1/endpoints \ -H "Authorization: Bearer $RUNLATER_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "GitHub", "forward_urls": ["https://myapp.vercel.app/api/webhooks/github"], "retry_attempts": 5, "use_queue": false }'
Copy each inbound_url
from the response
into the corresponding service's webhook settings. Done.
Example: GitHub + Cloudflare Workers
Process GitHub push events in a Cloudflare Worker, with Runlater handling the buffering:
import { createHmac } from "node:crypto" export default { async fetch(request: Request, env: Env) { const url = new URL(request.url) if (url.pathname === "/webhooks/github" && request.method === "POST") { const body = await request.text() const event = request.headers.get("x-github-event") if (event === "push") { const payload = JSON.parse(body) // Trigger a build, notify a channel, update a dashboard, etc. console.log(`Push to ${payload.repository.full_name}`) } return Response.json({ ok: true }) } return new Response("Not found", { status: 404 }) } }
Example: Shopify + Supabase
Receive Shopify order webhooks and store them in your Supabase database:
import { createClient } from "@supabase/supabase-js" Deno.serve(async (req) => { const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ) const order = await req.json() const topic = req.headers.get("x-shopify-topic") if (topic === "orders/create") { await supabase.from("orders").upsert({ shopify_id: order.id, email: order.email, total: order.total_price, status: order.financial_status, }) } return new Response("OK") })
API reference
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/endpoints | Create a new inbound endpoint |
| GET | /api/v1/endpoints | List all endpoints |
| GET | /api/v1/endpoints/:id | Get endpoint details |
| PUT | /api/v1/endpoints/:id | Update an endpoint |
| DELETE | /api/v1/endpoints/:id | Delete an endpoint and its events |
| GET | /api/v1/endpoints/:id/events | List recent inbound events |
| POST | /api/v1/endpoints/:id/events/:event_id/replay | Replay a specific event |
All endpoints require an
Authorization: Bearer
header with your API key. See the interactive API docs
for full request/response schemas.
Best practices
- Use serial delivery for payment webhooks. Stripe, Shopify, and other payment services can send events in rapid succession. Serial mode ensures you process them in order and don't accidentally double-charge or double-refund.
- Keep your webhook handler idempotent. Even with serial delivery, the same event might be forwarded more than once (if your app returns an error and Runlater retries). Use the event ID or a deduplication key to prevent double processing.
-
Verify signatures in your handler.
Runlater forwards original headers
byte-for-byte. Stripe's
stripe-signature, GitHub'sx-hub-signature-256, and Shopify'sx-shopify-hmac-sha256all work without changes. - Set up failure notifications. Configure email or Slack alerts in your organization settings so you know immediately when webhook forwarding fails.
-
Return quickly. Your handler should return 2xx within the timeout window.
If you need to do heavy processing, accept the webhook and queue the work separately using
rl.send(). -
Test locally with runlocal.eu.
Run
npx runlocal 3000to expose your local dev server, then use the tunnel URL as your endpoint's forward URL. See the local webhook testing guide for a full walkthrough.