Webhook delivery  / field guide
Practitioner field guide · 2026-09-07

The retry you are counting on was never promised

How platforms actually push events to servers other people run, reconstructed from the layer of the record that marketing never touches: GitLab's incident reviews and shipped source, GitHub's staff answers, three open-source delivery services, and the one specification their builders wrote together. Read it before you assume "at-least-once" means what you think it means, on either side of the wire.

25 primary sources 7 organisations 4 incident reviews Evidence through Sep 2026 Read: ~28 min
01

The territory

The problem, who has solved it in production, what this guide covers, and what it deliberately leaves out.

Strip the technology name away and the problem is this: when something happens inside your system, you must tell thousands of other organisations' servers about it, promptly and at least once, over HTTP endpoints they wrote, hosted on infrastructure you cannot see, with capacity you cannot know. At any moment some of those endpoints are slow, some are dead, some return errors that mean "stop" and some return errors that mean "try later", and none of them will tell you which. Every delivery you attempt consumes your compute; every delivery you skip breaks someone's integration. The sender carries all of the operational burden and receives none of the operational signal.

The systems studied here have all solved it in production, in public, with their working shown: GitLab (whose webhook reliability programme is fully visible in its issue tracker, incident reviews and shipped Ruby source), GitHub (through its staff answers, its published receiver contract and a 2026 incident thread), Svix, Convoy and Hookdeck's Outpost (three open-source delivery services whose configuration files are their design documents), WooCommerce (whose tracker records what happens when the sender lives inside the application), and the Standard Webhooks consortium (Svix, Zapier, Twilio, Mux, ngrok, Supabase, Lob and Kong), whose specification is the closest thing the field has to a shared decision record.

10 s
GitHub's entire patience for your endpoint: respond with a 2xx within 10 seconds or the delivery has failed
0
Automatic retries of a failed webhook delivery at GitHub, per GitHub staff; recovery is the receiver's job
40
Consecutive failures before GitLab disables a webhook permanently; the code comment budgets at least a month of backoff first
~27.6 h
Cumulative automatic retry window in Svix's shipped default schedule (derived: 5+300+1800+7200+18000+36000+36000 seconds)

The surprise sits in the first two tiles. The two platforms whose webhooks trigger most of the world's CI do not retry failed deliveries. GitHub staff said it flatly in a community thread: "There are no retries of failed webhook deliveries and no API for listing or retrieving failed webhook events" (an inspection and redelivery API arrived later, but redelivery remains something the receiver requests, not something GitHub does). GitLab's own feature proposal for retry policies, open since March 2022, describes the current behaviour as "drop: as current - allow messages to be dropped". Meanwhile the webhook-as-a-service ecosystem standardised the opposite: the Standard Webhooks specification recommends "a retry schedule spanning multiple days, with an exponential backoff", and Svix, Convoy and Outpost all ship it. At-least-once delivery is real in this field, but which side of the wire implements it depends entirely on who your sender is, and most receiving teams have never checked.

Figure 1 · The landscape: one event, four kinds of stranger

Your platform

HTTP POST, ~10 s budget

holds the worker
until timeout

connection refused

skipped: breaker open

Domain event

Durable queue

Dispatch workers

Customer A endpoint
healthy, 80 ms

Customer B endpoint
slow

Customer C endpoint
dead

Customer D endpoint
disabled after
consecutive failures

Your platform

HTTP POST, ~10 s budget

holds the worker
until timeout

connection refused

skipped: breaker open

Domain event

Durable queue

Dispatch workers

Customer A endpoint
healthy, 80 ms

Customer B endpoint
slow

Customer C endpoint
dead

Customer D endpoint
disabled after
consecutive failures

Every arrow leaves the sender's control at the egress boundary. The dispatch fleet's health is a function of other people's servers, which is the core tension of the whole problem. Reconstructed from Svix, Convoy and GitLab's docs.
Diagram source
Scope, and an honest constraint

This guide covers the sender's side: retry policy, endpoint health, isolation of the delivery path, and the security of calling URLs your customers typed. The receiver's half, idempotent processing of duplicates, is the subject of the earlier guide Making retried requests safe in this collection and is not repeated here.

The evidence base is deliberately and unusually narrow in kind: source code, decision records, issue threads and incident reviews, all on github.com and gitlab.com. The research session that produced this guide ran behind an egress policy that blocked every other host, including the engineering blogs of Stripe and Segment and the archives of arXiv and USENIX, so accounts known to exist there are not cited here; nothing was cited that was not fetched and read. The hunt section includes the queries to run for that missing layer. Treat the absence of blog, paper and talk evidence in this guide as an artefact of the constraint, not of the public record.

02

How it is actually built

The common shape across GitLab, Svix, Convoy and Outpost, with the divergence points marked. Every box below is attributable to at least two systems.

Across four independent implementations the delivery pipeline has the same skeleton. An event is matched against subscriptions and fanned out into one delivery task per endpoint. Tasks land on a durable queue: Sidekiq on Redis at GitLab, a Redis task queue at Svix ("PostgreSQL for the storage of events, Redis for the task queue and cache", per the README), Redis with an experimental Postgres option at Convoy, and "Redis, PostgreSQL, a supported message queue" at Outpost. Dispatch workers with bounded concurrency (Svix ships worker_max_tasks = 500 as the default cap) pull tasks, make the HTTP call with a hard timeout, and write two records: an attempt log the customer can see, and a per-endpoint failure counter that drives the health machinery. Nobody delivers synchronously from the request path, and every one of the four keeps per-attempt history rather than only a current status.

