<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Ayodejii</title>
    <description>The latest articles on Forem by Ayodejii (@aayodejii).</description>
    <link>https://forem.com/aayodejii</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F678945%2F60bf9bc3-3329-4900-8710-3b60d0170327.png</url>
      <title>Forem: Ayodejii</title>
      <link>https://forem.com/aayodejii</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/aayodejii"/>
    <language>en</language>
    <item>
      <title>Webhooks are easy to send. They're hard to deliver reliably</title>
      <dc:creator>Ayodejii</dc:creator>
      <pubDate>Wed, 08 Apr 2026 22:58:38 +0000</pubDate>
      <link>https://forem.com/aayodejii/webhooks-are-easy-to-send-theyre-hard-to-deliver-reliably-3and</link>
      <guid>https://forem.com/aayodejii/webhooks-are-easy-to-send-theyre-hard-to-deliver-reliably-3and</guid>
      <description>&lt;p&gt;Webhooks are easy to send. Delivering them is a different story.&lt;/p&gt;

&lt;p&gt;I got curious about this while building a side project. My app was sending webhooks, a simple HTTP POST to a client endpoint. It worked. Until it didn't. The endpoint went down briefly, I retried and the client processed the same event twice.&lt;/p&gt;

&lt;p&gt;That bug taught me that sending a webhook and delivering it reliably are two different problems. So I built a webhook delivery engine from scratch to understand what reliable delivery actually means. Four problems I ran into and how I solved each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Silent failures
&lt;/h2&gt;

&lt;p&gt;The most basic webhook implementation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. You fire the request and move on. If it succeeds, great. If it fails, you never know.&lt;/p&gt;

&lt;p&gt;The endpoint could be down. The network could have dropped the connection. You'd have no idea. The event is just gone.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: don't fire and forget. Instead, save the delivery to the database first, then process it asynchronously. If the request fails, you can retry it. If it never gets picked up, it's still in the database.&lt;/p&gt;

&lt;p&gt;In Deliverant, every event creates a delivery record before anything is sent. The delivery starts in a &lt;code&gt;PENDING&lt;/code&gt; state, moves to &lt;code&gt;IN_PROGRESS&lt;/code&gt; when a worker picks it up, and only reaches &lt;code&gt;DELIVERED&lt;/code&gt; after the endpoint confirms it with a 2xx response. If nothing confirms it, it retries.&lt;/p&gt;

&lt;p&gt;Nothing gets silently dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Duplicates
&lt;/h2&gt;

&lt;p&gt;Once you add retries, you get a new problem. If you send a webhook, the endpoint processes it but the response gets lost in transit, you'll retry. Now the endpoint has processed the same event twice.&lt;/p&gt;

&lt;p&gt;This is actually harder to solve than silent failures. The obvious answer is to just check if you've seen this event before but that breaks down quickly. What counts as the same event? How long do you keep track? What if the sender sends the same event twice intentionally? Arrghh!&lt;/p&gt;

&lt;p&gt;The standard approach is idempotency keys. The sender includes a unique key with each event. If the delivery engine sees the same key again within a defined window, it treats it as a duplicate and skips it. Stripe uses this pattern and I borrowed it for Deliverant.&lt;/p&gt;

&lt;p&gt;The window matters. Keep it too short and you miss legitimate retries. Keep it too long and you're storing unnecessary data. I went with 72 hours, which covers any reasonable retry window without being permanent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/events&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ord_abc123"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idempotency_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create-ord-abc123-1234567890"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Deliverant receives the same &lt;code&gt;idempotency_key&lt;/code&gt; twice within 72 hours, the second one comes back with a 200 but no new delivery is created. The sender thinks it worked. No duplicate processing happens on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Unclear failures
&lt;/h2&gt;

&lt;p&gt;Retries solve availability problems. They don't tell you anything about what actually went wrong.&lt;/p&gt;

&lt;p&gt;A delivery can fail for completely different reasons. The endpoint could be down. It could be returning a 400 because your payload schema changed. It could be rate-limiting you with a 429. A network timeout is a different problem from a DNS failure, which is a different problem from a TLS error.&lt;/p&gt;

&lt;p&gt;If you retry all of these the same way, you're wasting attempts. A 400 means the endpoint rejected your payload. Retrying it 12 times won't change that.&lt;/p&gt;

