<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Webhooks on Weli's blog</title><link>https://weli.dev/tags/webhooks/</link><description>Recent content in Webhooks on Weli's blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>blog@weli.dev (Gerard)</managingEditor><webMaster>blog@weli.dev (Gerard)</webMaster><copyright>Weli (CC BY 4.0)</copyright><lastBuildDate>Wed, 05 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://weli.dev/tags/webhooks/index.xml" rel="self" type="application/rss+xml"/><item><title>The valley of webhooks</title><link>https://weli.dev/blog/the-valley-of-webhooks/</link><pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate><author>blog@weli.dev (Gerard)</author><guid>https://weli.dev/blog/the-valley-of-webhooks/</guid><description>&lt;h2 id="the-third-time"&gt;The third time&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ve built the same system three times now, at three different companies, for three different providers. It never has a name and it never appears on a roadmap, but it always goes the same way: the truth about your own customers lives in someone else&amp;rsquo;s database. The users live in an identity provider, the subscriptions in Stripe, the bounces in whatever sends your email, and your product needs that truth locally. So you subscribe to webhooks and keep a copy.&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="the-third-time">The third time</h2>
<p>I&rsquo;ve built the same system three times now, at three different companies, for three different providers. It never has a name and it never appears on a roadmap, but it always goes the same way: the truth about your own customers lives in someone else&rsquo;s database. The users live in an identity provider, the subscriptions in Stripe, the bounces in whatever sends your email, and your product needs that truth locally. So you subscribe to webhooks and keep a copy.</p>
<p>The first time, I thought I was building an endpoint: one route that parses the JSON and updates a row, an afternoon of work.</p>
<p>The afternoon grew into a week. First came signature verification, because an open endpoint that mutates your database is a hole. Then the dedup table, because deliveries arrive twice and the docs cheerfully call this &ldquo;at-least-once.&rdquo; Then the handler got a buffer, because a <code>membership.created</code> sometimes shows up before the <code>user.created</code> it points to. Then the bootstrap importer, because webhooks only tell you what happens <em>after</em> you subscribe, and it raced the live events, so it grew a locking scheme. And finally came the reconciliation cron: a job that crawls the provider&rsquo;s list APIs at 3 a.m., diffs them against our tables, and quietly fixes what disagrees.</p>
<p>I want to be honest about what that cron is. It&rsquo;s a written confession. It says: <em>I do not trust the copy I built, and I have no way to know when it&rsquo;s wrong, so I will re-derive it from scratch every night, forever.</em></p>
<p>The trust was gone for a reason. The drift never announces itself; ours was found by a support ticket. A customer had cancelled months earlier and our database still said <code>active</code>; some <code>customer.subscription.deleted</code> had evaporated between Stripe and us, and nothing anywhere was capable of noticing: not their dashboard, which showed the delivery as retried and eventually dropped, and not our logs, which cannot log a request that never arrived.</p>
<p>And that&rsquo;s only the code. Every provider also brings its own dashboard. Three providers, three webhook configuration pages, each with its own idea of how endpoints are registered, which events exist, how test and live environments are kept apart, and where the signing secret lives. When something breaks, debugging is a tour: their delivery log in one tab, our logs in another, and a third tab for whichever dashboard I currently suspect. None of them look alike, and all of them have to be checked.</p>
<p>By the third time I built this system, I had stopped pretending. I budgeted for the whole stack up front (signatures, dedup, buffering, bootstrap, cron) and somewhere in the middle of writing my third dedup table, I finally asked the question I should have asked the first time.</p>
<p>What exactly am I reconstructing here?</p>
<h2 id="notifications-arent-data">Notifications aren&rsquo;t data</h2>
<p>I was reconstructing an ordered log. Every one of those integrations was an attempt to turn a stream of notifications back into the ordered, complete, current history it came from.</p>
<p>And here&rsquo;s the absurd part: that history <em>exists</em>. It has to, because it&rsquo;s sitting inside the provider; it&rsquo;s how they render their dashboards, their event pages, and their webhook replay tools. The provider takes their ordered log, shreds it into individual HTTP POSTs, fires them at my endpoint over a channel that guarantees neither order nor delivery, and then I reassemble the log on my side. So does every other consumer, independently, each with their own bugs.</p>
<p>It&rsquo;s a jigsaw puzzle where the manufacturer had the original picture, cut it up, mailed me the pieces one at a time, lost a few in the post, mailed some twice, and printed nothing on the box. And when my assembled puzzle doesn&rsquo;t match the original, their support team asks <em>me</em> which pieces I&rsquo;m missing. I don&rsquo;t know, and that&rsquo;s the entire problem: nothing announces a gap.</p>
<p>None of this is any provider&rsquo;s bug. Their webhooks work exactly as documented. The problem is what a webhook <em>is</em>: a notification, &ldquo;something happened, here&rsquo;s a POST about it.&rdquo; Notifications are a fine way to trigger a side effect and a terrible way to transfer a dataset, and somewhere along the way we started using them for the second thing without noticing we&rsquo;d changed jobs.</p>
<h2 id="how-did-this-become-the-norm">How did this become the norm?</h2>
<p>Nobody decided this. The term &ldquo;webhook&rdquo; was coined by Jeff Lindsay in 2007, and the early uses were genuinely good fits: GitHub&rsquo;s post-receive hooks kicking off a CI build, or a payment event pinging your server so it could email a receipt. The job was to do a thing when a thing happens, and for that a POST is perfect: fire-and-forget is fine when forgetting is fine.</p>
<p>Webhooks spread because they were the cheapest thing a provider could ship (one HTTP POST) and the cheapest thing a consumer could receive (you already had a web server, so you just added a route). By the early 2010s, &ldquo;we have webhooks&rdquo; was a checkbox on every API&rsquo;s landing page, and the checkbox never distinguished between two very different jobs:</p>
<ol>
<li><strong>Trigger a side effect</strong>: send the receipt, start the build, ping the channel.</li>
<li><strong>Keep a copy of the provider&rsquo;s data correct</strong>: this customer deleted their payment method, so update it in your DB too.</li>
</ol>
<p>Job one is what webhooks were born for. Job two is what I was doing all three times, and job two is the one where every property webhooks lack (ordering, completeness, bootstrap, verifiability) is precisely the property you need.</p>
<p>We picked the tool that was lying on the table in 2007, and then we spent fifteen years compensating.</p>
<h2 id="the-valley">The valley</h2>
<p>There&rsquo;s a concept in evolutionary biology I can&rsquo;t stop thinking about: the fitness landscape. Peaks are good designs, valleys are bad ones, and populations climb whatever slope they happen to be standing on. The trap is the <em>local optimum</em>: a small hill that&rsquo;s better than its immediate surroundings, so evolution parks there, even when a much higher peak exists across the valley. Getting to the higher peak means crossing through designs that are temporarily worse, and evolution doesn&rsquo;t do temporarily worse.</p>
<p><img src="/images/evolutionary-valley.svg" alt="A hand-drawn fitness landscape: webhooks sit in a valley slowly filling with mitigation tooling, while the provider-served log sits on a higher peak across the way"></p>
<p>Webhooks-for-replication are a local optimum, and the proof is the pile of workarounds on the valley floor: signature schemes, dedup stores, idempotent handlers, retry queues with exponential backoff on the provider side and dead-letter queues behind them, webhook logs with replay tooling because consumers keep asking for replays, and my 3 a.m. cron.</p>
<p>The pile has an economy on top of it. Svix exists so providers don&rsquo;t have to build webhook delivery; Hookdeck exists so consumers don&rsquo;t have to build webhook ingestion. AWS will sell you the valley as managed services, with EventBridge to ingest your SaaS partners&rsquo; events, SQS to queue them, and Lambda to retry your handler, and you get to assemble the pipeline yourself. And an entire industry of connector platforms (Fivetran, Airbyte, every &ldquo;unified API&rdquo; startup) is, at bottom, pseudo-CDC: change data capture reconstructed from webhooks and polled list APIs, one bespoke connector at a time, sold as a product. Inside a database, capturing changes is a solved problem: it&rsquo;s called replication, and it works because there&rsquo;s a log. Between companies, we rebuild it out of doorbells.</p>
<p>My favorite workaround of them all is the local tunnel. Many providers ship a CLI like <code>stripe listen</code> that opens a tunnel to your laptop, because a webhook cannot reach localhost. Think about what that is: a product, built and maintained by the provider, reinvented multiple times, whose entire purpose is to work around the delivery direction of their own primitive. When multiple providers all need to ship a local tunnel so developers can <em>develop</em>, the primitive is answering the wrong question.</p>
<p>None of this tooling is bad engineering; it&rsquo;s excellent engineering. That&rsquo;s what a local optimum looks like: so much excellent engineering poured into the valley floor that the valley becomes comfortable, and nobody looks up.</p>
<p>But some providers have looked up. Stripe retains <a href="https://docs.stripe.com/api/events">thirty days of events</a> and exposes <a href="https://docs.stripe.com/api/events/list"><code>/v1/events</code></a>, an ordered, listable log, and <a href="https://docs.stripe.com/webhooks/process-undelivered-events">recommends reconciling against it</a>. WorkOS ships an <a href="https://workos.com/docs/events/data-syncing/events-api">Events API</a>, an ordered cursor-paginated log, and <a href="https://workos.com/docs/events/data-syncing">their own docs recommend it over webhooks</a> when data consistency matters. The log keeps escaping, and each escape mints its own bespoke cursor semantics, its own bootstrap story, no way to verify a replica, and no shared contract, but the direction is unmistakable. This is convergent evolution: unrelated organisms, same environmental pressure, same wing.</p>
<p>The log exists everywhere, but the contract doesn&rsquo;t.</p>
<h2 id="could-it-be-better">Could it be better?</h2>
<p>Before reaching for a new design, it&rsquo;s worth asking what any replacement would actually have to provide. My three integrations suggest the list: order, so changes can be applied without buffering; a way to start from nothing, so bootstrap isn&rsquo;t a separate import racing the live events; deletes as data, so absence stops being the failure mode; resumability, so my downtime is my problem instead of a data-loss event; and some way to verify the result, so trust doesn&rsquo;t decay into a 3 a.m. cron.</p>
<p>Measured against that list, the obvious candidates come up short. Polling the list APIs harder is the reconciliation cron promoted to a whole strategy: it can rebuild current state, but it burns rate limits discovering that mostly nothing changed, it says nothing about order, and a deleted object looks identical to an object that never existed. Managed delivery, whether that&rsquo;s Svix on the provider&rsquo;s side or EventBridge and SQS on mine, makes the pushes more reliable, but they are still pushes: still no bootstrap, still no verification, still notifications pretending to be a dataset. That path hardens the valley floor without climbing anywhere.</p>
<p>The third candidate is the one the providers keep half-building on their own: stop pushing altogether, and let the consumer read the log itself.</p>
<h2 id="flip-the-arrow">Flip the arrow</h2>
<p>So here&rsquo;s the thought experiment. What if instead of the provider telling us when there is new information, we ask the provider what new information it has for us since we last checked?</p>
<p><img src="/images/webhooks-vs-log.svg" alt="Hand-drawn sketch: on the left, webhooks push a tangle of arrows at your endpoint; on the right, you pull one ordered log with a cursor"></p>
<p>Suppose a provider served one URL per collection, and that URL returned an ordered, cursor-addressed change log of full-state events. Ask without a cursor and you read from the beginning, which is your bootstrap, with no separate import and no race. Ask with a cursor and you resume where you left off. Your entire sync state is that cursor.</p>