Figure 2 · Reference architecture of a webhook sender

Egress boundary

Delivery service: the common shape

Application

breaker open:
skip or park

failed: re-enqueue
with backoff (if retrying)

Domain events

Fan-out
match subscriptions

Durable queue
Sidekiq / Redis / SQS

Dispatch workers
bounded concurrency,
hard timeout

Attempt log
one row per try

Endpoint state
consecutive-failure counter

Outbound proxy
static IPs, SSRF filter

Customer endpoints

Egress boundary

Delivery service: the common shape

Application

breaker open:
skip or park

failed: re-enqueue
with backoff (if retrying)

Domain events

Fan-out
match subscriptions

Durable queue
Sidekiq / Redis / SQS

Dispatch workers
bounded concurrency,
hard timeout

Attempt log
one row per try

Endpoint state
consecutive-failure counter

Outbound proxy
static IPs, SSRF filter

Customer endpoints

The two stores on the right are the part homegrown senders omit first and regret first: the attempt log is the customer's debugging surface, and the endpoint state is what keeps dead endpoints from eating the fleet. Reconstructed from Svix, Convoy, Outpost and GitLab's source.
Diagram source

The endpoint state machine

Every system tracks consecutive failures per endpoint and stops calling endpoints that keep failing. GitLab's shipped constants: a grace threshold of 3 (disabled on the 4th consecutive failure), backoff starting at 1 minute and doubling to a 1-day cap, permanent disablement past 39. Svix disables after 120 hours of unbroken failure; Convoy "opens the circuit when an endpoint fails consecutively, then probes it".

Sources: auto_disabling.rb, Svix config, Convoy

The attempt log

Svix persists every attempt "not only to retry failed attempts but to give users visibility"; Convoy markets itself on the debugging surface; GitLab exposes recent deliveries per hook and grew a customer request for resending "when they have many failures (hundred or more)" programmatically. The 2026 consumer complaint thread on GitHub's forum names the absence of this surface as the industry's biggest gap: "Providers give almost no visibility into what actually happened."

Sources: Svix, GitLab #372826, GitHub community

The egress boundary

The sender makes HTTP requests to URLs its customers typed, which is a server-side request forgery machine unless filtered. Svix blocks "SSRF attacks and Internal IP addresses" by default and warns that whitelisting private subnets "is a security risk"; Convoy's tracker carried a live SSRF fix for endpoint probing as recently as August 2026, and the fix died unmerged. Static egress IPs double as the customer's firewall story.

Sources: Svix config, Convoy PR #2827

Where the four diverge is instructive. GitLab's sender is not a service at all; it is a worker class inside the same Sidekiq fleet that runs everything else on GitLab.com, which is why, in every incident this guide catalogues, webhook latency moved when something unrelated saturated Sidekiq. The three dedicated services made the opposite choice, and Outpost pushes the divergence one step further: its destination list is "Webhooks, Hookdeck Event Gateway, Amazon EventBridge, AWS SQS, AWS S3, GCP Pub/Sub, RabbitMQ, and Kafka", which quietly concedes that for high-volume consumers the right delivery mechanism is not HTTP at all but a handoff to a bus the receiver already operates. That option changes who owns the retry problem, which is the subject of the next section.

Figure 3 · Endpoint lifecycle, with GitLab's shipped constants

2xx response, counter resets

4th consecutive failure

disabled_until expires
(1 min, doubling to 24 h cap)

next delivery succeeds

next delivery fails,
backoff doubles

40th consecutive failure

human sends test request
that returns 2xx

Healthy

Disabled

Probation

Dead

2xx response, counter resets

4th consecutive failure

disabled_until expires
(1 min, doubling to 24 h cap)

next delivery succeeds

next delivery fails,
backoff doubles

40th consecutive failure

human sends test request
that returns 2xx

Healthy

Disabled

Probation

Dead

The states an operator sees. GitLab's code comment budgets "at least 1 month" of failing-with-backoff before the permanent state can be reached. Drawn from auto_disabling.rb and GitLab's docs.
Diagram source
03

The decisions that matter

Five forks in the road, each with the choice a real system made, the stated reason, and the condition that flips the answer.

Decision 1: when a delivery fails, does the sender retry it?

The split
  • GitHub and GitLab: no. GitHub staff: "There are no retries of failed webhook deliveries"; GitLab's proposal #355721 documents "drop: as current".
  • Svix, Convoy, Outpost and the Standard Webhooks spec: yes, for roughly a day to multiple days, with exponential backoff and a dead-letter queue.
Stated reasons
  • GitLab prices retries in storage: "the cost of storing these records (and potential abuse)" makes it "a good candidate for an EE feature".
  • GitLab also cannot rank events: "we have no way to distinguish nice-to-have messages that will tolerate failure to essential messages that must be delivered eventually."
