Larkspur Systems · Platform Engineering
RFC

RFC-014: Queue-backed webhook delivery with retries

Move webhook sends out of the request path into a durable queue with exponential retries, per-endpoint circuit breaking, and an idempotency key on every delivery.

In review Author: Tomasz Wierzbicki Reviewers: Priya Natarajan, Ines Fontaine, Marcus Bell (Support) Decision by: 2026-09-19
Created
2026-09-01
Updated
2026-09-10 (v3: added Q4 and the decision log)
Supersedes
None
Related
INC-2026-0716 (webhook storm), support theme "missing job.completed events", PRD-021 integrations roadmap

Summary

Dispatch sends customer webhooks synchronously inside the request that produced the event, with a single attempt and a 5 s timeout. In August 2026, 2.3% of deliveries failed and were never retried, and one slow customer endpoint added up to 5 s to unrelated API calls. This RFC proposes a webhook_deliveries table as a durable outbox, a dispatcher worker pool that drains it with exponential retries over 24 hours, a per-endpoint circuit breaker, and an Idempotency-Key header so customers can deduplicate. The change is behind a per-workspace flag and rolls out over four weeks. Reviewers are asked to approve the design and answer the four open questions by 2026-09-19.

Context

Customers register up to ten HTTPS endpoints per workspace and subscribe to events (job.created, job.assigned, job.completed, technician.location and 14 others). When an API request or a background job produces an event, WebhookSender.send() is called inline: it serialises the payload, signs it with the workspace secret, and issues one HTTP POST with a 5 s timeout. The result is logged and discarded.

4.1 M
Deliveries per day (Aug 2026)
1,860
Registered endpoints
2.3%
Failed, never retried
p99 1.9 s
Send latency inside requests

Failures cluster: 61% of failed deliveries in August went to 23 endpoints, mostly during customer maintenance windows or deploys. The remaining 39% are spread across timeouts, TLS errors, and 5xx responses from otherwise healthy endpoints.

Problem

  1. Lost events. A failed delivery is gone. Customers reconcile by polling GET /v2/jobs?updated_since=, which is 18% of read traffic and defeats the purpose of webhooks. Support tagged 112 tickets "missing webhook" in the last quarter.
  2. Coupled latency. The sending request waits for the customer's endpoint. During INC-2026-0716 one endpoint answered in 4.8 s and POST /v2/jobs/{id}/complete p95 rose from 120 ms to 4.9 s for every workspace sharing the worker pool.
  3. No visibility. Customers cannot see delivery attempts or replay an event; Support reads raw logs on their behalf.
Why now

PRD-021 commits to technician.location events at up to one per technician per 30 s, which would raise volume to roughly 25 M deliveries per day in Q1 2027. The synchronous design does not survive that without the coupling in item 2 becoming routine.

Goals and non-goals

Goals

Non-goals

Proposal

Components

 producer (API / job)                         dispatcher workers (N=8 per region)
 ┌──────────────────────┐   same txn   ┌────────────────────┐   claim batch   ┌───────────────┐
 │ domain write         │────────────▶ │ webhook_deliveries │◀────────────────│ worker        │
 │ + INSERT delivery    │              │ (outbox, Postgres) │  FOR UPDATE     │  sign + POST  │──▶ customer
 └──────────────────────┘              └────────────────────┘  SKIP LOCKED    │  record result│    endpoint
                                                 ▲                            └───────┬───────┘
                                                 │ next_attempt_at = now + backoff     │
                                                 └─────────────────────────────────────┘
                                       endpoint_state (circuit breaker, per endpoint)
Outbox pattern: the delivery row is written in the same transaction as the domain change, so an event exists if and only if the change committed.
  1. Outbox table webhook_deliveries: id, workspace_id, endpoint_id, event_id, payload_hash, attempt, status (pending, delivered, failed, dead), next_attempt_at, last_error, created_at. Payloads live in the existing events table; the delivery row references them. Partial index on (next_attempt_at) WHERE status = 'pending'.
  2. Dispatcher: a worker pool per region claims up to 200 pending rows per poll with SELECT ... FOR UPDATE SKIP LOCKED, groups by endpoint, and sends with a per-endpoint concurrency of 4 and a 10 s timeout. Results update the row; failures schedule the next attempt.
  3. Circuit breaker endpoint_state: after 20 consecutive failures an endpoint opens for 5 minutes; deliveries to it stay pending without consuming attempts. A single probe closes it. Open breakers surface in the console and via GET /v2/webhooks/endpoints/{id}.
  4. Idempotency: header Idempotency-Key: <event_id> and X-Larkspur-Attempt: <n> on every POST. The signing string includes the event ID so replays verify identically.
  5. Replay: POST /v2/webhooks/deliveries/{id}/replay inserts a new delivery row with the same event; console button calls it.

Retry schedule