<pre tabindex="0"><code>GET /feed/customers?cursor=01J9XQ4R
Prefer: stream

200 OK
Content-Type: application/x-ndjson

{&#34;cursor&#34;:&#34;01J9XR2M&#34;,&#34;operation&#34;:&#34;upsert&#34;,&#34;object&#34;:{&#34;id&#34;:&#34;cus_123&#34;,&#34;plan&#34;:&#34;pro&#34;}}
{&#34;cursor&#34;:&#34;01J9XR2N&#34;,&#34;operation&#34;:&#34;delete&#34;,&#34;object_id&#34;:&#34;cus_099&#34;}</code></pre><p>Send <code>Prefer: stream</code> and the response never ends: each change arrives as it commits, over a connection <em>you</em> opened, using the same API key you use for the normal REST endpoints. Leave it off and you get a bounded page you can poll from a cron. It&rsquo;s the same endpoint, the same events, the same cursors, and the same consumer code.</p>
<p>None of this is exotic; it&rsquo;s a paginated GET. But walk back through my afternoon-that-grew and watch what it does to the stack:</p>
<ul>
<li><strong>The dedup table</strong> is gone. Every event carries the object&rsquo;s full current state, so applying one is a blind upsert keyed by id, and the same event applied twice produces the same result.</li>
<li><strong>The ordering buffer</strong> is gone, because the log is ordered.</li>
<li><strong>The bootstrap importer and its locking scheme</strong> are gone. A new consumer reads the same feed with no cursor, replays the collection, and carries straight on into live changes in one request.</li>
<li><strong>The lost delete</strong> is impossible. A tombstone is an event in the log, and it sits there until I read it. My cancelled customer cannot silently stay <code>active</code>, because absence stopped being the failure mode.</li>
<li><strong>The endpoint, the signatures, and the tunnel</strong> never exist in the first place. Every connection is consumer-initiated, and the loop runs behind NAT, on a laptop, or in a scheduled job.</li>
</ul>
<p>The feed could carry one more thing. When a read reaches the end of the log, the provider could tell you what should be there: a count and a checksum of current state, at the cursor you now hold. You compare the two, and you <em>know</em> your replica is right instead of assuming it. My 3 a.m. cron, the written confession, becomes a comparison I&rsquo;ve already made by the time I would have thought to schedule one.</p>
<h2 id="the-fourth-time">The fourth time</h2>
<p>If a feed like that existed, the fourth time I build this system would be a loop: <code>GET</code> the feed, let &ldquo;upsert&rdquo; upsert the object into my db and &ldquo;delete&rdquo; delete it, and save the last cursor. That&rsquo;s twenty lines with no route, no secrets to rotate, no queue, and no cron. The replica carries its own proof of correctness, and when someone asks which customers have an active subscription and a bouncing email address, the answer is a <code>JOIN</code> across local tables with no silent asterisk attached.</p>
<p>Nobody serves this today. That&rsquo;s the catch, and it&rsquo;s also the point.</p>
<h2 id="scroll">SCROLL</h2>
<p>I wanted to see whether the idea survives being written down precisely, so I drafted it as a protocol: <strong>SCROLL</strong>, short for Synchronized Change Replication Over Line Logs, at <a href="https://welidev.github.io/scroll/">welidev.github.io/scroll</a>. It&rsquo;s draft-00 in the request-for-comments sense of the phrase. It pins down the feed, the cursors, the streaming and polling modes, the checkpoints, tombstones, and retention, and it marks the places where my own confidence is lowest. It also doesn&rsquo;t require waiting for providers, since a shim can synthesize a feed from any provider&rsquo;s existing webhooks and list APIs, which is how I plan to find out where the design is wrong.</p>
<p>If you&rsquo;ve lived in the valley, if you&rsquo;ve written a dedup table or debugged a reconciliation cron or watched a delete evaporate, read it and tell me where it breaks. Disagreement is the desired response; silence is the failure mode.</p>]]></content:encoded></item></channel></rss>