Flips when
  • Events carry money or state transitions rather than notifications: then the spec's multi-day schedule is the floor, and a receiver-facing replay surface is mandatory.
  • Receivers can cheaply re-derive state by pulling your API: then GitHub's model (short patience plus a redelivery API) is honest and far cheaper.

Notice what the split correlates with. The platforms that refuse to retry are the ones whose events are advisory pointers into a pullable system of record; a missed push event is recoverable by listing commits. The systems that retry for days are the ones sold to senders whose events are the system of record, where a dropped invoice.paid is money. Neither camp is wrong; the error is inheriting a camp without checking which kind of event you emit. GitLab's tracker states the consequence of not deciding explicitly: users with essential messages and users with chatty CI noise get the same drop-on-failure treatment, because the sender cannot tell them apart.

Decision 2: what does the sender do about an endpoint that keeps failing?

Chosen, everywhere
  • A per-endpoint circuit breaker. GitLab: backoff after 4 consecutive failures, kill at 40. Svix: disable after 120 h of unbroken failure. Convoy: open the circuit, probe, then disable and notify. The spec: "notify the consumers using other channels (e.g. email), and ... disable future delivery".
Rejected
  • Trying forever. GitLab's epic names the motive for refusing it: "protect ... users across the system from the potential abuse or misuse of a small few."
  • GitLab's own first cut (2022): 4xx responses disabled the hook immediately and permanently, on the theory that client errors are configuration errors.
Flips when
  • The 4xx-is-permanent theory flipped in practice: receivers return 404 and 403 transiently "while we deploy/migrate/initialize", so GitLab 17.11 unified all failures onto the self-healing path. Distinguish status codes for diagnostics, not for lifecycle decisions.

