Told when it changes, from a queue rather than a transaction
Your storefront rebuilds when an article goes live. A search index hears that a product was retracted. Both are one HTTP request from here to you — signed, retried and logged, and sent by a worker that commits its claim before it opens a socket, so a receiver that is down for the night costs one endpoint’s deliveries and nothing else.
For the engineer wiring up the site, the index or the cache that has to keep up
The subscriber that never touches the network
Every write records its domain events in an outbox table, in the same transaction as the write; after the commit a dispatcher hands each event to its subscribers — the activity log, the alarms evaluator, the webhook fan-out. The obvious design posts to your endpoint from that subscriber, and it is wrong: subscribers run inside the transaction that claims a batch, so a slow receiver would hold a database transaction open for a stranger’s response time, and a failure would count against the event itself — eventually hiding it from the audit log too.
So the fan-out does two things: one query for the enabled endpoints whose filters match, and one insert of a delivery row per endpoint. No network. A separate worker claims pending rows, commits the claim, and only then makes the request with nothing open. Retries, backoff and giving up belong to the delivery row, so one broken receiver costs one endpoint.
The queue and the log are the same table. "Did it arrive?", "what did we send?" and "send it again" are reads and writes of one row — which is why the admin shows every delivery with its status, its attempts and a redeliver button.
The delivery table in the middle, with four receivers docked into it, each subscribed to different event kinds.
Eight event kinds
The entry lifecycle, plus a test ping.
| Kind | When it fires |
|---|---|
| entry.created | A new record was saved, as a draft. |
| entry.updated | An existing record’s values changed. |
| entry.published | A record went live — the one a site build listens for. |
| entry.unpublished | A live record was taken back to draft. |
| entry.deleted | A record was soft-deleted into the trash. |
| entry.restored | A trashed record was brought back. |
| entry.purged | A record was permanently removed. The event still names its workspace, because there is no row left to ask. |
| ping | What the "Send test" button produces, so a receiver can be checked before anything real happens. |
Media, account and workspace events are not offered yet — media events do not carry their workspace, and account events are administrative audit with a different audience.
Three filters, and what an empty one means
An endpoint is a URL, a signing secret, and three sets: workspaces, event kinds and content types. A delivery goes out when the event matches all three. There is no expression language — a second query grammar would be a second place for a rule to mean something the interface does not.
An empty set means everything, including things that do not exist yet: a workspace created next month, a kind added in the next release, a content type you have not written. That is the opposite of an API token, which forbids an empty workspace set — but a token’s set is the bounds of its authority, and this one is a subscription filter, where "all" is an ordinary answer.
The body carries references, not field values: the event, the workspace, who did it, and the record’s type and id. Your receiver reads the record back through the delivery API with its own token, so the published-only rule and any audience entitlements still apply. A webhook that inlined the values would route around every one of them, so there is no option to.
Verifying a delivery
Four things a receiver does, in this order.
-
Check the signature over the raw body
X-Apograph-Signatureist=<unix seconds>,v1=<hex>: an HMAC-SHA256 over the timestamp, a dot, and the exact bytes received. The timestamp is inside the signed string, so a captured delivery cannot be replayed; reject anything older than five minutes. Sign what arrived, not a re-serialised object. -
Deduplicate on the event id
Delivery is at-least-once.
X-Apograph-Event-Idis stable across every retry and every manual redelivery, whileX-Apograph-Deliverychanges each time. Remember the event id; ignore a repeat. -
Answer 2xx quickly
The default timeout is ten seconds. Acknowledge, then do the work — a receiver that rebuilds a site before answering will be asked to rebuild it.
-
Do not rely on order
Attempts retry on independent schedules and several workers may send at once, so an update can arrive after the publish that followed it. If order matters, order by
occurredAt.
Details that decide an integration
- Six attempts over nine hours
- Retried after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, each jittered by a fifth so a receiver coming back from an outage is not hit by its whole backlog on one tick. A
Retry-Afteron a 429 is honoured, up to one hour. - A 4xx is final, on purpose
- 408 and 429 describe a moment and are retried. Every other 4xx describes the request and will not be accepted on the sixth try, so the delivery is marked dead at once. After twenty consecutive failures the endpoint switches itself off, and the admin says why.
- Where this server may connect
- HTTPS only, no credentials in the URL, and loopback, link-local, private and reserved addresses refused — the cloud metadata range included. The check runs on the resolved address at every send, not the hostname at save time, so a name re-pointed later is refused too.
- Your own headers, minus the reserved ones
- Static headers per endpoint — a bearer token for a gateway, an API key. Anything starting with
X-Apograph-and the transport’s own headers are refused, so a delivery can never claim to be a different event. - The secret is shown once, and rotates in place
- Revealed on creation and on rotation, never by a read route; the interface shows its last four characters. Rotation takes effect for deliveries already queued, because they are signed when they are sent.
- Administrator-only, even to read
- An endpoint spans every workspace it names, holds a signing secret, and its log records where this installation talks to on the network. Both
webhooks:readandwebhooks:managestay with administrators, and redelivery needsmanage— it is a request to someone else’s system, which is a write however it is spelled.
The storefront that must not serve a draft
An editor publishes the winter collection at 09:12. Two seconds later a signed delivery reaches your build hook; the receiver checks the signature, records the event id, answers 200 and starts the build. The build fetches the collection through the delivery API with a read token, which serves published entries only — so if the editor unpublished it at 09:13, no draft was ever shown, and the next delivery takes it down.
Questions before you write a receiver
Is this a message bus?
No. It is an HTTP POST per event per endpoint, at-least-once and unordered, with a two-second tick before the first attempt; publishing five hundred records produces five hundred deliveries. For a cache purge or a site rebuild that is invisible. For sub-second propagation or strict ordering it is the wrong mechanism.
What if my receiver is inside my own network?
The default refuses private addresses, because "the server requests an address a user typed" is the shape of every SSRF. An installation whose receiver shares the cluster turns private networks on deliberately, in configuration.
Next
Every feature is included, free
The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.