InstaWebhook
September 19, 2026By InstaWebhook TeamWebhook Security

Building a Custom Webhook Provider: API Design Lessons from Stripe and GitHub

Building a Custom Webhook Provider: API Design Lessons from Stripe and GitHub Fact-checked against the official Stripe, GitHub and Standard Webhooks documentation in September...

Building A Custom Webhook Provider API Design Lessons From Stripe And Git Hub

Building a Custom Webhook Provider: API Design Lessons from Stripe and GitHub

Fact-checked against the official Stripe, GitHub and Standard Webhooks documentation in September 2026. Sources are listed at the end.

Table of contents

  1. Introduction: from webhook consumer to provider
  2. Payload design: events, envelopes and versions
  3. Security: signatures, replay protection and secrets
  4. Dispatch architecture: build for async resiliency
  5. Retries and fault tolerance
  6. Developer experience and observability
  7. Build vs. buy: what it takes to run this in production
  8. Conclusion: the webhook provider checklist
  9. Sources

1. Introduction: from webhook consumer to provider

For most developers, webhooks start as an intake problem: expose an HTTP POST endpoint, verify a signature, parse the JSON, return 200 OK. Then your SaaS grows, and your power users ask for the reverse. They don't want to poll your REST API every minute to find out whether an invoice was paid. They want you to push events to their servers in near real time.

Once you become the sender, you're doing distributed systems work. You are making outbound HTTP requests to servers you don't control, and those servers will time out, drop connections, return 500s, get redeployed with the wrong secret, or disappear for a weekend. A careless retry loop can hammer a recovering customer, or clog your own queues. And because your payloads travel over the public internet, your customers need a way to prove a request really came from you.

Two providers set the reference points most developers know:

  • Stripe has one of the most mature webhook systems: a consistent event envelope, timestamped signatures, multi-day retries, and a delivery log with manual resend.
  • GitHub shows a simpler design: metadata in headers, a bare resource in the body, no automatic retries, and a short redelivery window.

They make different trade-offs, and the differences are instructive. A third reference, the open Standard Webhooks specification, distills common practice into a single set of conventions and is a good tie-breaker when you have to choose.

Stripe, GitHub and Standard Webhooks at a glance

StripeGitHubStandard Webhooks
Body shapeEvent envelope (id, type, created, api_version, data.object)Bare resource with an action field; event name is in the X-GitHub-Event headertype, timestamp (ISO 8601), data
Unique IDEvent idX-GitHub-Delivery (same value on manual redelivery)webhook-id (stable across retries)
Signature headerStripe-Signature: t=…,v1=…X-Hub-Signature-256: sha256=…webhook-signature: v1,<base64>
Timestamp is signedYesNoYes, along with the message ID
Automatic retriesLive mode: up to 3 days, exponential backoffNoneRecommended: multi-day schedule with backoff and jitter
Response deadlineReturn a 2xx quickly (no figure given)10 secondsSuggested request timeout of 15–30 seconds
Payload sizeNo limit stated in the reviewed docs25 MB cap; larger events aren't deliveredKeep it small, usually under 20 kB
Manual replayDashboard: 15 days. CLI: 30 daysUI or REST API: last 3 daysRecommended, including bulk replay

Keep this table in mind. Most of the design decisions below are choices between these columns.


2. Payload design: events, envelopes and versions

The payload is the first contract you make with external developers, and the hardest one to change later.

Code example
┌───────────────────────────────────────────────────────────┐
│                   WEBHOOK EVENT ENVELOPE                  │
│                                                           │
│  id:          "evt_123456789"                             │
│  type:        "order.created"                             │
│  created:     1773921600                                  │
│  api_version: "2026-03-25.dahlia"                         │
│                                                           │
│  ┌─────────────────────────────────────────────────────┐  │
│  │ data                                                │  │
│  │   object: { id: "ord_999", total: 4999, ... }       │  │
│  └─────────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────┘