Decision 2 is the strongest convergence in the whole corpus: four independent implementations, one mechanism. The disagreement that remains is only about thresholds and semantics, and GitLab ran the experiment in public. Its 2022 design treated a 4xx as proof of receiver misconfiguration and disabled the hook on the spot; the issue tracker then filled with self-hosted administrators whose integrations broke every deploy window, one calling it "rather harsh to break self-hosted environments for the sake of protecting SaaS". The 2025 redesign (issue #396577, shipped in 17.11) lets every failure mode self-heal and reserves permanence for the 40th consecutive failure, which the code comment translates into operator time: a hook fails with maximum backoff for "at least 1 month" before the platform gives up on it. A public feedback issue is still tuning it in 2026. That is a complete, documented argument cycle most teams re-run privately and expensively.

Decision 3: does the sender live inside the application or beside it?

Observed
  • GitLab: inside, as Sidekiq workers sharing the fleet with every other background job.
  • WooCommerce: inside, queued during the request's shutdown phase.
  • Svix, Convoy, Outpost: beside, as a service with its own queue, workers and stores.
What inside costs
  • Every GitLab webhook-delay incident in this guide was a Sidekiq incident, not a webhook incident: the delivery path inherits every neighbour's failures.
  • WooCommerce's shutdown-hook queueing loses events silently when any plugin exits first.
Flips when
  • Low volume and advisory events: inside is fine and vastly simpler.
  • The moment webhook volume can spike with tenant activity (CI job completion, bulk imports), the shared substrate becomes the coupling that turns someone else's burst into your delivery SLO breach.

Decision 4: where does per-endpoint delivery state live?

Chosen
  • Dedicated services: per-attempt rows in Postgres plus queue state; the failure counter is derived, not contended.
  • GitLab: counters on the webhook row itself (recent_failures, disabled_until).
The trap GitLab hit
  • Issue #352245: when "several thousands of jobs finish at roughly the same time", every log worker updates the same row; "concurrent updates to a single row could create a lock contention in the database", stalling unrelated jobs.
Flips when
  • One webhook can receive a burst of simultaneous events (fan-in from CI, batch operations). Then health bookkeeping must be sampled, rate-limited or moved off the hot row; GitLab's fix was to rate-limit the bookkeeping itself.

Decision 5: push the payload, or hand off to the receiver's bus?

The emerging option
  • Outpost ships EventBridge, SQS, S3, Pub/Sub, RabbitMQ and Kafka as first-class destinations alongside HTTP.
  • The spec's "thin payload" pattern (send the ID, let the receiver pull the body) is the same concession in miniature: it is "more future proof" and cheaper per delivery.
Plain HTTP push
  • Remains the default because it requires nothing from the receiver but a URL, which is the entire adoption story of webhooks.
Flips when
  • A receiver's volume makes their inbound HTTP tier the bottleneck, or their security posture forbids inbound ports. A bus handoff moves the retry, ordering and scaling problems onto infrastructure the receiver already trusts.

Figure 4 · Choosing your retry posture

pointer

system of record

yes

no

yes

no

Is the event the system of record,
or a pointer into one?

Can receivers recover by
pulling your API?

Can you afford to store
every undelivered attempt?

Short patience + redelivery API.
Document loudly that you do not retry.

Multi-day exponential schedule,
DLQ, replay surface for customers

Tier the events: retry the essential,
drop the chatty, and say which is which

pointer

system of record

yes

no

yes

no

Is the event the system of record,
or a pointer into one?

Can receivers recover by
pulling your API?

Can you afford to store
every undelivered attempt?

Short patience + redelivery API.
Document loudly that you do not retry.

Multi-day exponential schedule,
DLQ, replay surface for customers

Tier the events: retry the essential,
drop the chatty, and say which is which

Terminal nodes are actions, and each corresponds to a system in this guide: fire-and-forget is GitLab today, pull-recovery is GitHub's model, the multi-day schedule is the Standard Webhooks recommendation.
Diagram source
DecisionChosenRejectedBecauseEvidence
Retry failed deliveries?No (GitHub, GitLab); multi-day backoff (Svix, Convoy, Outpost, spec)The other camp's answerStorage cost and event criticality vs. pullable systems of recordGitHub staff, GitLab #355721
Persistently failing endpointCircuit breaker: backoff, then disable + notifyTrying forever; instant permanent disable on 4xxPlatform protection; then the 4xx theory broke real integrationsEpic 8083, #396577
Sender placementDedicated service (Svix, Convoy, Outpost)Shared job fleet (GitLab), request shutdown (WooCommerce)Shared substrate couples delivery latency to every neighbourIncident 20791, WC #44199
Delivery state placementPer-attempt rows; derived countersHot counters on the subscription rowFan-in bursts turn one row into a database-wide lock queueGitLab #352245
TransportHTTP push by default; bus handoff at volumeHTTP-only foreverInbound HTTP is the receiver's bottleneck and security objectionOutpost
04

What broke in production

Five published failures in three classes. The recurring lesson: webhook incidents are almost never caused by webhooks.

Group the public record by failure class and a pattern appears that should reorganise how you monitor this subsystem. Class one: the shared substrate degrades and webhook delivery degrades with it, while the webhook code is blameless. Class two: the delivery machinery's own bookkeeping becomes the bottleneck under fan-in. Class three: the sender is embedded in an application whose lifecycle silently eats or multiplies events. No incident in this corpus was caused by the part teams design most carefully, the HTTP call itself.

Postmortem

Sidekiq slows, webhooks are the symptom

AssumptionWebhook latency is governed by the webhook path.
What happened"Slowdowns in sidekiq workers, including WebHookWorker and related jobs, caused elevated webhook latency" on GitLab.com, 2025-10-29.
Blast radiusDelays up to 15 minutes, 14:10 to 16:45 UTC; "No webhooks failed to deliver, but the delays disrupted some customer workflows."
FixMonitored Sidekiq back to health; the structural exposure (shared fleet) remains.
Design ruleA sender on a shared job substrate inherits the substrate's SLO. Measure delivery lag as its own SLI, not as queue depth.
Postmortem

An eventing service chokes upstream of the hooks

AssumptionThe delivery pipeline's capacity is the binding constraint.
What happenedGitHub, 2026-02-03: "connection churn in our eventing service ... caused CPU saturation and delays for reads and writes, with subsequent downstream delivery delays."
Blast radiusPush webhooks delayed up to 40 minutes (average 10), 14:00 to 17:40 UTC; Actions job starts delayed with them.
FixObservability to detect faster, plus "correcting stream processing client configuration to prevent recurrence."
Design ruleDelivery lag compounds along the pipeline; the customer experiences the sum. Alert on end-to-end age of delivered events, not per-stage health.
Postmortem

One tenant's burst is everyone's delay

AssumptionBackground capacity is shared fairly by default.
What happenedGitLab.com, 2022-02-07: "one project created ~23K of jobs" in minutes; the queue backed up. A month later (2022-03-14) an "abusive project" repeated the shape, and mitigation was manually "removing the jobs created by the abusive project".
Blast radius~1 hour of delayed CI and merge activity in February; 15 minutes of failed page loads and API errors in March.
FixThe February review's corrective action: "Rate limit webhook execution and backoff."
Design ruleFan-out multiplies tenant activity; per-tenant rate limits on hook execution are capacity protection, not abuse tooling.
Source

The health bookkeeping becomes the bottleneck

AssumptionRecording a delivery's outcome is free.
What happenedThousands of CI jobs finish together, all firing the same project webhook; every log worker updates that webhook's row. "The concurrent updates to a single row could create a lock contention in the database", which "causes all these jobs to wait, decreasing throughput and increasing the backlog of other jobs."
Blast radiusReduced Sidekiq throughput across job classes during bursts (charted in the issue).
FixLimit concurrent updates to the row and rate-limit execution per hook.
Design ruleThe circuit breaker's state is itself shared mutable state under fan-in. Sample it, shard it or bound writes to it; do not put it on the hot path unguarded.
Source

The embedded sender eats events silently

AssumptionQueueing deliveries during application shutdown always runs.
What happenedWooCommerce queues webhooks on the shutdown hook: "If any of those plugins does an `exit` or causes an error, the remaining `shutdown` tasks are never executed, resulting in WooCommerce's pending webhooks to never be added to the Action Scheduler queue."
Blast radiusSilent event loss, unbounded in time; no failure is recorded because no attempt was made.
FixProposed: queue at shutdown priority zero so plugins cannot pre-empt it. Not structural; the window shrinks but does not close.
Design ruleEnqueue on commit, inside the transaction boundary, never on process teardown. Loss without an attempt record is the worst failure mode because no alert can see it.
Source

Delivery fires the event it is delivering

AssumptionReading data to build a payload has no side effects.
What happened"Webhook delivery reads the order to build a REST payload ... HPOS performs a sync/write during this read. That write triggers `order.updated`. The webhook is queued again, repeating the cycle."
Blast radiusThe reporting operator saw unbounded queue growth, "elevated CPU usage caused by self-originating HTTP requests" and stalled analytics imports.
FixThe operator's workaround disables the sync during delivery; the structural question, whether payload reads may write, was still open at fetch time.
Design rulePayload construction must be a pure read, or delivery becomes an event source and the queue feeds itself. See Figure 5.

Figure 5 · The WooCommerce feedback loop: delivery as an event source

Receiver endpointOrder store (HPOScompat)Delivery jobAction SchedulerReceiver endpointOrder store (HPOScompat)Delivery jobAction Schedulerthe write firesorder.updatedqueue grows whileevery deliverysucceedsrun deliver_webhook_asyncread order for payloaddetects drift, writes asyncenqueue another deliveryorder dataPOST payload200 OK
Receiver endpointOrder store (HPOScompat)Delivery jobAction SchedulerReceiver endpointOrder store (HPOScompat)Delivery jobAction Schedulerthe write firesorder.updatedqueue grows whileevery deliverysucceedsrun deliver_webhook_asyncread order for payloaddetects drift, writes asyncenqueue another deliveryorder dataPOST payload200 OK
Every delivery succeeds and the backlog still grows, which is why queue-depth alerts caught it and delivery-failure alerts never would have. Reconstructed from the call stack in woocommerce#62492.
Diagram source

What is missing from the record matters too. No public account in this corpus describes the classic theorised catastrophe, a recovering endpoint flattened by its own retry backlog arriving at once. The spec and every implementation guard against it with backoff and jitter, so either the guard works or the failure goes unreported; both readings justify keeping the guard. Equally, no sender in this corpus publishes an account of losing events it had accepted, and GitLab's incident review is explicit that delay, not loss, was the impact. The delivery pipelines hold; it is the edges (the substrate below, the receiver beyond, the bookkeeping within) that fail.

05

Numbers you can plan against

Every figure with its context, date and source. Measured, shipped-default and derived are marked apart.

MetricValueAtContextKindAs ofSource
Receiver response budget10 sGitHub2xx required within it, or the delivery counts as failedDocumented contract2026-09GitHub docs
Automatic retries after failure0GitHub, GitLabReceiver recovers via redelivery API (GitHub) or manual resend (GitLab)Documented contract2026-09GitHub staff, GitLab #355721
Backoff on failing endpoint1 min → 24 h, ×2GitLabStarts on 4th consecutive failure; counter resets on any 2xxShipped constant2026-09auto_disabling.rb
Permanent disable threshold40 consecutive failuresGitLabCode comment: at least 1 month of failing at max backoff firstShipped constant2026-09auto_disabling.rb
Default retry schedule5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 10 hSvix OSSretry_schedule in the shipped config; ~27.6 h cumulative (derived sum)Shipped default2026-09config.default.toml
Endpoint disable window120 hSvix OSSEndpoint disabled after failing continuously this long; any success resetsShipped default2026-09config.default.toml
Dispatch concurrency cap500 tasksSvix OSSworker_max_tasks default per worker processShipped default2026-09config.default.toml
Delay under substrate incidentup to 15 minGitLab.com2025-10-29, Sidekiq slowdown; no deliveries lostMeasured2025-10Incident 20791
Delay under upstream incident40 min max, 10 min avgGitHub2026-02-03, push webhooks, eventing-service CPU saturationMeasured2026-02Incident thread
Tenant burst that hurt~23,000 jobsGitLab.comOne project, minutes, 2022-02-07; drove the webhook rate-limit workMeasured2022-02Incident 6297
Read these carefully

The shipped defaults are what operators get, not what any operator measured as optimal; Svix's schedule and GitLab's thresholds encode judgement, and both projects have changed them (GitLab's backoff redesign removed a backoff_count column as recently as the 18.x series). The 27.6-hour figure is this guide's arithmetic on Svix's schedule, not a published claim. Unknown, because nobody publishes it: sender-side cost per delivery, fleet sizes, and p99 delivery latency in steady state. If those numbers matter to your design, you will have to measure a pilot; no public account supplies them.

06

The evidence wall

Every source behind this page, graded. All from open trackers and repositories, fetched and read 2026-09-07; the ledger with per-claim quotes ships beside this page as sources.md.

Postmortem GitLab infrastructure2025-10

2025-10-29: Delayed Webhooks

Severity-3 incident review: Sidekiq worker slowdowns delayed webhooks up to 15 minutes for two and a half hours. Notable for what did not happen: no deliveries were lost.

Carry forwardDelivery lag is its own SLI; queue health is not a proxy for it.
gitlab.com/gitlab-com/gl-infra/production/-/issues/20791
Postmortem GitLab infrastructure2022-02

2022-02-07: High number of queued Sidekiq jobs

One project generated ~23K jobs in minutes and delayed CI platform-wide for an hour. The corrective action list is where GitLab's webhook rate limiting was born.

Carry forwardPer-tenant rate limits on hook execution are capacity protection.
gitlab.com/gitlab-com/gl-infra/production/-/issues/6297
Postmortem GitLab infrastructure2022-03

2022-03-14: GitLab.com issues with async jobs

An abusive project's async jobs took page loads and API requests down for 15 minutes; mitigation was manually deleting the offender's jobs from the shared queue.

Carry forwardManual queue surgery is the tool you get when isolation was not designed in.
gitlab.com/gitlab-com/gl-infra/production/-/issues/6586
Postmortem GitHub2026-02

Incident thread: webhook and Actions delays, 2026-02-03

Staff updates in the official community thread: eventing-service connection churn and CPU saturation delayed push webhooks up to 40 minutes and Actions starts with them.

Carry forwardAlert on end-to-end event age; per-stage green dashboards summed to a 40-minute delay.
github.com/orgs/community/discussions/186279
Decision record GitLab2022

Epic 8083: auto-disable failing webhooks

The original containment design: 5xx backoff to 24 h, 4xx disabled immediately, and the stated motive, protecting the platform "from the potential abuse or misuse of a small few".

Carry forwardEndpoint circuit breaking is platform self-defence first, customer service second.
gitlab.com/groups/gitlab-org/-/epics/8083
Decision record GitLab2023–2025

Issue #396577: let autodisabled webhooks self-heal

The public reversal of the 4xx-is-permanent rule after it broke integrations that 404 during deploys; shipped in 17.11 with uniform backoff and permanence only at 40 straight failures. A feedback issue (#503733) still tunes it.

Carry forwardUse status codes for diagnostics, not lifecycle: transient 4xx is normal operations.
gitlab.com/gitlab-org/gitlab/-/issues/396577
Decision record GitLab2022, open

Issue #355721: specify web-hook retry policies

Documents drop-on-failure as current behaviour and proposes drop / retry-N / enqueue tiers, priced as a paid feature because of storage cost. Contains the corpus's most honest sentence about event criticality.

Carry forwardIf the sender cannot rank events, every event gets the cheapest guarantee.
gitlab.com/gitlab-org/gitlab/-/issues/355721
Decision record Standard Webhooks (Svix, Zapier, Twilio, Kong, others)2023–

Standard Webhooks specification v1.0.0

The cross-vendor consensus document: multi-day exponential retry, endpoint disablement with out-of-band notification, webhook-id as idempotency key, HMAC with timestamp tolerance. Adopters listed include OpenAI, Anthropic and PagerDuty.

Carry forwardWhen implementing a sender, this spec is the free design review.
standard-webhooks/spec/standard-webhooks.md
Decision record GitLab2024–

Issue #503733: feedback on webhook self-healing

The post-ship feedback channel for the 17.11 redesign, kept open by the product team; evidence that endpoint-health thresholds are tuned continuously, not set once.

Carry forwardShip breaker thresholds as config with a feedback loop, not as constants you defend.
gitlab.com/gitlab-org/gitlab/-/issues/503733
Source GitLab2026-09 (master)

web_hooks/auto_disabling.rb

The shipped breaker: thresholds 3 and 39, backoff 1 minute doubling to 1 day, project hooks only, behind an ops feature flag, with the month-to-permanent budget explained in a comment.

Carry forwardA production breaker fits in one small file; the difficulty was the thresholds, and those took three years of iteration.
gitlab-org/gitlab/.../web_hooks/auto_disabling.rb
Source GitLab2022-02

Issue #352245: rate limit webhook execution and backoff

The lock-contention finding: simultaneous job completions firing one hook stall the database on that hook's status row, degrading throughput for unrelated jobs.

Carry forwardDelivery bookkeeping is shared mutable state; bound writes to it under fan-in.
gitlab.com/gitlab-org/gitlab/-/issues/352245
Source GitLab2022-09

Issue #372826: resend failed webhook requests via API

A customer with "many failures (hundred or more)" asks for programmatic resend; the shape of recovery work when the sender does not retry.

Carry forwardNo sender retries means every large receiver builds a replay pipeline; give them an API, not a button.
gitlab.com/gitlab-org/gitlab/-/issues/372826
Source Svix2026-09 (main)

svix-webhooks: README and config.default.toml

A production sender's whole posture in one config file: the retry schedule, the 120-hour endpoint disable window, worker concurrency, SSRF blocking on by default, Redis queue durability guidance.

Carry forwardRead shipped defaults as design documents; they are the decisions with the arguing removed.
svix/svix-webhooks/.../config.default.toml
Source Frain / Convoy2026-09 (main)

Convoy: webhooks gateway

Independent Go implementation converging on the same controls: constant or exponential-with-jitter retries, per-endpoint rate limiting, circuit breaking with probing, endpoint disable with notification, Redis queue.

Carry forwardThree independent implementations, one control set: treat it as the de facto standard.
github.com/frain-dev/convoy
Source Frain / Convoy2026-08

PR #2827: SSRF via unchecked GET, closed unmerged

A community fix for server-side request forgery in endpoint probing that died on CLA and CI friction; the vulnerability class is alive in 2026 even in dedicated webhook infrastructure.

Carry forwardEvery URL-probing feature is an SSRF surface; test the probe path, not only delivery.
github.com/frain-dev/convoy/pull/2827
Source Hookdeck2026-09 (main)

Outpost: outbound event infrastructure

At-least-once delivery, automatic and manual retries, and a destination list that extends past HTTP to EventBridge, SQS, S3, Pub/Sub, RabbitMQ and Kafka.

Carry forwardAbove a volume threshold, deliver to the receiver's bus and stop fighting their HTTP tier.
hookdeck/outpost/README.md
Source WooCommerce2024

Discussion #44199: shutdown-hook delivery loss

Maintainer-adjacent account of webhooks queued at request shutdown never being queued at all when another plugin exits first; silent loss in the world's most-deployed commerce platform.

Carry forwardEnqueue on commit, inside the transaction boundary, never on teardown.
github.com/woocommerce/woocommerce/discussions/44199
Source WooCommerce2026

Issue #62492: sync-on-read webhook feedback loop

An operator's incident report in issue form: payload reads trigger sync writes that fire the same event again, growing the queue and CPU while every individual delivery succeeds.

Carry forwardPayload construction must be side-effect free, or delivery becomes an event source.
github.com/woocommerce/woocommerce/issues/62492
Source GitHub community2022

Discussion #24721: handling GitHub webhook retry

The staff answer that anchors this guide's surprise: no retries, use polling; later comments point to the Deliveries API as the receiver-driven recovery path.

Carry forwardRead your sender's retry contract before designing your receiver; do not assume the spec's behaviour.
github.com/orgs/community/discussions/24721
Source GitHub community2026

Discussion #185003: why webhooks still fail us in 2026

A consumer-side systematisation of what is still broken: late or missing events, empty payloads, opaque errors, and WAFs silently eating deliveries. Zero replies from providers at fetch time.

Carry forwardThe receiver's four asks: guarantees, observability, diagnostics, tooling. Budget for all four.
github.com/orgs/community/discussions/185003
Vendor docs GitLab2026-09 (master)

Webhooks documentation (docs source)

The receiver contract: respond fast with 2xx, process async, prepare for duplicates, return 4xx only for true misconfiguration; plus the documented disable thresholds and the Idempotency-Key header held constant across retries.

Carry forwardThe sender publishes the receiver's half of the contract; make your receivers read it.
gitlab-org/gitlab/doc/.../webhooks.md
Vendor docs GitHub2026-09 (main)

Webhook best practices (docs source)

The 10-second response budget, async processing guidance, redelivery as the receiver's responsibility, and X-GitHub-Delivery as the stable dedup key.

Carry forwardTen seconds is the de facto industry timeout; design receivers to acknowledge in milliseconds.
github/docs/.../best-practices-for-using-webhooks.md
Vendor docs Frain / Convoy2023

Wiki: why developers do not use webhook gateways

The vendor's own adoption post-mortem: cost anxiety, integration inertia and unawareness, not technical disagreement, keep teams on homegrown senders.

Carry forwardThe build-vs-buy fight here is organisational; the technical control set is settled.
frain-dev/convoy/wiki
Decision record Standard Webhooks2026-09

Repository and steering committee

Governance signal: the spec's committee spans competing vendors (Svix, Zapier, Twilio, Mux, ngrok, Supabase, Lob, Kong), and its open issues show live argument about signature schemes and header conventions, not about retry or breaker mechanics.

Carry forwardThe contested frontier is authentication ergonomics; the delivery mechanics are consensus.
github.com/standard-webhooks/standard-webhooks
07

Build a miniature, then productionise it

Six rungs from an evening's dispatcher to a system whose failure modes you have met on purpose. The crossing from toy to real is rung 4.

A dispatcher with a contract

One process: accept an event, POST it to a registered URL with a 10-second timeout, a signature header (HMAC over timestamp plus body, per the Standard Webhooks spec), and an attempt record in SQLite. No retries yet.

Done when: a delivery to a slow endpoint gives up at exactly 10 s and the attempt row says so.  Teaches: the timeout is the sender's only unilateral power.

The retry schedule, from config

Re-enqueue failures on Svix's shipped schedule (5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 10 h) read from a config file, with jitter. Keep the delivery ID constant across attempts.

Done when: an endpoint that dies for an hour receives exactly one copy of each event afterwards, and your log shows which attempt succeeded.  Teaches: at-least-once is a schedule plus a stable ID, nothing more mystical.

The endpoint breaker

Implement Figure 3: consecutive-failure counter, disable at 4, backoff 1 minute doubling to a day, permanent at 40, counter reset on success, manual re-enable. Watch GitLab's constants stop making sense for your event rate and change them.

Done when: a dead endpoint costs the fleet one probe per backoff window instead of one timeout per event.  Teaches: thresholds are functions of event frequency; there is no universal constant.

Hostile endpoints, on purpose

Build the receiver zoo: one endpoint that tarpits at 9.5 s, one that returns 200 instantly then 500 for an hour, one that 404s during a simulated deploy window, one that is your own metadata service IP. Run a steady event stream against all four.

Done when: the tarpit does not delay the healthy endpoint's deliveries (this forces per-endpoint concurrency), and the metadata-service URL is rejected at registration.  Teaches: head-of-line blocking and SSRF are the two failures you must design out, not patch out.

The fan-in burst

Fire 20,000 events at one endpoint in a minute (GitLab's 23K-job incident, scaled to your laptop) while the health bookkeeping writes to a single row. Measure throughput, then batch or sample the bookkeeping and measure again.

Done when: you can show the contention on a graph and its removal on another.  Teaches: GitLab issue #352245, personally.

The customer surface

Add the two endpoints every real system grew: list recent deliveries with status and latency, and bulk-replay failures since a timestamp. Then delete a day of deliveries and recover a consumer using only these APIs.

Done when: recovery from your own outage needs no database access.  Teaches: the attempt log is the product; delivery is just its write path.

08

Keep hunting

The queries that produced this guide, plus the ones to run on the layer this session could not reach. The tracker queries are the transferable technique: every large open-source platform's webhook argument is public.

Incident reviews in open trackers

  • site:gitlab.com gl-infra production "incident review" webhook sidekiq
  • site:gitlab.com "Delayed Webhooks" production incident
  • site:github.com orgs/community/discussions webhook delayed incident
  • github availability report webhook delivery delays

Design arguments and reversals

  • gitlab auto-disable failing webhooks issue design
  • site:gitlab.com issues "retry" webhooks "problem to solve"
  • repo:frain-dev/convoy is:pr is:closed is:unmerged
  • standard-webhooks spec "operational considerations"

Shipped defaults as design documents

  • svix-webhooks config.default.toml retry_schedule
  • path:app/models/concerns/web_hooks repo:gitlab-org/gitlab
  • woocommerce webhook shutdown "action scheduler" delivery issue

The layer this guide could not reach

  • segment centrifuge "billions of events" delivery
  • brandur webhooks stripe operational
  • stripe webhook retry "72 hours" documentation
  • shopify webhooks eventbridge "at scale" engineering
09

References

  1. GitLab, Epic 8083: Improve the reliability of webhooks: auto-disable failing webhooks GitLab.org epics, targeted 15.6 (2022). Checked 2026-09-07.
  2. GitLab, Issue #396577: Allow all autodisabled webhooks to self-heal, but permanently disable after 40 concurrent failures GitLab.org tracker, 2023, shipped 17.11. Checked 2026-09-07.
  3. GitLab, Issue #355721: Specify web-hook retry policies GitLab.org tracker, 2022, open. Checked 2026-09-07.
  4. GitLab, Issue #352245: Rate limit webhook execution and backoff GitLab.org tracker, 2022. Checked 2026-09-07.
  5. GitLab, Issue #372826: Allow resending failed webhook requests with the API GitLab.org tracker, 2022. Checked 2026-09-07.
  6. GitLab, Issue #503733: Feedback on the webhook self-heal feature GitLab.org tracker, 2024. Checked 2026-09-07.
  7. GitLab, app/models/concerns/web_hooks/auto_disabling.rb gitlab-org/gitlab, master branch. Checked 2026-09-07.
  8. GitLab, Webhooks documentation source gitlab-org/gitlab, master branch. Checked 2026-09-07.
  9. GitLab Infrastructure, Incident 20791: 2025-10-29 Delayed Webhooks gl-infra production tracker, 2025-10-29. Checked 2026-09-07.
  10. GitLab Infrastructure, Incident 6297: 2022-02-07 High number of queued Sidekiq jobs gl-infra production tracker, 2022-02-07. Checked 2026-09-07.
  11. GitLab Infrastructure, Incident 6586: 2022-03-14 GitLab.com issues with async jobs gl-infra production tracker, 2022-03-14. Checked 2026-09-07.
  12. GitHub, Incident thread: 2026-02-03 webhook and Actions delays GitHub community discussions, 2026-02. Checked 2026-09-07.
  13. GitHub, Community discussion #24721: Handling GitHub webhook retry GitHub community discussions, 2022, with staff answer. Checked 2026-09-07.
  14. Carsten17, Why webhooks still fail us in 2026 and how we can do better GitHub community discussions, 2026. Checked 2026-09-07.
  15. GitHub, Best practices for using webhooks (docs source) github/docs, main branch. Checked 2026-09-07.
  16. Standard Webhooks, Specification v1.0.0 standard-webhooks repository, main branch. Checked 2026-09-07.
  17. Standard Webhooks, repository and steering committee GitHub. Checked 2026-09-07.
  18. Svix, svix-webhooks README svix/svix-webhooks, main branch. Checked 2026-09-07.
  19. Svix, svix-server config.default.toml svix/svix-webhooks, main branch. Checked 2026-09-07.
  20. Frain, Convoy: the cloud-native webhooks gateway GitHub repository and README. Checked 2026-09-07.
  21. Frain, Convoy PR #2827: Fix SSRF through unchecked GET (closed unmerged) GitHub, 2026-08. Checked 2026-09-07.
  22. Frain, Why developers do not use webhook gateways today Convoy wiki, 2023. Checked 2026-09-07.
  23. Hookdeck, Outpost README hookdeck/outpost, main branch. Checked 2026-09-07.
  24. WooCommerce, Discussion #44199: Gathering feedback about the Web API (webhook shutdown queueing) GitHub discussions, 2024. Checked 2026-09-07.
  25. WooCommerce, Issue #62492: HPOS sync-on-read causing repeated order.updated webhooks during delivery GitHub tracker, 2026. Checked 2026-09-07.