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.
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.
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
- 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. - 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}/completep95 rose from 120 ms to 4.9 s for every workspace sharing the worker pool. - No visibility. Customers cannot see delivery attempts or replay an event; Support reads raw logs on their behalf.
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
- G1: No event is lost while the customer endpoint is unavailable for up to 24 hours.
- G2: Webhook delivery adds no more than 2 ms p99 to the producing request.
- G3: At-least-once delivery with a stable idempotency key per event so customers can deduplicate.
- G4: Per-event delivery history and a replay action visible in the API and the console.
- G5: One misbehaving endpoint cannot degrade delivery to other endpoints in the same or other workspaces.
- G6: Sustain 25 M deliveries per day on the current worker tier with headroom of 2x.
Non-goals
- Ordering guarantees across events. Events carry
occurred_atand a monotonicsequenceper workspace; consumers order. - Exactly-once delivery. Not achievable over HTTP; idempotency keys are the contract.
- A customer-facing retry policy editor. Fixed schedule in this iteration.
- Replacing the event producers. The outbox write happens where
WebhookSender.send()is called today.
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 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 existingeventstable; the delivery row references them. Partial index on(next_attempt_at) WHERE status = 'pending'. - 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. - 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 viaGET /v2/webhooks/endpoints/{id}. - Idempotency: header
Idempotency-Key: <event_id>andX-Larkspur-Attempt: <n>on every POST. The signing string includes the event ID so replays verify identically. - Replay:
POST /v2/webhooks/deliveries/{id}/replayinserts a new delivery row with the same event; console button calls it.
Retry schedule
| Attempt | Delay after failure | Cumulative | Notes |
|---|---|---|---|
| 1 | 0 | 0 | Within 2 s of commit under normal load |
| 2 | 30 s | 30 s | Jitter ±20% on every delay |
| 3 | 2 min | 2.5 min | |
| 4 | 10 min | 12.5 min | |
| 5 | 30 min | 42.5 min | Endpoint owner notified by email after this attempt |
| 6 | 2 h | 2 h 42 min | |
| 7 | 6 h | 8 h 42 min | |
| 8 | 15 h | 23 h 42 min | Final; row moves to dead, retained 30 days for replay |
Failure handling
- Worker crash mid-batch: claimed rows are locked in an open transaction; the lock releases on crash and another worker claims them. Duplicate sends are possible and covered by the idempotency key.
- Database unavailable: producers fail their own write (unchanged behaviour); no delivery row is created without the domain change.
- Backlog growth: alert when pending rows older than 5 minutes exceed 10,000 or when the oldest pending row is over 15 minutes old. Workers scale horizontally; the claim query is bounded by the partial index.
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.
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.
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.
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.
Risks and mitigations
| Risk | Likelihood | Impact | Mitigation | Owner |
|---|---|---|---|---|
| Outbox table growth degrades the claim query | Medium | Medium | Partial 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 3 | Tomasz W. |
| Customers treat duplicate deliveries as new events | Medium | High | Idempotency key documented and shown in the console; changelog notice 30 days before phase 3; duplicates already occur today on client-side retries | Marcus B. |
| Burst after a breaker closes overwhelms the customer | Low | Medium | Per-endpoint concurrency of 4; half-open state releases 10 deliveries before fully closing | Tomasz W. |
| Dispatcher lag makes "real-time" integrations feel slow | Low | Medium | Poll interval 500 ms plus LISTEN/NOTIFY wake-up; target first attempt within 2 s at p99 | Priya 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.
- Phase 0 (week of 2026-09-22): schema and shadow writesMigration adds
webhook_deliveriesandendpoint_state. Producers write outbox rows for all workspaces; the dispatcher runs in dry-run mode and marks rowsdeliveredwithout sending. Gate: outbox insert adds under 2 ms p99 to producers; dry-run backlog age stays under 5 s for 72 hours. - 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.
- 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.
- 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
- No new data classes. Delivery rows store IDs, hashes, timestamps, and the response status; payloads remain in
eventsunder existing retention (90 days). last_errorstores the HTTP status and the first 256 bytes of the response body with credentials redacted by the existing log scrubber.- Signing is unchanged (HMAC-SHA256 over timestamp, event ID, and body). Replays re-sign with the current timestamp so customers' 5-minute replay window still applies.
- The replay endpoint requires the
webhooks:writescope and is rate-limited to 100 per workspace per hour.
Open questions
- Q1. Should
technician.locationevents skip retries entirely?Dana Okafor · by 2026-09-16Location updates are superseded every 30 s; retrying stale ones wastes attempts. Proposal: per-event-type policy withmax_attempts = 1for location. - Q2. Retain dead deliveries for 30 days or align with the 90-day event retention?Marcus Bell · by 2026-09-1630 days covers every replay request Support has seen; 90 days triples table size at target volume.
- Q3. Do we email endpoint owners after attempt 5, or only when a breaker opens?Ines Fontaine · by 2026-09-19Attempt-5 emails may be noisy for endpoints with brief maintenance windows.
- Q4. Regional workers or a global pool?Priya Natarajan · by 2026-09-19Regional keeps data residency simple and matches the outbox's database; global evens out load. Proposal assumes regional.
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.