AttemptDelay after failureCumulativeNotes
100Within 2 s of commit under normal load
230 s30 sJitter ±20% on every delay
32 min2.5 min
410 min12.5 min
530 min42.5 minEndpoint owner notified by email after this attempt
62 h2 h 42 min
76 h8 h 42 min
815 h23 h 42 minFinal; row moves to dead, retained 30 days for replay
A 4xx other than 408 and 429 stops retries immediately; the row is marked failed with the response code.

Failure handling

Observability

Metrics: webhook_delivery_attempts_total{result}, webhook_delivery_latency_seconds, webhook_backlog_age_seconds, webhook_breaker_open{endpoint}. Every attempt logs event_id, endpoint_id, attempt, status, and duration. The console delivery view reads webhook_deliveries directly.

Alternatives considered

A. In-process retries with a background thread

Keep the synchronous send but hand failures to an in-memory retry queue in the API process.

  • Smallest change; no schema.
  • Retries lost on deploy or crash (deploys happen 20+ times a week).
  • Does not fix latency coupling for the first attempt.
Rejected: fails G1 and G2.

B. Managed webhook service

Publish events to a third-party delivery service that handles retries, breakers, and a customer portal.

  • Fastest path to a customer-facing delivery log.
  • Payloads containing customer addresses and technician locations leave our boundary; DPA review estimated at 8 weeks.
  • Cost at 25 M/day exceeds two engineer-months per year.
Rejected: privacy review time and cost.

C. Postgres outbox plus dispatcher (proposed)

Durable rows in the existing database, drained by workers with SKIP LOCKED.

  • Transactional with the domain write; no new infrastructure.
  • Proven pattern in our billing exports (2 M rows/day).
  • Needs a separate broker if volume passes roughly 100 M/day; not expected before 2028.
Chosen: meets all goals with the smallest operational surface.

D. Dedicated message broker

Publish to a managed queue and consume with the same dispatcher.

  • Higher ceiling than C.
  • Dual-write problem between the database and the broker unless C's outbox is added anyway.
  • New system to operate, alert on, and cost.
Deferred: revisit if backlog age alerts fire routinely after C.

Risks and mitigations

RiskLikelihoodImpactMitigationOwner
Outbox table growth degrades the claim queryMediumMediumPartial index on pending rows; nightly purge of delivered rows older than 7 days and dead rows older than 30 days; load test at 3x target volume before phase 3Tomasz W.
Customers treat duplicate deliveries as new eventsMediumHighIdempotency key documented and shown in the console; changelog notice 30 days before phase 3; duplicates already occur today on client-side retriesMarcus B.
Burst after a breaker closes overwhelms the customerLowMediumPer-endpoint concurrency of 4; half-open state releases 10 deliveries before fully closingTomasz W.
Dispatcher lag makes "real-time" integrations feel slowLowMediumPoll interval 500 ms plus LISTEN/NOTIFY wake-up; target first attempt within 2 s at p99Priya N.

Rollout plan

Everything is behind workspace.flags.webhook_outbox. When the flag is off, WebhookSender.send() behaves exactly as today. Rollback at any phase is flipping the flag; pending rows are drained by the dispatcher regardless, so no events are lost on rollback.

  1. Phase 0 (week of 2026-09-22): schema and shadow writesMigration adds webhook_deliveries and endpoint_state. Producers write outbox rows for all workspaces; the dispatcher runs in dry-run mode and marks rows delivered without sending. Gate: outbox insert adds under 2 ms p99 to producers; dry-run backlog age stays under 5 s for 72 hours.
  2. Phase 1 (2026-09-29): internal workspacesFlag on for Larkspur's own three workspaces and the integration test suite. Gate: zero lost events versus the synchronous path over one week, verified by comparing event IDs received by the test receiver.
  3. Phase 2 (2026-10-06): opt-in beta, 20 customersSupport invites the 23 endpoints with the highest failure rates. Console shows delivery history. Gate: failed-and-never-retried rate below 0.05% for beta workspaces; no P1 support tickets attributable to duplicates.
  4. Phase 3 (2026-10-20): general availabilityFlag on for all workspaces in 25% daily increments. Synchronous path remains for two release cycles, then is removed. Gate: backlog age p99 under 15 s at 100%; alert volume unchanged for one week.

Security and privacy

Open questions

Decision log

2026-09-04
Retry ceiling is 24 hours, not 72. Decided by Ines Fontaine with Support. Beyond 24 hours customers reconcile from the API anyway; a longer window mostly grows the table.
2026-09-08
Idempotency key is the event ID, not a per-delivery UUID. Decided in review with Tomasz Wierzbicki and Priya Natarajan. Customers deduplicate on the thing they care about (the event), and replays reuse it by design.
2026-09-10
Alternative D deferred rather than rejected. Revisit criterion recorded in the alternatives section.