<?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: EmitHQ</title>
    <description>The latest articles on Forem by EmitHQ (@emithq).</description>
    <link>https://forem.com/emithq</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%2F3842443%2F54811ca0-f9d6-45e2-8ce2-98f4b26a144d.png</url>
      <title>Forem: EmitHQ</title>
      <link>https://forem.com/emithq</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/emithq"/>
    <language>en</language>
    <item>
      <title>Webhook Delivery Architecture: How We Built for Reliability</title>
      <dc:creator>EmitHQ</dc:creator>
      <pubDate>Sun, 29 Mar 2026 20:54:34 +0000</pubDate>
      <link>https://forem.com/emithq/webhook-delivery-architecture-how-we-built-for-reliability-5e50</link>
      <guid>https://forem.com/emithq/webhook-delivery-architecture-how-we-built-for-reliability-5e50</guid>
      <description>&lt;p&gt;Webhook delivery looks simple. POST a JSON body to a URL, check for a 2xx, move on.&lt;/p&gt;

&lt;p&gt;Then your Redis instance restarts and you lose 4,000 queued deliveries. A customer's endpoint goes down for six hours and your retry logic hammers it 50 times a second. Someone registers &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; as their endpoint URL and starts reading your cloud metadata.&lt;/p&gt;

&lt;p&gt;These are the problems that determine webhook delivery reliability — and they only show up in production. Here's how EmitHQ handles each one, with the actual TypeScript from our &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ" rel="noopener noreferrer"&gt;open-source codebase&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer API call
       │
       ▼
  ┌─────────┐     ┌────────────┐     ┌──────────────┐
  │ Hono API │────▶│ PostgreSQL │────▶│ BullMQ Queue │
  │ (verify  │     │ (persist   │     │ (deliver     │
  │  auth,   │     │  message + │     │  to endpoint,│
  │  RLS)    │     │  attempts) │     │  retry, DLQ) │
  └─────────┘     └────────────┘     └──────────────┘
                        ▲                    │
                        │                    ▼
                   Source of truth    Customer endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arrow from PostgreSQL to BullMQ is one-way on purpose. The database is the source of truth. The queue is a best-effort delivery mechanism. If the queue loses a job, the database still has the message and every pending delivery attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist Before Enqueue
&lt;/h2&gt;

&lt;p&gt;When a customer sends a webhook message through the API, we write it to PostgreSQL inside a transaction — along with one &lt;code&gt;delivery_attempt&lt;/code&gt; row per active endpoint — before touching the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// messages.ts — inside a tenant-scoped transaction&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoNothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// UNIQUE(app_id, event_id)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Idempotency hit — this event_id was already processed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 200, not 202&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Fan out: one delivery attempt per active endpoint&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deliveryAttempts&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptRows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Queue is best-effort — failure here is non-fatal&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptRows&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;onConflictDoNothing()&lt;/code&gt; on &lt;code&gt;UNIQUE(app_id, event_id)&lt;/code&gt; gives us database-level idempotency. If the same event arrives twice — network retry, client bug, load balancer replay — the second insert silently skips and we return the original message.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;catch(() =&amp;gt; {})&lt;/code&gt; on &lt;code&gt;enqueueDelivery&lt;/code&gt; is deliberate. If Redis is down, the message and its delivery attempts are already in PostgreSQL. A recovery process can re-enqueue pending attempts from the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standard Webhooks Signing
&lt;/h2&gt;