Envelope vs. bare payload

Stripe wraps every resource in an event envelope. A trimmed example:

Code example
{
  "id": "evt_1N3k452eZvKYlo2C0XzY9XYZ",
  "object": "event",
  "type": "payment_intent.succeeded",
  "created": 1773921600,
  "api_version": "2026-03-25.dahlia",
  "data": {
    "object": {
      "id": "pi_3MtwBw2eZvKYlo2C1Gq12345",
      "object": "payment_intent",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  },
  "livemode": true
}

GitHub takes a different route. The event name travels in the X-GitHub-Event header, the delivery ID in X-GitHub-Delivery, and the body is the resource plus a top-level action key (for example "action": "opened" on an issues event). GitHub tells consumers to check both the header and the action before processing, because it keeps adding new event types and new actions to existing types.

The envelope approach has a practical advantage for you as the provider: metadata lives in one predictable place regardless of resource type, so customers can write generic ingestion middleware once, and the body is self-describing when it's stored, queued or replayed later. Header-based metadata works too (GitHub proves it), but then the headers are part of your contract and every consumer has to keep them alongside the body.

Taxonomy rules worth copying

  • Name events hierarchically. Use dot-delimited names such as invoice.paid or user.created. Standard Webhooks recommends dot-delimited, hierarchical types limited to letters, digits and underscores, and says a given type should always carry the same payload schema.
  • Give every event a unique ID that stays the same across retries. This is what lets consumers deduplicate. Stripe recommends logging processed event IDs, and notes that in some cases two separate Event objects are generated for the same underlying change, so consumers should also compare data.object.id plus type.
  • Include an event timestamp, but don't promise ordering. Stripe doesn't guarantee that events arrive in the order they were generated, and its snapshot events record created in whole seconds, so distinct events can share a timestamp. Stripe's guidance is to avoid using created for ordering or deduplication, and to re-fetch the object from the API when you need the latest state. If ordering matters for your domain, consider adding a per-resource version or sequence number and documenting it.
  • Pick one timestamp format and document it. Stripe uses Unix seconds. Standard Webhooks recommends ISO 8601 for the payload timestamp (and Unix seconds for the signed webhook-timestamp header). Either is fine; mixing them silently is not.
  • Let customers filter by event type. Stripe and Standard Webhooks both recommend letting consumers subscribe only to what they need, and filtering on your side. Stripe also caps an account at 16 webhook endpoints.

Thin vs. thick payloads

  • Thick (full) payloads carry the whole object. Consumers get context immediately, but payloads are bigger and can go stale if the resource changes quickly.
  • Thin payloads carry the event type and an ID, and the consumer fetches the rest. Standard Webhooks lists real advantages: less data to generate and send, easier to produce from any code path, easier to evolve (you can make a thin payload fuller later, but not the reverse), and better access control, since every read goes through your API and can be audited.

Stripe supports both models. Its classic API v1 events are snapshot events that include a copy of the object at the time of the event. Its newer API v2 events are thin events that contain the event type and object ID, and Stripe's SDKs provide helpers to fetch the related object or the full event. Thin and snapshot events use separate webhook endpoints.

Size limits. The numbers vary a lot by provider. GitHub caps payloads at 25 MB and simply won't deliver an event that would exceed it, for example when a huge number of branches or tags are created at once. Standard Webhooks takes the opposite stance and recommends keeping payloads small, usually under about 20 kB, and passing a link when you need to send something large. A sensible default: aim for small, and fall back to a reference (a signed download URL, or a resource URL to query) for anything big.

Versioning

Payload shapes will change. Stripe's approach is worth understanding in detail:

  • Each account (and each event destination) has an API version, and the version in effect when the event occurs determines the structure of the event sent to you.
  • Events are immutable. Upgrading your API version later doesn't rewrite events that already exist, and fetching an old event through a newer API version doesn't change its structure.
  • Versions are named by date plus a release name, for example 2026-03-25.dahlia. Since the 2024-09-30 acacia release, Stripe ships monthly versions with no breaking changes, plus a major release with breaking changes twice a year.

For your own provider, two patterns work well together: let customers pin a version per endpoint and run outgoing payloads through a compatibility transformer, and put the version in the envelope so consumers can branch on it. Make additive changes (new fields, new event types) the default, and tell consumers to ignore fields and event types they don't recognize.


3. Security: signatures, replay protection and secrets

Webhooks go to public URLs, so receivers need two guarantees: the request really came from you (authenticity), and it wasn't altered or replayed (integrity and freshness).

Code example
Your server                                    Customer server
    │                                                │
    │ 1. Serialize the exact body you will send      │
    │ 2. Compute HMAC-SHA256 over timestamp + body   │
    │ 3. Set the signature header                    │
    ├───────────────────────────────────────────────►│
    │            POST /webhook                       │ 4. Recompute HMAC with the shared secret
    │                                                │ 5. Compare in constant time
    │                                                │ 6. Check the timestamp is recent

Three real signature schemes

GitHub sends X-Hub-Signature-256: sha256=<hex>, an HMAC-SHA256 hex digest of the raw request body, keyed with your secret. It still sends the older SHA-1 X-Hub-Signature header for compatibility but recommends the SHA-256 one. There is no timestamp in the signed content, so a captured request can be replayed and still verify. GitHub's advice for replay protection is to track the X-GitHub-Delivery ID and reject repeats. Its docs also give consumers a fixed test vector to check their implementation. You can reproduce it in a terminal:

Code example
printf "Hello, World!" | openssl dgst -sha256 -hmac "It's a Secret to Everybody"
# 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

Stripe sends Stripe-Signature: t=<unix>,v1=<hex>. The signed string is the timestamp, a literal period, and the raw JSON body. Because the timestamp is inside the signed content, an attacker can't change it without invalidating the signature, so a receiver can safely reject old requests. Stripe's libraries default to a 5-minute tolerance, and Stripe warns against a tolerance of 0, which disables the recency check. Receivers should ignore any scheme other than v1 (test events also carry a fake v0 signature) to avoid downgrade attacks.

Standard Webhooks goes one step further and signs the message ID as well as the timestamp and body: msg_id.timestamp.payload. It uses three headers (webhook-id, webhook-timestamp, webhook-signature). The signature is base64-encoded and prefixed with v1,, and the header is a space-delimited list so several signatures can be sent at once. Symmetric secrets are 24 to 64 random bytes, base64-encoded with a whsec_ prefix. The spec also defines an asymmetric option (ed25519, signature prefix v1a,), and recommends preferring it when you don't control both the producer and the consumer, since receivers then hold only a public key.

Sign every attempt, not just every event

Stripe generates a fresh timestamp and signature each time it delivers an event, including retries. Standard Webhooks draws the same line: the attempt timestamp changes on every retry, while the message ID and the event's own timestamp stay the same. If you signed once at event creation and reused the header, a retry a few hours later would fail every receiver's tolerance check. Build the signature inside your delivery worker, right before the HTTP request.

Node.js: sign and verify

This Stripe-style implementation supports secret rotation by emitting one v1 signature per active secret:

Code example
import crypto from 'node:crypto';

/**
 * Builds a Stripe-style signature header.
 * @param {string} rawBody  Exact UTF-8 string you will send as the request body.
 * @param {string|string[]} secrets  One secret, or two during a rotation window.
 * @returns {string} e.g. "t=1773921600,v1=<hex>,v1=<hex>"
 */
export function signStripeStyle(rawBody, secrets, timestamp = Math.floor(Date.now() / 1000)) {
  const signedPayload = `${timestamp}.${rawBody}`;
  const sigs = [].concat(secrets).map((secret) =>
    crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex')
  );
  return `t=${timestamp},${sigs.map((s) => `v1=${s}`).join(',')}`;
}

And the receiving side, which you should publish in your docs, in every language your customers use:

Code example
export function verifyStripeStyle(rawBody, header, secret, toleranceSec = 300) {
  const parts = header.split(',').map((p) => p.split('='));
  const t = parts.find(([k]) => k === 't')?.[1];
  const candidates = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || candidates.length === 0) return false;

  // Reject stale (or far-future) timestamps.
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest();

  // timingSafeEqual throws if lengths differ, so check length first.
  return candidates.some((hex) => {
    const got = Buffer.from(hex, 'hex');
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

Two implementation notes apply to every language:

  • Sign and verify the raw body. Stripe and Standard Webhooks both warn that parsing the JSON and re-serializing it changes whitespace or key order and breaks the signature. Frameworks that parse the body before your handler runs are the most common cause of "signature verification failed" tickets.
  • Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hash_equals). GitHub and Standard Webhooks both call this out, and GitHub specifically warns against a plain ==.

If you'd rather follow the open standard, the Standard Webhooks variant is only a few lines different:

Code example
export function signStandardWebhooks(rawBody, msgId, secret, timestamp = Math.floor(Date.now() / 1000)) {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const sig = crypto.createHmac('sha256', key)
    .update(`${msgId}.${timestamp}.${rawBody}`, 'utf8')
    .digest('base64');

  return {
    'webhook-id': msgId,               // stable across retries
    'webhook-timestamp': String(timestamp), // changes on every attempt
    'webhook-signature': `v1,${sig}`,
  };
}

Secrets: format, uniqueness and rotation

  • Use a recognizable prefix. Both Stripe and Standard Webhooks use whsec_. A recognizable prefix makes secrets easier to spot in code reviews and secret-scanning tools.
  • One secret per endpoint. Stripe generates a unique signing secret for each endpoint, and Standard Webhooks warns that reusing keys across customers creates security problems.
  • Rotate with an overlap window. When a customer rolls a secret, Stripe lets them keep the old one alive for up to 24 hours, and during that time it sends one signature per active secret. Standard Webhooks solves the same problem by allowing several space-separated signatures in one header. Receivers accept the request if any one signature verifies.

Transport and network safeguards

  • HTTPS only. Stripe requires HTTPS for live-mode endpoints and supports TLS 1.2 and 1.3 only.
  • Publish your source IPs. Stripe recommends receivers allowlist its published IP addresses in addition to verifying signatures, and GitHub exposes its list via the GET /meta endpoint. Standard Webhooks notes that enterprise customers behind firewalls often require static source IPs.
  • Protect yourself from SSRF. Customers give you arbitrary URLs, and your workers will call them from inside your network. Standard Webhooks recommends routing webhook traffic through a filtering proxy (it names Stripe's open-source Smokescreen) and running the workers in a private subnet that can't reach internal services. Don't follow redirects either: Stripe, Svix and Standard Webhooks all treat a 3xx as a failed delivery, and redirect-following is a classic way to slip past URL validation.

4. Dispatch architecture: build for async resiliency

The cardinal rule: never send a webhook inside the request that caused it. If POST /api/v1/users waits on a customer's slow endpoint, your own API hangs. Stripe gives consumers the mirror-image advice: process events through an asynchronous queue and return a 2xx before doing heavy work, because spikes (such as monthly subscription renewals) can overwhelm synchronous handlers.

Code example
┌────────────┐   ┌────────────────┐   ┌─────────────┐   ┌────────────────┐
│ App / API  │──►│ Outbox table   │──►│ Queue       │──►│ Dispatch       │
│ (business  │   │ (same DB tx)   │   │ (per-tenant │   │ workers        │
│  change)   │   │                │   │  fairness)  │   │                │
└────────────┘   └────────────────┘   └─────────────┘   └───────┬────────┘
                                                                │
                              ┌─────────────────────────────────┤
                              ▼                                 ▼
                    ┌────────────────────┐          ┌───────────────────────┐
                    │ Rate limit +       │          │ Circuit breaker       │
                    │ concurrency cap    │          │ (per endpoint)        │
                    └─────────┬──────────┘          └───────────┬───────────┘
                              └──────────────┬──────────────────┘
                                             ▼
                                ┌─────────────────────────┐
                                │ Egress proxy (SSRF      │
                                │ filter) → customer URL  │
                                └─────────────────────────┘

Don't lose events: the transactional outbox

The classic failure mode is a dual write. If you publish to the queue inside the database transaction, the transaction might roll back after the event is already out. If you publish after commit, your process might crash before sending. The transactional outbox pattern avoids both: write the event to an outbox table in the same database transaction as the business change, and let a separate relay (a polling publisher, or change-data-capture) move rows to the broker. Chris Richardson's microservices.io catalog documents the pattern in detail.

Workers and timeouts

Workers pull tasks from the queue, look up the customer's subscriptions, build and sign the payload, and issue the HTTP request. Choose your request timeout deliberately. GitHub gives receivers 10 seconds, Svix 15, and Standard Webhooks suggests 15 to 30. Anything much shorter than 10 seconds punishes receivers that do modest work before responding, while very long timeouts tie up worker capacity when an endpoint hangs. Whatever you pick, document it.

Noisy neighbors and concurrency limits

If customer A triggers 100,000 events and customer B triggers one, a single FIFO queue makes B wait behind A. Partition or shard queues per tenant (or per endpoint), and cap in-flight requests per endpoint so a slow customer can't consume your whole worker pool. Numbers such as "10 to 20 concurrent requests per endpoint" are reasonable starting points, but tune them from real traffic. Also respect 429 Too Many Requests and Retry-After responses from customers, which Standard Webhooks specifically recommends.

Circuit breakers

The Circuit Breaker pattern, as documented in Microsoft's Azure Architecture Center, has three states. Closed means requests flow normally while failures are counted. Open means requests are rejected immediately, without waiting for a timeout. Half-open lets a limited number of trial requests through, and closes the circuit again after enough consecutive successes.

Applied per endpoint, this stops you from spending worker capacity on a dead URL. When the breaker is open, hold that endpoint's events in a delayed state rather than discarding them, then let a probe delivery decide when to resume. The thresholds (for example, opening after a run of consecutive failures or a high error rate within a window) are yours to tune.

A breaker is a short-term, automatic protection measured in minutes. Don't confuse it with endpoint disabling (section 5), which is a days-long policy decision that also notifies the customer.

Dead-letter queues and operational events

When a message has exhausted its retry schedule, mark it as failed and keep it, so it can be inspected and replayed. Svix, for example, marks the message Failed for that endpoint and emits an operational webhook of type message.attempt.exhausted to the sender's account. Emitting events about your own delivery health, such as "this endpoint was disabled", through the same webhook channel is a neat pattern, because your customers already know how to consume it.

Delivery semantics: at-least-once, not exactly-once

A retry-based system delivers a message at least once. Duplicates happen, and Stripe says so plainly. Make that a documented contract, keep the event ID stable across retries, and tell consumers to deduplicate on it. Standard Webhooks suggests using webhook-id as an idempotency key (for example, remembering IDs in Redis for a few minutes).


5. Retries and fault tolerance

Even well-run customers have deploys, restarts and blips. Your retry policy decides whether a 20-minute outage becomes a lost event or a non-event.

What the reference points do

SystemAutomatic retriesWhen it gives up
Stripe (live mode)Up to 3 days with exponential backoff. The exact schedule isn't published, but the Dashboard shows the next retry time. Sandbox: 3 retries over a few hoursStripe emails you about failing webhooks; an independent review by Svix reports that endpoints are disabled after about 3 days of continuous failure
GitHubNone. A failed delivery stays failed until someone redelivers itManual redelivery is possible for 3 days
SvixImmediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h (about 27.5 hours in total)Marks the message failed; disables the endpoint after 5 days of failures
Standard Webhooks (example)A ten-step schedule stretching to a 24-hour gap, about 75.5 hours from the first attemptNotify the customer through another channel (for example email) and disable the endpoint

Two takeaways. First, there's no universal standard: the Standard Webhooks spec recommends a multi-day schedule with exponential backoff and jitter, but every provider picks its own numbers. Second, the retry window is a product promise. It tells your customers how long they can be down before data is at risk, so publish it.

Also note that Stripe runs a much shorter retry policy in test mode than in live mode. That's a useful pattern for your own sandbox environment: fast feedback for developers, without letting test endpoints consume production capacity for days.

Exponential backoff with full jitter

Fixed-interval retries are dangerous. If an outage takes down hundreds of customer endpoints and every failed delivery retries on the same clock, they all hit at once when the endpoint recovers. Jitter breaks up that synchronization. The AWS Architecture Blog's "Exponential Backoff and Jitter" describes three variants; "full jitter" picks a random delay between zero and the capped exponential value:

Code example
sleep = random(0, min(cap, base × 2^attempt))
Code example
import random

# Delay before each attempt. Index 0 is the initial delivery.
# This is Svix's published schedule, about 27.5 hours end to end.
SCHEDULE_SECONDS = [0, 5, 5 * 60, 30 * 60, 2 * 3600, 5 * 3600, 10 * 3600, 10 * 3600]


def full_jitter_delay(attempt: int, base: float = 5.0, cap: float = 6 * 3600) -> float:
    """AWS 'full jitter': random(0, min(cap, base * 2**attempt)). attempt=0 is the first retry."""
    return random.uniform(0, min(cap, base * (2 ** attempt)))


def scheduled_delay(attempt_index: int, jitter: float = 0.2) -> float | None:
    """Fixed schedule with proportional randomisation.
    Returns None when the schedule is exhausted: mark the message failed."""
    if attempt_index >= len(SCHEDULE_SECONDS):
        return None
    return SCHEDULE_SECONDS[attempt_index] * random.uniform(1 - jitter, 1 + jitter)


def next_delay(attempt_index: int, retry_after: float | None = None, max_wait: float = 6 * 3600):
    """Honour a customer's Retry-After header (capped) when present."""
    if retry_after is not None:
        return min(retry_after, max_wait)
    return scheduled_delay(attempt_index)

Full jitter is the simplest to reason about, but it can produce very short delays. A published schedule with a modest random spread (as in scheduled_delay) gives you predictable retry windows to document, plus enough randomness to avoid herds. Pick one and be explicit about it.

HTTP status codes: what counts as failure

The rule Stripe, Svix and Standard Webhooks share is simple: only a 2xx is a success; everything else is a failure. Standard Webhooks adds a few refinements:

ResponseTreat asWhat to do
2xxSuccessMark delivered
3xxFailureDon't follow the redirect. Ask the customer to update the endpoint URL
410 Gone"Stop sending"Disable the endpoint. This is the receiver's way of unsubscribing
429Rate limitedThrottle that endpoint and honour Retry-After
502, 504 (and 503 with Retry-After)Receiver under loadRetry, and slow down for this endpoint
Other 4xx, other 5xx, timeouts, connection and TLS errorsFailureRetry on schedule

Notice what's missing: a rule that says "don't retry 4xx". It's tempting to treat 400, 401, 403 and 404 as terminal, since the customer's endpoint is obviously misconfigured. But Stripe's status-code guidance lists these as ordinary failures, and there's a good reason. A very common cause of 4xx responses is a bad deploy on the customer's side, such as a wrong signing secret making verification fail and return 400. If you give up immediately, a fixable mistake becomes permanently lost events. Retrying for days gives them time to notice and recover, and an eventual 410 or endpoint disabling handles the truly dead ones.

Automatic endpoint disabling

A dead endpoint shouldn't get a fresh multi-day retry schedule for every event forever. Standard Webhooks recommends that when delivery fails consistently over a long period, you both notify the customer through another channel and disable the endpoint. Svix's rule is instructive: an endpoint is disabled when all attempts fail for 5 days, and the clock only starts after multiple failures within a 24-hour span with at least 12 hours between the first and last failure, so a short outage never counts. Stripe stops retries for events destined to a disabled or deleted endpoint.

Whatever thresholds you choose, make re-enabling easy, and make the notification specific (which endpoint, what error, since when).


6. Developer experience and observability

Backend reliability is only half the job. If customers can't see what happened, your support inbox fills up with "why didn't I get my webhook?" tickets.

Code example
┌────────────────────────────────────────────────────────────────────────┐
│ WEBHOOK DELIVERIES                                                     │
│                                                                        │
│ EVENT ID     TYPE             STATUS     HTTP     DURATION   AGE       │
│ evt_90112    order.created    Delivered  200      124 ms     2m        │
│ evt_90111    invoice.failed   Pending    500      5002 ms    10m       │
│              └─ next retry in 28m                                      │
│                                                                        │
│ ▸ evt_90111   Request headers · Payload · Response body (first 2 KB)   │
│                                                                        │
│  [ Resend ]     [ Send test event ]     [ Disable endpoint ]           │
└────────────────────────────────────────────────────────────────────────┘

Delivery logs

The bar is set by what Stripe and GitHub already expose:

  • Stripe lists each event's delivery status (Delivered, Pending or Failed) per endpoint, with the HTTP status of each attempt and the time of the next scheduled retry. It also maps common failure modes (connection errors, redirects, 4xx, 5xx, TLS errors, timeouts) to concrete fixes.
  • GitHub keeps a "Recent deliveries" view for each webhook, where you can inspect a delivery's request and response and redeliver it. Since October 2023, that history is limited to the last 3 days (7 days on GitHub Enterprise Server).

At minimum, show the exact request body and headers that were sent, the response status and latency, the start of the response body (the first couple of kilobytes is usually enough to see an error message), and the state of the delivery: delivered, pending a retry, or failed.

Manual replay

Customers will fix a bug on their side and want the missed events back. Provide replay for a single event and for a time range. Stripe's Dashboard can resend an event for up to 15 days after it was created, and its CLI (stripe events resend) works up to 30 days. One subtle detail: Stripe says manually resending an event that already had failed deliveries doesn't cancel its automatic retries, even if the manual attempt succeeds, so consumers need idempotent handlers regardless. GitHub allows redelivery within 3 days, and because X-GitHub-Delivery stays the same on redelivery, receivers can recognize the repeat. GitHub's docs also suggest scripting a periodic job that finds failed deliveries through the REST API and redelivers them, which is a useful reminder that replay should be available through your API as well as your UI.

Test events and local development

  • Send a ping when an endpoint is created. GitHub sends a ping event containing a random "zen" string when you create a webhook, so developers can confirm reachability and signature handling before real data flows. Offer the same, plus a "send test event" button for any event type.
  • Give people a way to receive webhooks locally. Stripe's CLI can forward events to a local server (stripe listen) and trigger sample events (stripe trigger), so developers don't need a public URL to start.
  • Publish signature test vectors and per-language verification snippets. GitHub's docs include a known secret, payload and expected signature so implementers can check their code. It's a small addition that prevents a large class of support questions.

Also worth offering

Standard Webhooks lists a few "nice to have" features that turn out to matter in practice: multiple endpoints per customer (fan-out), so one event can reach several systems, and an endpoint-management API so customers and third-party tools can create, list and remove endpoints programmatically.


7. Build vs. buy: what it takes to run this in production

Everything above is a lot of surface area. A production-grade sender needs:

  • a durable queue and storage for events and delivery attempts, plus an outbox to keep them consistent with your database;
  • per-tenant fairness, concurrency limits and an SSRF-safe egress path;
  • signing, secret storage and zero-downtime rotation;
  • a retry scheduler, circuit breakers, endpoint disabling and dead-letter handling;
  • delivery logs, replay, test events and (ideally) an embeddable customer-facing UI;
  • monitoring, alerting and on-call for all of the above.

That's a real product. Teams typically choose one of three paths:

  1. Build in-house. Maximum control, and it makes sense when webhooks are core to your business or you have unusual compliance needs. Budget for ongoing operations, not just the initial build.
  2. Self-host open-source infrastructure. Options include Hookdeck's Outpost (an open-source, Apache-2.0 project for outbound webhooks and event destinations, which needs Redis or Redis Cluster, PostgreSQL and a supported message queue), and Svix's open-source, self-hostable server. You keep control of the data and run the infrastructure yourself.
  3. Use a managed service. You trade some control for a much smaller operational footprint.

Where InstaWebhook fits

InstaWebhook focuses on the reliability layer that sits between an event and its destination. According to its feature documentation, it provides:

  • Durable endpoints: it validates the endpoint token, enforces limits, stores the event and queues delivery before it responds.
  • Delivery timelines: each event shows when it was received, queued, attempted, retried, delivered or dead-lettered.
  • Configurable retries: default backoff schedules, or a schedule per endpoint, with durable job leasing and crash recovery.
  • Replay and a dead-letter queue: exhausted events go to a dedicated queue for investigation, bulk retry, replay or resolution.
  • Signing: timestamped HMAC signatures on outgoing deliveries, with published verification examples.
  • Audit logs and optional BYO database storage: payloads can live in your own PostgreSQL schema.

It's also a practical fit when your customers need a sandboxed endpoint to receive, inspect and test events while integrating with your API: create an endpoint, send a test event, and read the delivery timeline before production traffic depends on it.

Whichever route you take, evaluate it against the checklist below rather than the marketing page.


8. Conclusion: the webhook provider checklist

Stripe and GitHub took different routes to the same goal: events that arrive reliably, that receivers can trust, and that developers can debug. Standard Webhooks packages much of that experience into a spec you can adopt today. Use this checklist before you ship.

Payload

  • Are events wrapped in a consistent envelope with a unique id (stable across retries), a type, a timestamp and a data object?
  • Do you document the API version, and can customers pin one per endpoint?
  • Is your payload size policy explicit, with a reference fallback for large data?
  • Have you told consumers that ordering isn't guaranteed and duplicates can happen?

Security

  • Are requests signed with HMAC-SHA256 over a string that includes the timestamp (and ideally the message ID) and the raw body?
  • Do you generate a fresh timestamp and signature on every delivery attempt?
  • Is there one secret per endpoint, with a recognizable prefix and an overlapping rotation window?
  • Do you require HTTPS, refuse to follow redirects, and route traffic through an SSRF-safe egress path?
  • Do you publish your source IPs, a recommended timestamp tolerance, and verification snippets with test vectors?

Architecture

  • Is delivery fully decoupled from your API request path, with an outbox so events aren't lost?
  • Do you have per-tenant fairness and per-endpoint concurrency limits?
  • Do you have per-endpoint circuit breakers and a dead-letter state?

Retries

  • Do you use a documented multi-day schedule with exponential backoff and jitter?
  • Do you treat only 2xx as success, honour Retry-After and 410 Gone, and avoid discarding events on 4xx?
  • Do you notify customers and disable endpoints that fail for days, and make re-enabling easy?

Developer experience

  • Can customers see every attempt (request, response, latency, next retry), send test events, and replay one event or a range from the UI and the API?
  • Have you honestly compared build, self-host and managed options against the operational cost?

Sources