webpoke — executive order, event delivery division

Restoring Truth and Sanity to Webhooks

Webhooks should be a poke with a hint and a ranged pull. Truth lives in a pullable ledger; sanity is a cursor you own. Everything else — signatures, retries, idempotency keys, replay consoles — is the cost of pretending delivery is a source of truth.

art. 01

Notifications carry validity, not values. A poke says "the frontier is at or beyond X" — never the data itself.

art. 02

The contract is a cursor, not a delivery. The consumer owns one durable integer; the provider owns a readable ledger.

art. 03

Every failure mode degrades to latency, never to corruption. Lost, duplicated, and reordered pokes are all harmless.

art. 04

Trust the data, not the transport. A poke can be forged, replayed, dropped, or reordered and still cannot corrupt a consumer — all it can do is suggest re-reading a ledger the consumer verifies for itself.

Reference stack

See poke + pull survive everything you throw at it.

A filmed walkthrough of the standalone reference stack: a vendor appends order events to an S2-shaped ledger and fires a signed, content-free poke; a customer-owned cursor pulls ranged reads, runs one handle(), and commits its checkpoint. Cut the notification channel and nothing breaks — lag climbs, then one heartbeat drains the backlog with events-lost: 0. Abuse the poke with duplicates, stale hints, and out-of-order delivery; the monotone frontier coalesces them harmlessly. Spin up a slow second consumer and watch isolation hold.

Open on YouTube · filmed against the local reference stack in webpoke-demo

The order

Move the contract from delivery to cursor.

The provider's side gets boring: best-effort fan-out plus a range-read endpoint it needed anyway. One design, internal and external.

Signed content-free poke

A best-effort notification that something changed: an account ID, a stream name, optionally a frontier hint. Nothing in it is load-bearing.

GET /changes?since=<cursor>

An authoritative range-read over an append-only ledger: ordered, bounded pages, opaque cursors, and a stated replay window.

Heartbeat floor

Consumers poll anyway every N minutes. A consumer that missed every poke self-heals without anyone operating a replay console.

Consumer-owned checkpoint

One row per consumer. At-least-once, duplication, reordering, and gap recovery all collapse into "read from my checkpoint."

Findings

What payload-bearing webhooks actually cost.

The ecosystem default optimizes for the first hour of integration and pays for it forever after. Systems designed by their first demo choose push-with-payload; systems designed by their failure modes choose poke and pull.

Delivery becomes the correctness mechanism

The moment the notification carries the data, you owe signatures, ordering guarantees, per-consumer retry queues, idempotency keys, dead-letter handling, and a replay console — a product surface that exists only because the webhook is trying to be a source of truth while in flight.

The disclaimer is an unpriced liability transfer

"Here's webhooks, but don't rely on them" makes the consumer build both halves: the full push receiver and the reconciliation poller. The provider saves one redesign; every consumer pays the reconciliation engineering independently, usually after their first incident.

The payload's value goes negative

Follow the providers' own checklists — dedupe, tolerate reordering, re-fetch the object instead of trusting the embedded one — and the payload contributes nothing. It is pure attack surface plus a false sense of completeness, retained because JSON in the body demos well.

The workaround is the confession

Every serious shop puts a single receiver in front of an internal bus, re-materializing the provider's ledger locally to recover the offset-pull properties the provider had and declined to expose — seeded through the lossy channel, so it inherits the gaps.

Precedent

Everyone at scale already converged here.

The pattern has quietly won at every provider that operates webhooks at real scale — which is the tell.

Dropbox

Content-free webhooks ("something changed for these accounts") plus a cursor-based delta pull.

Google Drive

Push notifications carry essentially nothing; you call changes.list with your page token.

Plaid

Migrated payload-bearing transaction webhooks to /transactions/sync; docs describe the webhook as a signal to call sync now.

Stripe

GET /v1/events is a pullable, cursor-paginated, ~30-day event ledger — shipped, but framed as the fallback while the lossy channel is framed as the product.

Salesforce Pub/Sub

Cursor pull with replay IDs over gRPC.

Kafka / CDC / replication slots

Consumer-owned offsets everywhere; the "push" people perceive is a long-poll wakeup.

RFC 5005 archived Atom feeds

Append-only pages with stable URLs and far-future cache headers — a pullable, CDN-able ledger fifteen years early. Greg Young exposed an event store over exactly this shape; nobody built the open-web consumer runtime for it.

Lineage

RSS got the architecture right. We're signing it.

The pattern isn't new — it's the oldest correct answer on the web, read clearly. Greg Young built an event store and chose Atom as its read API for exactly these properties: the feed is the log, history is immutable and cacheable, and the consumer owns its checkpoint.

The feed is the log, not a notification

An Atom or RSS entry is an immutable, addressable fact with a stable id; the feed is an append-only event stream, and current state is a left-fold over it. The consumer follows links and keeps its own position — a cursor by another name. We don't improve on RSS here; we read it the way Greg Young did when he served an event store's streams over Atom.

Polling is underrated

Conditional GET — an ETag or If-Modified-Since answered with a 304 — makes re-reading a feed nearly free. The heartbeat floor isn't a workaround bolted onto push; it's the web's original liveness mechanism, already cached by infrastructure you don't run.

History is immutable and cacheable

RFC 5005 archived pages never change and carry far-future cache headers; only the head page is hot. A catch-up subscriber reads from its checkpoint, folds forward to the head, then stays live — and a CDN accelerates the whole ledger without ever being trusted to author it.

Signing is the one thing we add

RSS trusts the origin server to author honestly. A decentralized firehose wants the events themselves signed so untrusted relays can carry them. Sign the events, add a content-free poke to cut poll latency, and webpoke is just Atom-over-HTTP — Greg Young's read model, federated.