&lt;p&gt;Every outbound delivery is signed using the &lt;a href="https://www.standardwebhooks.com/" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; specification — the same HMAC-SHA256 scheme used by Zapier, Twilio, and Supabase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webhook-signer.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;webhookId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;whsec_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toSign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;webhookId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`v1,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawSecret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toSign&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three headers go out with every delivery: &lt;code&gt;webhook-id&lt;/code&gt; (message ID), &lt;code&gt;webhook-timestamp&lt;/code&gt; (Unix seconds), and &lt;code&gt;webhook-signature&lt;/code&gt; (the &lt;code&gt;v1,{base64}&lt;/code&gt; HMAC).&lt;/p&gt;

&lt;p&gt;Secrets use the &lt;code&gt;whsec_&lt;/code&gt; prefix format — the prefix is stripped, and the remainder is base64-decoded to raw key bytes. Each endpoint gets its own signing secret. Independent rotation, no cross-endpoint impact.&lt;/p&gt;

&lt;p&gt;On the verification side, we use &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; — never string equality. String comparison leaks timing information that can be used to forge signatures byte by byte. Verification also rejects timestamps outside a 5-minute tolerance window, preventing replay attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhook Retry Logic: Full Jitter
&lt;/h2&gt;

&lt;p&gt;When a delivery fails with a 5xx, timeout, or connection error, the webhook retry logic kicks in. But we don't use simple exponential backoff — we use full jitter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backoff.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RETRY_DELAYS_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~5s&lt;/span&gt;
  &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~30s&lt;/span&gt;
  &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~2m&lt;/span&gt;
  &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~15m&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_600_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~1h&lt;/span&gt;
  &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~4h&lt;/span&gt;
  &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ~24h&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeBackoffDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsMade&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsMade&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RETRY_DELAYS_MS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RETRY_DELAYS_MS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The delay is &lt;code&gt;random(0, cap)&lt;/code&gt; — not &lt;code&gt;cap + random jitter&lt;/code&gt;. This is full jitter as described in the &lt;a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/" rel="noopener noreferrer"&gt;AWS architecture blog&lt;/a&gt;. Standard exponential backoff with decorrelated jitter still clusters retries near the cap. Full jitter spreads them uniformly across the entire window. The result: a recovering endpoint gets a steady trickle of retries instead of a synchronized burst.&lt;/p&gt;

&lt;p&gt;Eight attempts over a ~29-hour window. After that, the message moves to the dead-letter queue.&lt;/p&gt;

&lt;p&gt;Not everything gets retried. Status codes 400, 401, 403, 404, and 410 are permanent failures — the endpoint rejected the request for a reason that won't fix itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// delivery-worker.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NON_RETRIABLE_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NON_RETRIABLE_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnrecoverableError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Non-retriable status &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BullMQ's &lt;code&gt;UnrecoverableError&lt;/code&gt; bypasses the entire retry schedule and moves the job straight to failed. No wasted retries against a 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  Circuit Breaker and Dead-Letter Queue
&lt;/h2&gt;

&lt;p&gt;If an endpoint fails 10 times consecutively, we stop hitting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// delivery-worker.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CIRCUIT_BREAKER_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentFailures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failureCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentFailures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;CIRCUIT_BREAKER_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;adminDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;disabledReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;circuit_breaker: consecutive failure threshold reached&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;failureCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentFailures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Operational webhook sent: endpoint.disabled&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any successful delivery resets &lt;code&gt;failureCount&lt;/code&gt; to 0. The circuit breaker is per-endpoint — one failing endpoint doesn't affect the others. Disabled endpoints can be re-enabled through the API or dashboard, which resets the failure counter and resumes delivery.&lt;/p&gt;

&lt;p&gt;When all 8 retry attempts are exhausted, the delivery attempt is marked &lt;code&gt;exhausted&lt;/code&gt; and lands in the dead-letter queue. From there, it can be replayed through the API or dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// replay.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;replayDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;adminDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deliveryAttempts&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;nextAttemptAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deliveryQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deliver&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`replay:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attemptId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;replay:&lt;/code&gt; prefix and timestamp in the job ID prevent BullMQ deduplication from ignoring the re-enqueued job.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSRF Protection
&lt;/h2&gt;

&lt;p&gt;Customers provide their own endpoint URLs. Without validation, an attacker could register &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; — the AWS metadata endpoint — and have your delivery worker fetch their cloud credentials.&lt;/p&gt;

&lt;p&gt;Blocking the IP at registration time isn't enough. DNS rebinding attacks work by having a hostname resolve to a public IP during validation, then switching to an internal IP before the actual delivery request.&lt;/p&gt;

&lt;p&gt;EmitHQ validates at both points. At endpoint creation, we resolve the hostname and check the IP against blocked ranges (RFC 1918, loopback, link-local, cloud metadata). At delivery time, we resolve again and re-check — catching any DNS rebinding that happened between registration and delivery.&lt;/p&gt;

&lt;p&gt;Blocked ranges include &lt;code&gt;169.254.169.254&lt;/code&gt;, &lt;code&gt;metadata.google.internal&lt;/code&gt;, &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, &lt;code&gt;192.168.0.0/16&lt;/code&gt;, and &lt;code&gt;127.0.0.0/8&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the Code
&lt;/h2&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ" rel="noopener noreferrer"&gt;github.com/Not-Another-Ai-Co/EmitHQ&lt;/a&gt; under AGPL-3.0. Start with &lt;code&gt;packages/core/src/workers/delivery-worker.ts&lt;/code&gt; — it ties together every pattern in this post. The signing is at &lt;code&gt;packages/core/src/signing/webhook-signer.ts&lt;/code&gt;, the retry schedule at &lt;code&gt;packages/core/src/queue/backoff.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If something doesn't hold up, &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt;. If it does, &lt;a href="https://emithq.com" rel="noopener noreferrer"&gt;emithq.com&lt;/a&gt; — one API call to sign up, no credit card.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>typescript</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Why We Built EmitHQ: The $49–$490 Webhook Pricing Gap</title>
      <dc:creator>EmitHQ</dc:creator>
      <pubDate>Sun, 29 Mar 2026 20:53:23 +0000</pubDate>
      <link>https://forem.com/emithq/why-we-built-emithq-the-49-490-webhook-pricing-gap-npd</link>
      <guid>https://forem.com/emithq/why-we-built-emithq-the-49-490-webhook-pricing-gap-npd</guid>
      <description>&lt;p&gt;I was building a SaaS product that needed to send webhooks to customers. The kind of thing where your billing system fires &lt;code&gt;invoice.paid&lt;/code&gt; and three different customer endpoints — their CRM, their Slack bot, their data pipeline — all need to receive it reliably.&lt;/p&gt;