&lt;p&gt;In Deliverant, every failed attempt gets classified. The worker looks at the outcome and assigns one of these: &lt;code&gt;HTTP_5XX_RETRYABLE&lt;/code&gt;, &lt;code&gt;HTTP_4XX_PERMANENT&lt;/code&gt;, &lt;code&gt;RATE_LIMITED&lt;/code&gt;, &lt;code&gt;TIMEOUT&lt;/code&gt;, &lt;code&gt;NETWORK_ERROR&lt;/code&gt;, &lt;code&gt;TLS_ERROR&lt;/code&gt;, &lt;code&gt;DNS_ERROR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A 4xx response stops the delivery immediately. There's no point retrying something the endpoint has already told you is wrong. A 5xx or timeout gets scheduled for a retry with exponential backoff, starting at 5 seconds and capping at 24 hours across up to 12 attempts.&lt;/p&gt;

&lt;p&gt;You also get the full attempt history in the dashboard. HTTP status, latency, response body snippet and classification for every attempt on every delivery. When something fails, you know exactly what happened and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Risky replays
&lt;/h2&gt;

&lt;p&gt;At some point, deliveries will fail and stay failed. Maybe your endpoint was down for hours and exhausted all retry attempts. Now you have a backlog of failed events and you need to re-deliver them.&lt;/p&gt;

&lt;p&gt;The naive approach is a retry all button. It kicks off everything at once with no way to preview what will run, no record of who triggered it and no way to stop it once it starts. If something goes wrong, you have no idea what got re-delivered and what didn't.&lt;/p&gt;

&lt;p&gt;Deliverant handles replays as a batch operation. You select the failed deliveries you want to re-deliver, optionally run a dry-run first to see what would be created without actually creating anything, then confirm. Every batch gets a record with a status, a count of created deliveries, and a reference back to the source deliveries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/batches&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"del_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"del_def456"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dry_run"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dry run returns exactly what would happen. Once you're satisfied, you run it for real. The audit trail stays whether it was a dry run or not.&lt;/p&gt;

&lt;p&gt;That's the difference between retry all and hope and actually knowing what you did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The stack is Django, Celery, Redis and PostgreSQL. Nothing exotic.&lt;/p&gt;

&lt;p&gt;Django handles the API. Every ingest request, delivery query and replay operation goes through it. I chose Django because I know it well and because the ORM makes it easy to enforce the kind of invariants this system needs: unique constraints on idempotency keys, atomic state transitions, that sort of thing.&lt;/p&gt;

&lt;p&gt;PostgreSQL is the source of truth. Every event, delivery and attempt lives there. If anything crashes, the database has the full history and the system can recover from it.&lt;/p&gt;

&lt;p&gt;Celery runs the delivery workers and the retry scheduler. The scheduler runs on a beat timer and queries for deliveries that are due for their next attempt. Workers pick them up, execute the HTTP request, record the outcome and either mark the delivery as done or schedule the next retry.&lt;/p&gt;

&lt;p&gt;Redis is the task queue for Celery and also handles one other thing: the kill switch. There's a Redis-backed flag that pauses all delivery processing instantly without a deployment. Useful when something is wrong and you need to stop the workers immediately.&lt;/p&gt;

&lt;p&gt;The dashboard is Next.js. It talks to Django through a BFF layer. Next.js API routes proxy requests to the backend, with the API key stored in an HttpOnly cookie rather than exposed to the browser.&lt;/p&gt;

&lt;p&gt;The whole thing runs with &lt;code&gt;docker compose up --build&lt;/code&gt;. API, workers, scheduler, dashboard, Postgres, Redis. All of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Deliverant is open source and self-hostable today. If you want to run it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aayodejii/deliverant.git
&lt;span class="nb"&gt;cd &lt;/span&gt;deliverant
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API starts at &lt;code&gt;http://localhost:8000&lt;/code&gt;, the dashboard at &lt;code&gt;http://localhost:3000&lt;/code&gt;. The full API reference is at &lt;a href="https://deliverant.co/docs" rel="noopener noreferrer"&gt;deliverant.co/docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A hosted version is coming. If you'd rather not manage the infrastructure yourself, join the waitlist at &lt;a href="https://deliverant.co" rel="noopener noreferrer"&gt;deliverant.co&lt;/a&gt; and I'll let you know when it's ready.&lt;/p&gt;

&lt;p&gt;If you have questions or want to contribute, the repo is at &lt;a href="https://github.com/aayodejii/deliverant" rel="noopener noreferrer"&gt;github.com/aayodejii/deliverant&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhook</category>
      <category>python</category>
      <category>django</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
