01Capture and delivery are separate planes
Intake persists and acknowledges without consulting subscription state. Nothing downstream can make a product transaction slower or less available.
ADR-0120 views, in reading order. Every view ships three ways: an HTML page, an SVG that re-opens in diagrams.net fully editable, and draw.io source.
The mechanism behind every `payment_intent.succeeded` and `pull_request.opened` a SaaS product ever sent: how a domain event becomes a signed HTTPS request against 40,000 endpoints nobody here controls, what happens when one of those endpoints is slow, broken or hostile, and how a developer finds out why theirs is not receiving anything. Read the set in order — the argument runs from the boundary, through the two people it exists for, into the structure that keeps one broken consumer away from everyone else.
Capture and delivery are separate planes joined by a durable log, and the unit of isolation on the delivery side is the endpoint — not the tenant, not the event type, and not a shared worker pool.
A SaaS product with an active ecosystem has to call 40,000 servers it does not own, cannot page, and must not trust. Some of them are behind a laptop on a hotel connection. Some return 200 OK and then throw the event away. One of them, at any given moment, is taking fifteen seconds to respond and never erroring — and that one is the whole problem, because in the obvious architecture it is quietly consuming the workers that every other customer's deliveries are waiting for. Meanwhile the product's own checkout has to acknowledge a payment in milliseconds, and it cannot be waiting on any of this.
Intake accepts an event, persists it, and acknowledges — in that order, knowing nothing about who is subscribed, so intake latency is independent of subscription count. Fan-out happens afterwards on the platform's own time, turning one event into N independent units of work, each carrying an idempotency key that is stable across every retry and every replay. Each unit lands in its endpoint's own queue, or its own addressable group within one, so that a worker leasing work for one endpoint can never be blocked by another. Delivery workers run in egress-only subnets with no route back into the estate: they re-resolve the address immediately before connecting, sign the timestamp and the raw body under a KMS-wrapped per-endpoint secret, POST through a NAT range the platform publishes as a public interface, and classify the outcome into one of four named classes. Retryable failures back off on a published schedule for 72 hours; permanent ones are never retried and never silent. What is left over is dead-lettered, the owner is told at the first one, and the backlog waits for a replay the customer asks for.
Intake persists and acknowledges without consulting subscription state. Nothing downstream can make a product transaction slower or less available.
ADR-01Not the tenant. Two endpoints belonging to the same customer have independent queues, circuits, backlogs and health.
ADR-02A stable idempotency key per delivery, constant across retries and replays, and a consumer contract that puts deduplication where it can actually be done.
ADR-04Strict ordering turns one stuck delivery into a total outage for that endpoint. It is offered, with the consequence stated, rather than given by default.
ADR-05Canonical string, two active secrets, seven-day rotation overlap, and worked verification examples. A signature nobody can verify is decoration.
ADR-06Twelve attempts over 72 hours with full jitter. Customer-configurable retries make capacity unplannable and let a tenant build a self-inflicted outage.
ADR-08Notification at the first dead letter, not the hundredth, through a channel that does not depend on the endpoint working.
ADR-09Egress-only subnets with no route inward. The address check is a control; the topology is the control that holds when the check has a bug.
ADR-12What we were asked to send, and what we actually did. Neither is derivable from the other, and the queues are rebuilt from the second.
ADR-15A webhook platform outlives the product it was built for, because every customer integration depends on the shape of the request and the semantics of the retry. These are the properties that should survive a change of scale, of cloud, and of the people who built it.
ADR-01 says the product's write path ends at durability. That survives replacing SQS, Fargate, DynamoDB, AWS and the language everything is written in. What would break it is a future team adding a 'synchronous webhook' option for a customer who asked nicely — which is why it is drawn as a boundary in view 11 rather than left as a convention.
Every other guarantee on this platform can be tightened or loosened. The key cannot, because consumers have written deduplication against it and a change to its stability silently double-processes payments. It is the one field where the right answer in ten years is exactly the answer today.
ADR-03 will be revisited — SQS queue limits, cost per queue and the traffic distribution will all change, and the hybrid promotion scheme may be replaced entirely. ADR-02's claim, that two endpoints never share a fate, is what must survive that rewrite, and it is stated separately for exactly that reason.
Of everything here, the published NAT range is the hardest thing to change, because it lives in 40,000 firewall rules the platform cannot see. Treating it as a versioned public interface from day one (ADR-11) is the decision a future team will be most grateful for and least likely to have made themselves.
Twelve attempts over 72 hours will be argued about forever. What must not drift is that the schedule is published, uniform and the platform's — because the moment it becomes per-tenant configuration, capacity planning, support answers and the meaning of 'we tried' all stop being sayable in one sentence.
Every number here is a stated assumption from the requirement, chosen to be argued with. The right-hand column names the view where the mechanism that meets it is drawn.
| Quality | Target | How it is met | View |
|---|---|---|---|
| Intake availability | ≥ 99.99% monthly | Stateless intake behind a load balancer across three AZs; acknowledgement depends only on the durable write, never on subscription state. | 11 |
| Intake latency | ≤ 15 ms p99, independent of subscription count | Persist and acknowledge; matching and fan-out happen after the response. | 11 |
| Delivery-plane availability | ≥ 99.9% monthly | Measured as the share of deliveries dispatched inside the latency objective to healthy endpoints — a definition that excludes the consumer's own failures. | 17 |
| Time to first attempt | p50 ≤ 250 ms · p99 ≤ 2 s from acceptance | Fan-out and enqueue are asynchronous but not batched; workers lease continuously rather than on a poll interval. | 11 |
| Throughput | 25,000 events/s steady · 120,000/s peak · 45,000 attempts/s | Workers scale horizontally with no shared coordination point whose capacity depends on endpoint count. | 15 |
| Delivery success | ≥ 99.5% first attempt · ≥ 99.95% within three, to healthy endpoints | Per-attempt timeouts sized against a synchronous consumer handler; full-jittered backoff to avoid synchronised retries. | 12 |
| Isolation | No endpoint's backlog delays any other, including same-tenant | Per-endpoint queue or message group, per-endpoint concurrency cap, per-endpoint circuit. | 07 |
| Durability of accepted events | RPO 0 in-region · ≤ 5 s cross-region | Acknowledged only after the durable write; DynamoDB global tables and S3 cross-region replication. | 15 |
| Recovery | Delivery RTO ≤ 15 min · intake RTO ≤ 5 min | Warm standby with state pre-replicated and a second egress range already published. | 15 |
| Subscription propagation | change ≤ 30 s p99 · unsubscribe ≤ 5 s p99 | Strongly consistent read at fan-out; unsubscribes fail closed rather than serving a stale copy. | 20 |
| Secret rotation | effective ≤ 60 s p99 · 7-day two-secret overlap | Both signatures sent during the overlap; worker key cache TTL bounded below the propagation target. | 13 |
| Retention | payloads 30 d · attempts 90 d · dead letters 30 d · audit 400 d | Enforced by storage lifecycle and TTL attributes rather than by an application deletion job that can stall. | 09 |
| Cost | ≤ $0.40 per million deliveries all-in | Retries accounted separately from first attempts; alarm when retries exceed 25% of total attempts. | 17 |
Three of this architecture's claims are cheap to test and expensive to be wrong about. A prototype that proves these can be built on; one that skips them is a demonstration of sending an HTTP request, which is the easy half.
| Risk | If it lands | Response |
|---|---|---|
| The queue-per-endpoint model does not survive the population | 40,000 SQS queues hits a service limit or a cost line, and the fallback to shared queues with message groups gives ordering isolation without throughput isolation — which is not the promise in ADR-02. | Measure in the prototype. The designed fallback is the hybrid in ADR-03: dedicated queues for the hot tail, shared groups for the long tail, with automatic promotion. |
| Strict ordering is demanded and then regretted | A large customer asks for per-endpoint ordering, gets it, and discovers the first poison payload blocks their stream for 72 hours. The platform is blamed for honouring the request. | Ordering is a declared per-subscription property with its consequence stated at subscription time, and per-resource ordering in Phase 2 confines the blocking to one entity rather than the whole endpoint. |
| Auto-disable fires during a customer's own incident | The platform unilaterally breaks a working configuration at the worst possible moment, and the notification is missed because everyone is busy. | The backlog survives the disable, re-enable offers an explicit choice, and the first-dead-letter notification precedes the disable by up to 72 hours. If notification reliability cannot be demonstrated, the disable threshold is the number that should move. |
| The egress range has to change | 40,000 customer firewall rules are wrong and the platform cannot see any of them. Deliveries fail as connection timeouts, which look identical to the customer being down. | Both regions' ranges are published before launch, the range is over-provisioned, and any change follows a published deprecation window with dual-range operation. |
| Retry traffic dominates capacity | A population of permanently failing endpoints consumes a majority of delivery workers while producing no successful deliveries, and the cost per useful delivery quietly triples. | Retries are accounted separately, alarmed above 25% of attempts, and per-endpoint retry cost is surfaced so a pathological endpoint is identifiable rather than absorbed. |
| Fat payloads become the dominant cost | At 25,000 events/s, a 30-day retention and a p99 payload above the assumed 64 KB, payload storage outgrows everything else on the platform. | Payload stored once per event rather than per delivery; retention is a per-plan lever; the thin-event option in ADR-04's context remains available if the numbers force it. |
Sixteen decisions make up this architecture. Everything else across the twenty views is convention, sizing or consequence. Each record states the forcing question, the context that makes it hard, what was decided, how it is realised on Amazon Web Services, the options weighed, what the choice buys and costs, the conditions that would flip it, why it should still be right in ten years, and the lesson worth carrying to a different system.
Status of this document. This is a design, not a report on a running system. The rates, latencies, volumes, retentions and windows are the requirement's stated assumptions for a mid-size B2B SaaS product with an active developer ecosystem — 8,000 tenants, 40,000 registered endpoints, 25,000 accepted events per second, 45,000 delivery attempts per second — invented to be defensible and arguable rather than absent. They are to be replaced by measured telemetry before build, and three of them in particular are the first numbers real data should overturn: the 12% retry share, which decides how much of the fleet is spent producing nothing; the p99 fan-out of 40, which decides whether the queue mapping in ADR-03 survives; and the 30-day payload retention, which silently defines the replay window every customer will eventually discover the hard way.
Where the product's involvement ends, and what may never share a fate with what.
ADR-01Capture and delivery are separate planes joined by a durable log ADR-02The endpoint, not the tenant, is the unit of isolation ADR-03Endpoints map onto physical queues by a declared scheme, starting with message groupsWhat the platform promises a consumer, in words a consumer can build against.
ADR-04At-least-once, published, with an idempotency key stable across retries and replays ADR-05Ordering is declared per subscription, and defaults to noneHow a consumer knows a request came from here, and how a secret changes without a cut-over.
ADR-06HMAC over a published canonical string, with asymmetric signatures deferred ADR-07Two active secrets with a seven-day overlap, rotated by the customer at their own paceWhat happens when the other end is slow, broken, hostile, or simply gone.
ADR-08The retry schedule belongs to the platform, and resumption is ramped ADR-09Give up loudly: notification at the first dead letter, on a channel independent of the endpoint ADR-10Replay is a new delivery with the original key, rate-capped and confirmedPointing a worker fleet at URLs strangers chose, safely and attributably.
ADR-11The egress address range is a public interface with a change process ADR-12Network topology, not validation code, is the containment for request forgeryWhat is recorded, who may change what, and how a developer diagnoses their own integration.
ADR-13Failure is self-diagnosable, and platform time is measured separately from consumer time ADR-14Auto-disable after 72 hours, with the backlog retained and re-enable an explicit choice ADR-15Two systems of record: what we were asked to send, and what we actually did ADR-16Subscription reads are strongly consistent at fan-out, and a URL change is an elevated actionEvery capability and what it is realised with, the origin of that choice, the alternative that was weighed, and the record that argues it. The requirement stays vendor-neutral throughout; this table is where the architecture commits.
| Capability | Choice | Origin | Credible alternative | Why this one | Record |
|---|---|---|---|---|---|
| Event intake | Fargate behind an Application Load Balancer, three AZs | AWS | API Gateway with a Lambda authoriser | Intake is a long-lived, connection-heavy, latency-sensitive tier with a 15 ms p99 budget; a warm container fleet holds that budget more predictably than per-request compute, and the ALB gives the connection reuse the producers need. | ADR-01 |
| Event payload store | S3 with SSE-KMS, 30-day lifecycle expiry | AWS | Payload inline in DynamoDB | Payloads are write-once, read-a-few-times, p99 64 KB and capped at 256 KB. Object storage makes retention a lifecycle rule rather than a deletion job, and keeps the index rows small. | ADR-15 |
| Event index, deliveries and attempts | DynamoDB, partitioned by endpoint, TTL on expiry | AWS | Aurora PostgreSQL | Attempt volume is 45,000 writes per second with a strict per-endpoint access pattern and no cross-partition query in the hot path. TTL removes the deletion job entirely. | ADR-15 |
| Subscription and endpoint store | DynamoDB with strongly consistent reads at fan-out | AWS | A cached projection refreshed asynchronously | An unsubscribe that has been acknowledged must not be followed by a delivery. The read is once per event rather than once per attempt, so consistency is affordable here and not on the attempt path. | ADR-16 |
| Per-endpoint queueing | SQS FIFO, endpoint id as the message group key, redrive to a dead-letter queue | AWS | Kafka with a partition per endpoint; one queue per endpoint | Message groups give per-endpoint ordering isolation and lease fairness without 40,000 physical queues, and redrive is a first-class primitive rather than something to build. Partition-per-endpoint does not scale to this cardinality. | ADR-03 |
| Delivery workers | Fargate in egress-only subnets, autoscaled on backlog age | AWS | Lambda per delivery | A worker holds connections, enforces per-endpoint concurrency and runs a 15-second attempt. Per-invocation compute makes concurrency governance and connection reuse both harder, and the per-attempt cost worse at 45,000/s. | ADR-02 |
| Signing material | KMS data keys, stored wrapped, decrypted with a 5-minute worker cache | AWS | Secrets Manager per endpoint | 40,000 endpoints × 2 secrets is a poor fit for a per-secret-priced service, and the wrapped-key pattern keeps the plaintext out of the datastore while letting the worker sign locally. | ADR-06 |
| Egress | NAT gateways on a dedicated, published /28 per region | AWS | Per-tenant Elastic IPs; egress through a proxy fleet | A narrow, stable, published range is what customers allowlist. Per-tenant addresses multiply the NAT footprint and are deferred to Phase 3 for the security reviews that demand them. | ADR-11 |
| Network containment | Egress-only subnets with no route to capture, control or product VPCs; no metadata endpoint | AWS | Rely on the application-level address guard alone | The worker connects to URLs strangers chose. The guard is a control; routing is the control that still holds when the guard has a bug. | ADR-12 |
| Cross-region state | DynamoDB global tables, S3 cross-region replication, multi-region KMS key | AWS | Application-level dual writes | RPO ≤ 5 s cross-region with no application conflict handling, and a signing key usable in the standby region without re-wrapping every secret. | ADR-01 |
| Notification of delivery failure | The product's existing notification service (email, in-app) | Product | A channel owned by this platform | The alert must not depend on the customer's endpoint working, and it must reach the same people who receive every other account notification. Building a second channel would be a second thing to get wrong. | ADR-09 |
| Audit | Append-only DynamoDB table, 400-day TTL, export to the tenant's SIEM | AWS | CloudTrail data events | The audited actions are application-level — a URL change, a secret rotation, a bulk replay — and are not visible to an infrastructure audit trail. | ADR-16 |
| Observability | OpenTelemetry to the estate's existing platform, with platform and consumer time as separate spans | Open source | CloudWatch metrics only | The one measurement that settles arguments is platform time against consumer time, and that requires span-level attribution rather than aggregate metrics. | ADR-13 |
| Delivery history surface | API Gateway in front of a query service over the attempt log | AWS | Direct console access to the store | The history is a customer-facing product with its own authorisation, redaction and rate limits, not an internal debugging view exposed outward. | ADR-13 |
| Release | Rolling Fargate deployment with a 20-second drain, 5% canary, automatic rollback on success rate | AWS | Blue/green on the whole fleet | A drain longer than the 15-second attempt timeout lets in-flight attempts finish; anything not finished returns on the visibility timeout rather than being lost. | ADR-08 |
Does the product's write path know anything about who is subscribed, or does it hand an event to a durable store and stop?
When one consumer misbehaves, what is the smallest thing that is allowed to be affected?
40,000 endpoints, each needing its own isolated stream — how many physical queues is that, and what breaks first?
What exactly is promised to a consumer about how many times they will see an event, and who is responsible for the difference?
Are events delivered to an endpoint in order, and if so, in order of what?
How does a consumer know a request came from this platform, and what happens to that assurance if the platform's own store is breached?
How does a signing secret change without a coordinated cut-over between the platform and a consumer who may be asleep?
Who decides how often a failing endpoint is retried, and what happens the moment it comes back?
When a delivery finally fails for good, who finds out, and when?
How does a customer get back the events they missed, without that recovery being its own outage?
Where do deliveries appear to come from, and what does it take to change that?
A worker fleet connects to URLs strangers chose. What stops one of those URLs being something inside the estate?
When a developer says 'we're not receiving your webhooks', how do they find out why without opening a ticket?
What happens to an endpoint that has been failing for three days — and who decides?
When a customer says an event never arrived, what does the platform consult, and what can it prove?
How fresh must subscription state be when an event is fanned out, and who is allowed to change where a tenant's data goes?
The terms this package uses in a specific way, and what each one is doing in the architecture.
| Package | What it is | What it does here | Considered instead |
|---|---|---|---|
| Endpoint | One registered URL with its own event-type selection, secrets, health, queue and backlog. | The unit of isolation, accounting and failure — the single most load-bearing definition in the package. | Treating the tenant as the unit, which lets a customer's staging endpoint degrade their own production deliveries. |
| Delivery | One (event, endpoint) pair — the unit of work fan-out produces. | Carries the idempotency key, the state and the retry schedule. One event with a fan-out of 40 produces 40 deliveries. | Treating the event as the unit of work, which makes a single slow endpoint hold the whole event's progress. |
| Attempt | One HTTP request against an endpoint on behalf of a delivery. | The atom of the evidence record: timestamp, duration, status, outcome class. The customer's request inspector is a view over these. | Recording only the final outcome, which loses the history a customer needs to diagnose an intermittent receiver. |
| Idempotency key | A value minted at fan-out, constant across every retry and every replay of that delivery. | The field the at-least-once contract is built on, and what makes replay safe without a new mechanism. | The event id alone, which is shared by every endpoint's copy and so cannot deduplicate a per-endpoint retry. |
| Outcome class | One of exactly four values: success, retryable, permanent, rejected-before-dispatch. | The published taxonomy a developer's first question maps onto, and what decides whether the schedule continues. | Raw status codes, which leave a developer guessing whether their 409 will be retried. |
| Canonical string | The exact byte sequence the signature is computed over: timestamp plus the raw request body. | A published contract frozen by a CI test, because changing it breaks every consumer's verification at once. | Describing the scheme in prose, which produces as many implementations as there are readers. |
| Circuit | Per-endpoint state that stops dispatch after consecutive failures and probes periodically. | Stops the platform adding load to a consumer's incident, and stops a dead endpoint consuming delivery capacity. | Retrying at full rate on the published schedule, which is the platform participating in the outage. |
| Dead letter | A delivery that exhausted its attempt count or its wall-clock deadline. | Retained for 30 days, the source for replay, and the trigger for the notification that actually matters — the first one. | Dropping the delivery, which turns a recoverable outage into silent data loss. |
| Replay | Re-delivering a recorded delivery with its original event id and idempotency key. | The customer's self-service recovery path, rate-capped because it is a load test they are running against themselves. | Asking support to re-send, which does not scale and produces no record. |
| Ramped resumption | Bringing a recovered endpoint from 10% to 100% of its concurrency cap over five minutes. | The platform declining to cause the consumer's second outage, and the requirement most likely to be removed by someone optimising drain time. | Draining the backlog at full rate the instant a probe succeeds. |
| Published egress range | The NAT address range deliveries originate from, treated as a versioned public interface. | What 40,000 customer firewalls allowlist; the hardest thing on the platform to change, because the platform cannot see those rules. | A NAT range that moves with a routine infrastructure ticket. |
| Entitlement evaluation | Checking at fan-out time that an endpoint is allowed to receive this event type and these fields. | Evaluated at fan-out rather than at subscription time, so a plan change or a permission revocation takes effect on the next event. | Evaluating at subscription time, which delivers data to an endpoint whose entitlement lapsed months ago. |
svg/<view>.svg or drawio/<view>.drawio in draw.io Desktop or at app.diagrams.net to edit. The SVG carries the diagram inside it, so it is both the picture and the source. This folder is self-contained — copy it whole and every link still resolves.