&lt;p&gt;So I looked at the existing webhook platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pricing Cliff
&lt;/h2&gt;

&lt;p&gt;The free tiers are generous. Svix gives you &lt;a href="https://www.svix.com/pricing/" rel="noopener noreferrer"&gt;50,000 messages a month&lt;/a&gt;. Hookdeck offers &lt;a href="https://hookdeck.com/pricing" rel="noopener noreferrer"&gt;10,000 events&lt;/a&gt;. Plenty to build against.&lt;/p&gt;

&lt;p&gt;But then you need to go paid.&lt;/p&gt;

&lt;p&gt;Svix's first paid tier is &lt;strong&gt;$490/month&lt;/strong&gt;. There is no $50 plan. No $100 plan. You go from free to $490 in one step.&lt;/p&gt;

&lt;p&gt;Hookdeck has a $39/month Team plan, but it caps default throughput at 5 events per second. Their next real tier is Growth at $499/month. Need more throughput? That's an add-on at roughly $1 per event per second — so 50 evt/s costs an extra ~$45/month on top of your plan.&lt;/p&gt;

&lt;p&gt;Convoy starts at $99/month, which is more reasonable. But their &lt;a href="https://www.elastic.co/licensing/elastic-license" rel="noopener noreferrer"&gt;Elastic License v2.0&lt;/a&gt; restricts offering it as a managed service. If you're just using Convoy to send your own webhooks, the license is fine. If you want to embed webhook delivery into a platform you sell to others, you'll need to look elsewhere.&lt;/p&gt;

&lt;p&gt;That leaves a gap. A SaaS team doing 200K events a month — past the free tier, nowhere near enterprise — has no good option between building it themselves and paying $490.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Gap Exists
&lt;/h2&gt;

&lt;p&gt;These aren't bad companies. Svix created the &lt;a href="https://www.standardwebhooks.com/" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; specification, now adopted by companies including Zapier, Twilio, Kong, ngrok, and Supabase. That's a genuine contribution — dozens of companies ship interoperable webhook signatures because of that work. Hookdeck has processed over &lt;a href="https://hookdeck.com/blog/webhooks-at-scale" rel="noopener noreferrer"&gt;100 billion webhooks&lt;/a&gt;. Convoy pioneered open-source webhook delivery.&lt;/p&gt;

&lt;p&gt;But they've raised venture capital. Svix took &lt;a href="https://www.svix.com/blog/series-a/" rel="noopener noreferrer"&gt;$13M in funding led by a16z&lt;/a&gt;. When your investors expect 10x returns, you optimize for enterprise contracts, not $49/month plans. The pricing reflects the business model, not the cost of delivery.&lt;/p&gt;

&lt;p&gt;Webhook infrastructure is cheap to run. At 50 million events per month — enough to serve 400+ customers — here's what the infrastructure actually costs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers&lt;/td&gt;
&lt;td&gt;~$3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neon PostgreSQL&lt;/td&gt;
&lt;td&gt;~$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upstash Redis&lt;/td&gt;
&lt;td&gt;~$200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Railway&lt;/td&gt;
&lt;td&gt;~$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QStash&lt;/td&gt;
&lt;td&gt;~$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$418&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a 99%+ gross margin at scale. The $490 price tag isn't about infrastructure costs. It's about market positioning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY Trap
&lt;/h2&gt;

&lt;p&gt;So teams try to build their own webhook service from scratch. An HTTP POST with a JSON body. The first version takes a day. The production-ready version takes three months.&lt;/p&gt;

&lt;p&gt;You need HMAC-SHA256 signing with timing-safe comparison — string equality is vulnerable to timing attacks. You need exponential backoff with jitter, not fixed intervals, because thundering herd problems will take down your customer's endpoints and yours. You need per-endpoint circuit breakers that auto-disable after consecutive failures. Idempotency keys so duplicate deliveries don't double-charge someone. A dead-letter queue with manual replay. Multi-tenant isolation that doesn't rely on WHERE clauses alone.&lt;/p&gt;