The general shape

Every sync protocol is this invariant, partly admitted.

Notifications are hints, signed logs are truth, cursors belong to the consumer. Each protocol rediscovers the split and usually compromises it — which is why decentralized social firehoses keep converging here under load.

Decentralized social firehoses

An ATProto repo is a signed, event-sourced commit log; consumers replay subscribeRepos from a seq cursor. The ecosystem's scaling pain is fan-out of full content to every consumer — exactly the half a content-free poke deletes. An untrusted relay should only ever be able to say "go check," never to author.

ActivityPub, the cautionary opposite

Fan-out on write: push the content into each inbox, with no log, no cursor, and no replay. An inbox that's down loses the event; a slow inbox backs up the sender's delivery queue. Every failure mode webpoke designs out, push-delivery federation designs in.

The rest of the family

Kafka offsets, CDC replication slots, Matrix /sync tokens, Nostr since/until, IMAP IDLE then FETCH, NNTP high-water marks — a log with a per-consumer position, all the way back to newsgroups. Wherever a durable "push" appears, it's a long-poll wakeup over a ledger someone is quietly pulling.

Dividends

Offset pull is multi-consumer by construction.

Each consumer's entire existence, from the producer's perspective, is one integer the producer doesn't even know about.

Permissionless consumers

A new consumer is just a reader with credentials. The producer's cost is O(1) in consumer count — ranged reads of immutable history are the most cacheable workload that exists. You can put a CDN in front of an event log. Try CDN-ing a webhook.

Consumer isolation

A slow consumer is just behind; nobody else can tell. No provider-side retry queues backing up, no "we deregistered your endpoint because it failed too often."

History through the same channel

A webhook subscription starts at now; bootstrapping needs a separate backfill API and a gapless stitch. An offset consumer bootstraps by starting at zero. Same channel, same code path, no seam.

Replay as an operation, not a project

Rebuild after a bug: rewind the cursor. Test a new implementation: run it side-by-side at its own offset and diff the outputs. There is no webhook-shaped version of blue-green consumption.

Code on demand

Send the filter to the data, not the data to the filter.

The cursor pull is the consumer's — but it still pays egress and filters locally. Let it ship a cheap, sandboxed predicate to the query side: pull your slice, not the firehose. The poke stays content-free; only the pull learns a new trick.

Ship the filter to the data

The pull can carry a small sandboxed WASM filter/map/reduce that runs at /changes before the page is serialized — narrow to one stream, project three fields, fold a running count. The cursor contract is unchanged; only less crosses the wire.

Cheap by construction

WASM instantiates in microseconds and runs resource-capped — fuel, memory, and wall-clock limits, no network, no disk. A provider runs untrusted consumer code the way an edge runtime already runs untrusted handlers: safely, in a sandbox, as a metered line item.

The provider can bill for it

Pushed-down compute is just another SKU: count the fuel, estimate the cost, charge it on the same per-request counters a spend proxy already meters. Incentives align — the consumer pays a little to not move bytes it would have discarded; the provider sells cycles instead of egress.

Fielding's optional constraint, finally earning its keep

Code-on-demand is the one REST constraint almost nobody used. Push-down predicates keep getting reinvented per system — Kafka server-side filters, ClickHouse, ATProto Jetstream wildcards, S2 read-time filters. A WASM filter at the ledger generalizes the move; for a decentralized firehose it means pulling your slice instead of the whole thing.

The rubric

Three questions, in order.

The hybrid is not universal. Imperatives, lossy telemetry, sub-round-trip feeds, and ephemeral presence each want a different grain — the rubric tells you which.

1

Is there an authoritative versioned store the consumer could read?

No → push, with durability matched to the message: imperatives get durable, acked delivery; samples get fire-and-forget. Commands have no current value to re-pull — if the poke is lost, the intent is lost.

2

Is the notification reducible to a monotone fact?

A cursor, a version, a dirty bit. Yes → poke + pull; monotone facts form a lattice — duplicates coalesce, reorders take the max, losses are subsumed by any later poke or heartbeat. The channel can now be maximally cheap and unreliable.

3

Is floor latency tolerable when the channel fails?

Yes → done. No → still build poke + pull, then spend money hardening only the poke channel — the cheap half, because hardening it cannot corrupt anything.

Deliverable

Ship the stateful half of the SDK.

Every provider ships the stateless client — the easy 20% — and abandons consumers at exactly the part that breaks in production. The fix is infrastructure modules, not library code: ~200 lines of IaC plus a handler stub, in three reference stacks (AWS, GCP, plain Postgres).

Poke receiver

Stateless endpoint or partner event-bus source. Its entire job is triggerPollNow().

Cursor row

One durable record per consumer in Dynamo, Postgres, or wherever state already lives.

Ranged-pull worker

A Lambda or container that reads /changes from the checkpoint, in order, in bounded pages.

Fan-out + DLQ

Per-stream or per-type dispatch to queues or a bus, with the dead-letter path that push made mandatory now merely optional.

Status

Doctrine live. Reference stack filmed.

doctrine

Drafted — this page, distilled from field notes on poke + ranged-pull systems.

reference stack demo

Live — filmed walkthrough of the Acme Orders stack: poke cut, heartbeat drain, poke abuse, slow-consumer isolation, and a shadow second handler diffing from cursor zero.

changes-endpoint spec

Sketch — the tiny RFC: opaque cursors, ordered bounded pages, stated replay window, signed content-free poke.

consumer runtime modules

Roadmap — Terraform/CDK reference stacks for AWS, GCP, and Postgres-in-a-container.