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:

Using fetch
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
Using curl
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:

app/api/webhooks/stripe/route.ts (Next.js)
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 })
}
Signature verification still works. Runlater forwards the original headers and body byte-for-byte. Stripe signature verification, GitHub HMAC validation, and similar schemes work without changes.

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:

Replay an event
# 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:

scripts/setup-endpoints.sh
# 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:

src/index.ts (Worker handler)
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:

supabase/functions/shopify-webhook/index.ts
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's x-hub-signature-256, and Shopify's x-shopify-hmac-sha256 all 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 3000 to 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.
Ready to get started? Create a free account (3 endpoints on the free tier) and set up your first inbound endpoint in under 2 minutes. See the endpoint documentation for the full API reference.

Back to all guides