&lt;p&gt;Figure six weeks for a senior engineer to get it production-ready. That's before the first outage teaches you what you missed. And then you maintain it forever — every infrastructure change is a change to your delivery system, every on-call engineer needs to understand it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;EmitHQ is an open-source webhook platform built for SaaS teams that have outgrown free tiers but can't justify enterprise pricing. It handles outbound delivery to customer endpoints and inbound reception from providers like Stripe and GitHub, starting at $49/month with no throughput add-ons.&lt;/p&gt;

&lt;p&gt;Three paid tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Starter:&lt;/strong&gt; $49/month — 500K events, 50 evt/s, configurable retries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Growth:&lt;/strong&gt; $149/month — 2M events, 200 evt/s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale:&lt;/strong&gt; $349/month — 10M events, 1,000 evt/s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retries don't count against your quota — penalizing retries would punish you for your customers' downtime. Overage on paid tiers is $0.50/K (Starter), $0.40/K (Growth), or $0.30/K (Scale), billed at the end of the month.&lt;/p&gt;

&lt;p&gt;We use the same &lt;a href="https://www.standardwebhooks.com/" rel="noopener noreferrer"&gt;Standard Webhooks spec&lt;/a&gt; that Svix created for outbound signing — the same &lt;code&gt;webhook-id&lt;/code&gt;, &lt;code&gt;webhook-timestamp&lt;/code&gt;, &lt;code&gt;webhook-signature&lt;/code&gt; headers, the same HMAC-SHA256 algorithm. If your customers already verify Standard Webhooks signatures, switching to EmitHQ requires zero changes to their verification code. Migration cost is zero on their side.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;Two architectural decisions define EmitHQ's reliability, and both are visible in the &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ" rel="noopener noreferrer"&gt;source code&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Persist Before Enqueue
&lt;/h3&gt;

&lt;p&gt;Every message is written to PostgreSQL before it enters the Redis delivery queue. If the queue loses a job — Redis restart, memory pressure, network partition — the database still has the message. This is the difference between "we'll try to deliver it" and "we guarantee we recorded it."&lt;/p&gt;

&lt;h3&gt;
  
  
  Full-Jitter Exponential Backoff
&lt;/h3&gt;

&lt;p&gt;Failed deliveries retry with randomized delays: ~30 seconds, then ~2 minutes, then ~15 minutes, scaling up to ~12 hours. Eight attempts over a 29-hour window. The jitter prevents synchronized retries from overwhelming a recovering endpoint. Each endpoint gets its own retry schedule — one failing endpoint doesn't affect the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-Endpoint Circuit Breakers
&lt;/h3&gt;

&lt;p&gt;After 10 consecutive failures, EmitHQ auto-disables the endpoint and sends you an operational webhook (&lt;code&gt;endpoint.disabled&lt;/code&gt;) so you know about it. A half-open probe resumes delivery automatically after a 30-second cooldown. Both thresholds — failure count and cooldown period — are configurable per endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant Isolation
&lt;/h3&gt;

&lt;p&gt;PostgreSQL Row-Level Security enforces isolation at the database level. Every query runs with &lt;code&gt;SET LOCAL app.current_tenant&lt;/code&gt; set by middleware before any data access. Even a bug in application code can't cross tenant boundaries — the database rejects the query before it executes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open Source
&lt;/h2&gt;

&lt;p&gt;EmitHQ's server is &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ" rel="noopener noreferrer"&gt;AGPL-3.0&lt;/a&gt;. The SDKs are MIT. You can read every line of delivery logic, retry calculation, and signing implementation.&lt;/p&gt;

&lt;p&gt;You can self-host it. No phone-home, no license keys, no feature gates. AGPL means if you modify the server and offer it as a service, you share your changes. But running it for your own infrastructure? No restrictions.&lt;/p&gt;

&lt;p&gt;This matters because webhook delivery is a trust decision. You're routing your customers' data through someone else's infrastructure. Knowing exactly how that infrastructure works — and having the option to run it yourself — isn't a feature. It's a prerequisite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Read the delivery worker, the retry logic, the signing implementation. If it holds up, &lt;a href="https://emithq.com" rel="noopener noreferrer"&gt;sign up at emithq.com&lt;/a&gt; — one API call, no credit card, no sales demo. If it doesn't, the &lt;a href="https://github.com/Not-Another-Ai-Co/EmitHQ/issues" rel="noopener noreferrer"&gt;issue tracker&lt;/a&gt; is open.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>opensource</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
