<?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>DEV Community: EventDock</title>
    <description>The latest articles on DEV Community by EventDock (@eventdock).</description>
    <link>https://dev.to/eventdock</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3660548%2Fc81bc6e6-bc07-4db9-af25-2ada184b662b.png</url>
      <title>DEV Community: EventDock</title>
      <link>https://dev.to/eventdock</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eventdock"/>
    <language>en</language>
    <item>
      <title>How to Verify Shopify Webhook Signatures (and the Base64 Raw-Body Trap)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Mon, 20 Jul 2026 16:14:52 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-verify-shopify-webhook-signatures-and-the-base64-raw-body-trap-6nj</link>
      <guid>https://dev.to/eventdock/how-to-verify-shopify-webhook-signatures-and-the-base64-raw-body-trap-6nj</guid>
      <description>&lt;p&gt;Shopify webhook verification fails for two reasons more than any other, and both come from habits that work everywhere else. The signature is base64, not the hex string Stripe and GitHub hand you, and Shopify's own quickstart parses the body with express.json() before you get a chance to hash the bytes it actually signed. Get either wrong and your comparison never matches even though your code looks right. This post shows the exact Shopify webhook check, which secret to use, and the traps that account for most of the failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exact scheme
&lt;/h2&gt;

&lt;p&gt;Per Shopify's docs, each webhook request carries an HMAC in the &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt; header. It is HMAC-SHA256 computed over the &lt;strong&gt;raw request body&lt;/strong&gt;, keyed with your &lt;strong&gt;app's client secret&lt;/strong&gt;, and the result is &lt;strong&gt;base64-encoded&lt;/strong&gt;. Three details decide whether your check works: the raw body, the client secret, and base64.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the signature
&lt;/h2&gt;

&lt;p&gt;Compute the HMAC over the untouched body, base64-encode it, and compare it to the header in constant time.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const crypto = require('crypto');

// Your app's client secret: Partner Dashboard &amp;gt; your app &amp;gt; API credentials
const CLIENT_SECRET = process.env.SHOPIFY_API_SECRET;

function isValidShopifyWebhook(rawBody, hmacHeader) {
  const digest = crypto
    .createHmac('sha256', CLIENT_SECRET)
    .update(rawBody)              // the raw bytes, not a re-serialized object
    .digest('base64');           // base64, NOT hex

  const a = Buffer.from(digest);
  const b = Buffer.from(hmacHeader || '');
  return a.length === b.length &amp;amp;&amp;amp; crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body, verify, then parse
app.post('/webhooks/shopify',
  express.raw({ type: '*/*' }),
  (req, res) =&amp;gt; {
    const rawBody = req.body;     // Buffer, untouched
    const hmac = req.get('X-Shopify-Hmac-Sha256');

    if (!isValidShopifyWebhook(rawBody, hmac)) {
      return res.status(401).send('invalid hmac');
    }

    const payload = JSON.parse(rawBody.toString('utf8'));  // parse only after
    const topic = req.get('X-Shopify-Topic');              // e.g. orders/create
    // ... handle the event
    res.sendStatus(200);
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The comparison uses &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; rather than &lt;code&gt;===&lt;/code&gt;, because a plain string compare returns early on the first differing byte and leaks timing. Guard the length first, since &lt;code&gt;timingSafeEqual&lt;/code&gt; throws when the two buffers differ in length. Shopify expects a fast &lt;code&gt;200&lt;/code&gt; on success and treats a non-2xx as a failed delivery, so respond with &lt;code&gt;401&lt;/code&gt; when the HMAC does not match and keep the handler quick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw-body trap
&lt;/h2&gt;

&lt;p&gt;HMAC signs bytes, not objects. If a JSON body parser runs first and you rebuild a string from the parsed object to verify against, the bytes are no longer identical to what Shopify sent. Whitespace and key order shift and the signature never matches. This one bites Shopify developers especially often because the platform's introductory tutorials wire up &lt;code&gt;express.json()&lt;/code&gt; at the top of the app, which consumes and reparses the body before your webhook route runs. Put the raw capture on the webhook route itself with &lt;code&gt;express.raw()&lt;/code&gt;, verify against the Buffer, and call &lt;code&gt;JSON.parse&lt;/code&gt; only after the check passes. It is the same trap Stripe, Paddle, and most others share, laid out in the &lt;a href="https://eventdock.app/blog/webhook-signature-verification-compared" rel="noopener noreferrer"&gt;webhook signature verification comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Base64, not hex
&lt;/h2&gt;

&lt;p&gt;Most signature guides you have read, including for Stripe and GitHub, end in &lt;code&gt;.digest('hex')&lt;/code&gt;. Shopify does not. It base64-encodes the HMAC, so the header looks like &lt;code&gt;XWJ...=&lt;/code&gt; with mixed case and often a trailing &lt;code&gt;=&lt;/code&gt;, not a long run of lowercase hex. If you copy verification code from a Stripe example and only change the header name and secret, the digest encoding stays hex and every Shopify comparison fails. Change &lt;code&gt;hex&lt;/code&gt; to &lt;code&gt;base64&lt;/code&gt; and the same code starts matching. When you compare, compare like to like: base64 digest against the base64 header, or decode both to bytes first, but do not mix one of each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which secret Shopify signs with
&lt;/h2&gt;

&lt;p&gt;The signing key is your app's client secret, the same value you use in the OAuth flow, found under API credentials in the Partner Dashboard. Reach for the wrong value and every check fails, and there are a few wrong values sitting right next to it. The client secret is not your API access token, and it is not the API key that pairs with it, it is the secret. If you run more than one app, or a development app alongside a production one, each has its own client secret, so a webhook delivered under one app verified with the other app's secret will never match. When verification fails and you are sure the body and the encoding are right, the secret is the next thing to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that account for most failures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Encoding the digest as hex instead of base64.&lt;/li&gt;
&lt;li&gt;Verifying against a re-serialized body instead of the raw bytes, usually because &lt;code&gt;express.json()&lt;/code&gt; ran first.&lt;/li&gt;
&lt;li&gt;Using the wrong secret: the API access token or API key instead of the client secret, or another app's client secret.&lt;/li&gt;
&lt;li&gt;Comparing with &lt;code&gt;===&lt;/code&gt; instead of a constant-time function.&lt;/li&gt;
&lt;li&gt;Testing against one store or app and shipping with a different app's credentials, so the secret no longer matches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Signatures do not cover the events you never receive
&lt;/h2&gt;

&lt;p&gt;A valid HMAC proves a webhook really came from Shopify. It does nothing for the &lt;code&gt;orders/create&lt;/code&gt; or &lt;code&gt;app/uninstalled&lt;/code&gt; event that never arrives because your server was mid-deploy, timed out under load, or threw before it acknowledged. Shopify retries a failed delivery 8 times over about 4 hours, and for a subscription created through the Admin API it deletes the subscription after 8 consecutive failures, so a bad stretch does not just lose events, it can switch the topic off entirely, and the events behind it are orders, refunds, and fulfillments, the ones you least want to miss.&lt;/p&gt;

&lt;p&gt;That is the layer EventDock adds. You point Shopify at EventDock instead of your app. EventDock verifies the signature against the raw body, stores the event, and acknowledges Shopify right away so no timeout or downtime window costs you a delivery, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers instead of counting toward the failure threshold that disables the topic. For the duplicate deliveries any retrying pipeline produces, the &lt;a href="https://eventdock.app/blog/exactly-once-webhook-processing-pattern" rel="noopener noreferrer"&gt;exactly-once processing pattern&lt;/a&gt; keeps your handler idempotent.&lt;/p&gt;

&lt;p&gt;You can point a Shopify test webhook at EventDock on the free tier and watch every event get verified, stored, and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>shopify</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Verify Razorpay Webhook Signatures (and Why It Is Not the Payment Signature)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Thu, 16 Jul 2026 05:32:39 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-verify-razorpay-webhook-signatures-and-why-it-is-not-the-payment-signature-1pei</link>
      <guid>https://dev.to/eventdock/how-to-verify-razorpay-webhook-signatures-and-why-it-is-not-the-payment-signature-1pei</guid>
      <description>&lt;p&gt;Razorpay trips people up in a way most providers do not: it has two separate signatures that use the same algorithm but different keys and different inputs, and they are easy to confuse. One verifies webhooks. The other verifies a checkout payment on your success handler. Sign the wrong thing with the wrong secret and your verification fails while your code looks correct. This post shows the exact Razorpay webhook verification, the raw-body trap, and how the webhook signature differs from the payment signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: Razorpay has two different signatures
&lt;/h2&gt;

&lt;p&gt;Both are HMAC-SHA256 and both end up as a hex string, which is exactly why they get mixed up. But they are not interchangeable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook signature&lt;/strong&gt; arrives in the &lt;code&gt;X-Razorpay-Signature&lt;/code&gt; header on webhook requests. It is keyed with your &lt;strong&gt;webhook secret&lt;/strong&gt; and computed over the &lt;strong&gt;raw request body&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment signature&lt;/strong&gt; comes back as &lt;code&gt;razorpay_signature&lt;/code&gt; in the Checkout success handler, alongside &lt;code&gt;razorpay_order_id&lt;/code&gt; and &lt;code&gt;razorpay_payment_id&lt;/code&gt;. It is keyed with your &lt;strong&gt;API key secret&lt;/strong&gt; and computed over &lt;code&gt;order_id + "|" + payment_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you verify a webhook using the key secret, or verify a payment using the webhook secret, every check fails. Keep them straight and most Razorpay signature pain disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the webhook signature
&lt;/h2&gt;

&lt;p&gt;Per Razorpay's docs, the webhook hash is HMAC-SHA256 with your webhook secret as the key and the raw webhook body as the message, hex-encoded, delivered in &lt;code&gt;X-Razorpay-Signature&lt;/code&gt;.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const crypto = require('crypto');

// Set when you created the webhook in the Razorpay Dashboard
const WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET;

function isValidRazorpayWebhook(rawBody, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)              // the raw bytes, not a re-serialized object
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || '');
  return a.length === b.length &amp;amp;&amp;amp; crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body, verify, then parse
app.post('/webhooks/razorpay',
  express.raw({ type: '*/*' }),
  (req, res) =&amp;gt; {
    const rawBody = req.body;     // Buffer, untouched
    const signature = req.get('X-Razorpay-Signature');

    if (!isValidRazorpayWebhook(rawBody, signature)) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(rawBody.toString('utf8'));  // parse only after
    // ... handle event.event (payment.captured, order.paid, etc.)
    res.json({ status: 'ok' });
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The comparison uses &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; rather than &lt;code&gt;===&lt;/code&gt;, because a plain string compare returns early on the first differing byte and leaks timing. Guard the length first, since &lt;code&gt;timingSafeEqual&lt;/code&gt; throws when the two buffers differ in length.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw-body trap
&lt;/h2&gt;

&lt;p&gt;HMAC signs bytes, not objects. If a JSON body parser runs first and you rebuild a string from the parsed object to verify against, the bytes are no longer identical to what Razorpay sent. Whitespace and key order shift and the signature never matches. Capture the raw, unparsed body, verify against it, and parse only after the check passes. In Express that means &lt;code&gt;express.raw()&lt;/code&gt; on the webhook route rather than &lt;code&gt;express.json()&lt;/code&gt;. This is the same trap Stripe, Paddle, and most others share, laid out in the &lt;a href="https://eventdock.app/blog/webhook-signature-verification-compared" rel="noopener noreferrer"&gt;webhook signature verification comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payment signature is a different check
&lt;/h2&gt;

&lt;p&gt;When Checkout finishes, the handler gives you &lt;code&gt;razorpay_order_id&lt;/code&gt;, &lt;code&gt;razorpay_payment_id&lt;/code&gt;, and &lt;code&gt;razorpay_signature&lt;/code&gt;. This is not a webhook, and it is not verified with the webhook secret. You recompute it with your API key secret over the two IDs joined by a pipe.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Checkout success verification (NOT the webhook path)
const expected = crypto
  .createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)   // API key secret here
  .update(razorpay_order_id + '|' + razorpay_payment_id)   // the two IDs, pipe-joined
  .digest('hex');

const ok = expected === razorpay_signature;  // compare to the checkout field
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Same algorithm, different key, different message. Treat the webhook path and the payment path as two separate verifications and do not share a secret between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that account for most failures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verifying the webhook with the API key secret instead of the per-webhook secret.&lt;/li&gt;
&lt;li&gt;Verifying against a re-serialized body instead of the raw bytes.&lt;/li&gt;
&lt;li&gt;Reusing the payment-signature logic (&lt;code&gt;order_id|payment_id&lt;/code&gt;) to check a webhook, or vice versa.&lt;/li&gt;
&lt;li&gt;Comparing with &lt;code&gt;===&lt;/code&gt; instead of a constant-time function.&lt;/li&gt;
&lt;li&gt;Using a test-mode webhook secret against live traffic, or the reverse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Signatures do not cover the events you never receive
&lt;/h2&gt;

&lt;p&gt;A valid signature proves a webhook really came from Razorpay. It does nothing for the &lt;code&gt;payment.captured&lt;/code&gt; or &lt;code&gt;order.paid&lt;/code&gt; event that never arrives because your server was mid-deploy, timed out under load, or threw before it acknowledged. Razorpay retries failed webhooks for a while, but retries only help if your endpoint comes back before they stop, and the events behind them are payments, refunds, and settlements, the ones you least want to lose.&lt;/p&gt;

&lt;p&gt;That is the layer EventDock adds. You point Razorpay at EventDock instead of your app. EventDock verifies the signature, stores the event, and acknowledges Razorpay right away so no timeout or downtime window costs you a delivery, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers. For the duplicate deliveries any retrying pipeline produces, the &lt;a href="https://eventdock.app/blog/exactly-once-webhook-processing-pattern" rel="noopener noreferrer"&gt;exactly-once processing pattern&lt;/a&gt; keeps your handler idempotent.&lt;/p&gt;

&lt;p&gt;You can point a Razorpay test webhook at EventDock on the free tier and watch every event get verified, stored, and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>razorpay</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Verify Square Webhook Signatures (and the Notification-URL Trap)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 14 Jul 2026 10:55:28 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-verify-square-webhook-signatures-and-the-notification-url-trap-32go</link>
      <guid>https://dev.to/eventdock/how-to-verify-square-webhook-signatures-and-the-notification-url-trap-32go</guid>
      <description>&lt;p&gt;Square's webhook signature catches people in a specific way: the signature is computed over the notification URL joined to the raw request body, not the body on its own. Miss the URL, or reconstruct a URL that differs by even a trailing slash, and every notification fails validation while your HMAC code looks perfectly correct. This post shows the exact scheme Square uses, the two traps that break it, and the older header you should stop trusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Square actually signs
&lt;/h2&gt;

&lt;p&gt;Every webhook from Square carries an &lt;code&gt;x-square-hmacsha256-signature&lt;/code&gt; header. Per Square's own validation docs, the value is an HMAC-SHA-256 built from three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;signature key&lt;/strong&gt; for your webhook subscription (found next to the subscription in the Square Dashboard, one per subscription).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;notification URL&lt;/strong&gt; you configured for that subscription.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;raw body&lt;/strong&gt; of the request, exactly as sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The message you run through HMAC is the notification URL followed by the raw body, concatenated in that order, and the result is Base64-encoded. To validate, you compute the same thing and compare it against the header.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const crypto = require('crypto');

// From your webhook subscription in the Square Dashboard
const SIGNATURE_KEY = process.env.SQUARE_SIGNATURE_KEY;

// The EXACT notification URL you configured for the subscription
const NOTIFICATION_URL = 'https://api.yourapp.com/webhooks/square';

function isValidSquareSignature(rawBody, signatureHeader) {
  const payload = NOTIFICATION_URL + rawBody;   // URL first, then the raw body
  const expected = crypto
    .createHmac('sha256', SIGNATURE_KEY)
    .update(payload, 'utf8')
    .digest('base64');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || '');
  return a.length === b.length &amp;amp;&amp;amp; crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body, verify, then parse
app.post('/webhooks/square',
  express.raw({ type: '*/*' }),
  (req, res) =&amp;gt; {
    const rawBody = req.body.toString('utf8');
    const signature = req.get('x-square-hmacsha256-signature');

    if (!isValidSquareSignature(rawBody, signature)) {
      return res.status(403).send('bad signature');
    }

    const event = JSON.parse(rawBody);   // only parse after the check passes
    // ... handle event
    res.sendStatus(200);
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Notice the comparison uses &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; rather than &lt;code&gt;===&lt;/code&gt;. A plain string compare returns early on the first differing byte, and that timing difference leaks how much of the signature you got right. Use a constant-time compare, and guard the length first because &lt;code&gt;timingSafeEqual&lt;/code&gt; throws on a length mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap one: the raw body
&lt;/h2&gt;

&lt;p&gt;HMAC signs bytes, not objects. If your framework parses the JSON and you rebuild a string from the parsed object to verify against, the bytes are no longer identical to what Square sent. Key order, whitespace, and number formatting all shift, and the signature never matches. Capture the raw, unparsed body, verify against it, and parse only after the check passes. In Express that means &lt;code&gt;express.raw()&lt;/code&gt; on the webhook route, not &lt;code&gt;express.json()&lt;/code&gt;. This is the same trap Stripe, Paddle, HubSpot, and Slack share, laid out side by side in the &lt;a href="https://eventdock.app/blog/webhook-signature-verification-compared" rel="noopener noreferrer"&gt;webhook signature verification comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap two: the notification URL has to match exactly
&lt;/h2&gt;

&lt;p&gt;Because the URL is part of the signed message, the string you concatenate has to be the exact notification URL Square has on file for the subscription, character for character. This is where most Square-specific failures come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reconstructing the URL from the request.&lt;/strong&gt; If you build the URL from &lt;code&gt;req.protocol&lt;/code&gt; and &lt;code&gt;req.headers.host&lt;/code&gt;, a load balancer that terminates TLS can hand your app &lt;code&gt;http&lt;/code&gt; instead of &lt;code&gt;https&lt;/code&gt;, or a different host, and your rebuilt URL stops matching. Do not rebuild it. Use the exact URL you registered, as a constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slash and path differences.&lt;/strong&gt; &lt;code&gt;https://api.yourapp.com/webhooks/square&lt;/code&gt; and the same URL with a trailing slash produce different signatures. Match what is stored in the subscription exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox versus production.&lt;/strong&gt; The two environments have separate subscriptions, separate signature keys, and usually separate notification URLs. Verifying a sandbox event against your production key and URL fails every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clean fix is to treat the notification URL as a known constant per environment rather than something you derive at request time. You configured it, so you already know it. This is the same class of problem Twilio has, where the signature is tied to the request URL and a proxy can break it, covered in the &lt;a href="https://eventdock.app/blog/validate-twilio-webhook-signature" rel="noopener noreferrer"&gt;Twilio signature validation guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop using the legacy x-square-signature header
&lt;/h2&gt;

&lt;p&gt;Older Square integrations validated an &lt;code&gt;x-square-signature&lt;/code&gt; header that used HMAC-SHA1. That scheme is legacy and being retired in favor of the SHA-256 header above. If your code still reads &lt;code&gt;x-square-signature&lt;/code&gt;, move to &lt;code&gt;x-square-hmacsha256-signature&lt;/code&gt;. SHA1 is not a hash you want at the door of your payment events, and Square's current guidance is to use the SHA-256 signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that account for most failures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Signing the body only and forgetting to prepend the notification URL.&lt;/li&gt;
&lt;li&gt;Verifying against a re-serialized body instead of the raw bytes.&lt;/li&gt;
&lt;li&gt;Rebuilding the URL from request headers behind a proxy instead of using the configured constant.&lt;/li&gt;
&lt;li&gt;Using the wrong subscription's signature key, or crossing sandbox and production keys.&lt;/li&gt;
&lt;li&gt;Comparing with &lt;code&gt;===&lt;/code&gt; instead of a constant-time function.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Signatures are only half of it
&lt;/h2&gt;

&lt;p&gt;Validating the signature proves a notification really came from Square. It does nothing for the notification that never arrives, the one that fired while your server was mid-deploy, or the one dropped because your handler threw before it acknowledged. Square retries failed deliveries for a while and then disables the subscription if it keeps failing, so a bad hour on your side can turn into a subscription Square has stopped sending to. The events behind that are the ones tied to money, a payment completed, a refund, a dispute, exactly the ones you cannot afford to lose.&lt;/p&gt;

&lt;p&gt;That is the layer EventDock adds. You point Square at EventDock instead of your app. EventDock verifies the signature, stores the event, and acknowledges Square right away so no timeout or downtime window costs you a delivery, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers instead of being dropped and eventually disabled. For the duplicate deliveries any retrying pipeline produces, the &lt;a href="https://eventdock.app/blog/exactly-once-webhook-processing-pattern" rel="noopener noreferrer"&gt;exactly-once processing pattern&lt;/a&gt; covers how to stay idempotent.&lt;/p&gt;

&lt;p&gt;You can point a Square sandbox subscription at EventDock on the free tier and watch every event get verified, stored, and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>square</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>Hookdeck vs EventDock: Pricing, Features &amp; Real Tests (2026)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:40:09 +0000</pubDate>
      <link>https://dev.to/eventdock/hookdeck-vs-eventdock-pricing-features-real-tests-2026-5cf8</link>
      <guid>https://dev.to/eventdock/hookdeck-vs-eventdock-pricing-features-real-tests-2026-5cf8</guid>
      <description>&lt;p&gt;If you've ever had a webhook silently fail at 2 AM, you know the pain. You need a reliability layer between the provider and your server. The question is: which one?&lt;/p&gt;

&lt;p&gt;Hookdeck and EventDock both solve this problem, but they take very different approaches. This is an honest comparison to help you choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Webhooks Are Fragile
&lt;/h2&gt;

&lt;p&gt;Webhooks are the backbone of modern integrations. Stripe sends payment events, Shopify sends order updates, GitHub sends push notifications. But webhooks have a fundamental flaw: &lt;strong&gt;they assume your server is always available, always fast, and always correct.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers go down for deploys, scaling events, or outages&lt;/li&gt;
&lt;li&gt;Endpoints time out under load&lt;/li&gt;
&lt;li&gt;Bugs cause 500 errors that silently swallow events&lt;/li&gt;
&lt;li&gt;Providers retry with exponential backoff — then give up forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A webhook reliability platform sits between the provider and your server. It accepts webhooks on your behalf, stores them durably, and delivers them with retries, monitoring, and replay.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Hookdeck Does
&lt;/h2&gt;

&lt;p&gt;Hookdeck is a well-established webhook infrastructure platform. Credit where it's due — they've built a comprehensive system with a strong feature set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connections model:&lt;/strong&gt; You create Sources (webhook origins) and Destinations (your endpoints), then wire them together with Connections. This gives you flexible routing — one source can fan out to multiple destinations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transformations:&lt;/strong&gt; JavaScript functions that modify webhook payloads in-flight. Useful for reformatting data between systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtering:&lt;/strong&gt; Rules to drop or route webhooks based on payload content, headers, or other criteria.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI tool:&lt;/strong&gt; Local development tunnel for testing webhooks against your local server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise routing:&lt;/strong&gt; Advanced features for high-volume, multi-tenant webhook architectures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hookdeck is a powerful system. If you need complex webhook orchestration — transforming payloads, routing to multiple services, building event-driven pipelines — it's a solid choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What EventDock Does Differently
&lt;/h2&gt;

&lt;p&gt;EventDock takes a different philosophy: &lt;strong&gt;webhook reliability should be simple, fast, and predictable.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Simpler Mental Model
&lt;/h3&gt;

&lt;p&gt;No connections, sources, or destinations to configure. You create an endpoint, get a URL, point your provider at it, and set your destination. Done. EventDock focuses on doing one thing well: making sure your webhooks arrive reliably.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge-Native Architecture
&lt;/h3&gt;

&lt;p&gt;EventDock runs on Cloudflare Workers — your webhooks are received at the edge location closest to the provider, anywhere in the world. This means &lt;strong&gt;sub-100ms global ingest latency&lt;/strong&gt;. Your provider gets a fast 200 response, and EventDock handles delivery to your server in the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flat, Predictable Pricing
&lt;/h3&gt;

&lt;p&gt;No per-event billing that scales unpredictably. You pick a plan, you know what you pay. No surprises when a provider sends a burst of retry events.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automatic Signature Verification
&lt;/h3&gt;

&lt;p&gt;EventDock verifies webhook signatures from major providers (Stripe, Shopify, GitHub, and more) automatically. Spoofed webhooks are rejected before they ever reach your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dead Letter Queue with One-Click Replay
&lt;/h3&gt;

&lt;p&gt;When webhooks fail after all retries, they go to a dead letter queue. You can inspect the full payload, fix the issue on your end, and replay with a single click from the dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Free Webhook Endpoint Tester
&lt;/h3&gt;

&lt;p&gt;A free tool at &lt;a href="https://eventdock.app/tools/webhook-tester" rel="noopener noreferrer"&gt;eventdock.app/tools/webhook-tester&lt;/a&gt; that lets you inspect incoming webhooks in real time. No signup required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;EventDock&lt;/th&gt;
&lt;th&gt;Hookdeck&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Automatic retries&lt;/td&gt;
&lt;td&gt;Yes (7 attempts, exponential backoff)&lt;/td&gt;
&lt;td&gt;Yes (configurable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead letter queue&lt;/td&gt;
&lt;td&gt;Yes, with one-click replay&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signature verification&lt;/td&gt;
&lt;td&gt;Automatic for major providers&lt;/td&gt;
&lt;td&gt;Manual configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency / deduplication&lt;/td&gt;
&lt;td&gt;Built-in (24h window)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time dashboard&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload transformations&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fan-out routing&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Connections model)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtering rules&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI / local tunnel&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge-native (global PoPs)&lt;/td&gt;
&lt;td&gt;Yes (Cloudflare Workers)&lt;/td&gt;
&lt;td&gt;No (centralized)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;~2 minutes&lt;/td&gt;
&lt;td&gt;~10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alerts (Slack, email)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Pricing Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;EventDock&lt;/th&gt;
&lt;th&gt;Hookdeck&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;5,000 events/month, 3 endpoints&lt;/td&gt;
&lt;td&gt;100,000 events/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry tier&lt;/td&gt;
&lt;td&gt;\$29/mo — 20,000 events, 10 endpoints&lt;/td&gt;
&lt;td&gt;Growth: \$0.20 per 1,000 events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mid tier&lt;/td&gt;
&lt;td&gt;\$149/mo — 50,000 events, 25 endpoints&lt;/td&gt;
&lt;td&gt;Growth: \$0.20 per 1,000 events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Higher tier&lt;/td&gt;
&lt;td&gt;\$499/mo — 250,000 events, 50 endpoints&lt;/td&gt;
&lt;td&gt;Enterprise: custom pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing model&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Flat monthly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Per-event (usage-based)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The pricing tradeoff:&lt;/strong&gt; Hookdeck's free tier is more generous (100k vs 5k events). But once you're paying, EventDock's flat pricing is more predictable. At 50,000 events/month, Hookdeck's Growth plan costs roughly \$10/month — cheaper than EventDock's \$149. But EventDock includes a fixed feature set with no per-event overages, and the cost stays flat even if you get a burst of retries or replays.&lt;/p&gt;

&lt;p&gt;For teams that value cost predictability over raw per-event pricing, EventDock's model is simpler to budget for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start: EventDock in 2 Minutes
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. Sign up at dashboard.eventdock.app (free, no credit card)
&lt;h1&gt;
  
  
  2. Create an endpoint — you get a URL like:
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://in.eventdock.app/ep_abc123" rel="noopener noreferrer"&gt;https://in.eventdock.app/ep_abc123&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;
  
  
  3. Set your destination (where webhooks should be delivered):
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://api.yourapp.com/webhooks/stripe" rel="noopener noreferrer"&gt;https://api.yourapp.com/webhooks/stripe&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;
  
  
  4. Point Stripe (or any provider) at your EventDock URL:
&lt;/h1&gt;
&lt;h1&gt;
  
  
  Stripe Dashboard → Webhooks → Add endpoint
&lt;/h1&gt;
&lt;h1&gt;
  
  
  URL: &lt;a href="https://in.eventdock.app/ep_abc123" rel="noopener noreferrer"&gt;https://in.eventdock.app/ep_abc123&lt;/a&gt;
&lt;/h1&gt;
&lt;h1&gt;
  
  
  That's it. EventDock handles retries, DLQ, and monitoring.
&lt;/h1&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  When NOT to Use EventDock&lt;br&gt;
&lt;/h2&gt;

&lt;p&gt;Honesty builds trust, so here's when EventDock is &lt;em&gt;not&lt;/em&gt; the right choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need payload transformations.&lt;/strong&gt; If you need to rewrite webhook payloads before they reach your server (e.g., converting between data formats), Hookdeck's transformation feature is genuinely useful. EventDock delivers payloads as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need complex routing.&lt;/strong&gt; If a single webhook source needs to fan out to 5 different services with different filtering rules, Hookdeck's Connections model is designed for that. EventDock is one-to-one: one endpoint, one destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You process 100k+ events/month and want the cheapest per-event price.&lt;/strong&gt; Hookdeck's usage-based pricing can be cheaper at scale if you don't value flat pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're building an event mesh or complex event-driven architecture.&lt;/strong&gt; Hookdeck is positioned more as event infrastructure. EventDock is positioned as a reliability layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Hookdeck&lt;/strong&gt; if you need webhook orchestration — transformations, routing, filtering, and enterprise-grade event infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose EventDock&lt;/strong&gt; if you need straightforward webhook reliability — retries, DLQ, monitoring, and signature verification — with simple setup, edge-native performance, and predictable pricing.&lt;/p&gt;

&lt;p&gt;Both are solid products. The right choice depends on whether you need a webhook &lt;em&gt;platform&lt;/em&gt; or a webhook &lt;em&gt;reliability layer&lt;/em&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Try EventDock Free
&lt;/h3&gt;

&lt;p&gt;5,000 events/month, 3 endpoints, no credit card required. Set up in 2 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dashboard.eventdock.app/login" rel="noopener noreferrer"&gt;Start Free&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>devtools</category>
      <category>saas</category>
    </item>
    <item>
      <title>The Webhook Security Checklist to Run Before You Go Live</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:39:04 +0000</pubDate>
      <link>https://dev.to/eventdock/the-webhook-security-checklist-to-run-before-you-go-live-568e</link>
      <guid>https://dev.to/eventdock/the-webhook-security-checklist-to-run-before-you-go-live-568e</guid>
      <description>&lt;p&gt;Most webhook security bugs are not exotic. They are the same handful of mistakes, shipped by teams who verified a signature once, saw it pass, and moved on. This is the checklist to run before a webhook endpoint goes to production. Each item is something an attacker or a bad network day will find if you skip it, and every one of them has burned a real integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Verify the signature against the raw body
&lt;/h2&gt;

&lt;p&gt;This is the single most common webhook bug across every provider. The signature is an HMAC computed over the exact bytes the provider sent. If your framework parses the JSON and you rebuild a string from the parsed object to check against, the bytes are no longer identical. Key order shifts, whitespace collapses, numbers get reformatted, and a perfectly correct HMAC never matches.&lt;/p&gt;

&lt;p&gt;Capture the raw, unparsed body, verify against it, and parse only after the check passes. The mechanics differ per framework: Express needs &lt;code&gt;express.raw()&lt;/code&gt;, Next.js route handlers use &lt;code&gt;await req.text()&lt;/code&gt;, Cloudflare Workers use &lt;code&gt;await request.text()&lt;/code&gt;. The rule holds for Stripe, Paddle, HubSpot, and Slack alike. If you want the per-provider details side by side, see the &lt;a href="https://eventdock.app/blog/webhook-signature-verification-compared" rel="noopener noreferrer"&gt;webhook signature verification comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Compare signatures in constant time
&lt;/h2&gt;

&lt;p&gt;Once you have computed the expected signature, do not compare it with &lt;code&gt;===&lt;/code&gt; or &lt;code&gt;==&lt;/code&gt;. A normal string comparison returns as soon as it hits the first differing character, and the tiny timing difference leaks how many leading bytes were correct. Given enough attempts, that is enough to forge a valid signature one byte at a time.&lt;/p&gt;

&lt;p&gt;Use a constant-time comparison: &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; in Node, &lt;code&gt;hmac.compare_digest&lt;/code&gt; in Python, &lt;code&gt;hash_equals&lt;/code&gt; in PHP. They take the same amount of time whether the first byte differs or the last one does. Make sure both buffers are the same length first, because &lt;code&gt;timingSafeEqual&lt;/code&gt; throws on a length mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Enforce a timestamp and a replay window
&lt;/h2&gt;

&lt;p&gt;A valid signature proves the request came from the provider. It does not prove the request is fresh. If an attacker captures one signed request, nothing stops them from replaying it a thousand times unless you check the timestamp.&lt;/p&gt;

&lt;p&gt;Most providers fold a timestamp into the signed string exactly so you can reject stale requests. Stripe, HubSpot, and Slack all sign a timestamp and recommend rejecting anything older than five minutes. Read the timestamp, confirm it is within your tolerance of now, and reject it if it is not. This is also why you sign the timestamp plus the body rather than the body alone. Twilio is the exception here: it signs the request URL rather than a timestamp, so its replay story is different and worth understanding on its own in the &lt;a href="https://eventdock.app/blog/validate-twilio-webhook-signature" rel="noopener noreferrer"&gt;Twilio validation guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Fail closed, and log what failed
&lt;/h2&gt;

&lt;p&gt;When verification fails, return a 401 or 403 and drop the request. Do not process it "just in case." And do not fall back to processing unsigned requests because signature checking was "acting up" in staging. A webhook endpoint that accepts unsigned requests is an open door with a lock bolted to the wall next to it.&lt;/p&gt;

&lt;p&gt;Log enough to debug a real failure without leaking secrets: the header that arrived, the timestamp, and whether the failure was a bad signature versus a stale timestamp versus a missing header. Never log the signing secret or the computed HMAC. When a provider rotates a secret or a proxy mangles a header, these logs are the difference between a five-minute fix and an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Handle signing secrets like the credentials they are
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One secret per endpoint.&lt;/strong&gt; Stripe issues a distinct &lt;code&gt;whsec_&lt;/code&gt; per endpoint. Using the wrong one is a silent, total verification failure. Map each endpoint to its own secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out of source, in the environment.&lt;/strong&gt; Signing secrets belong in your secret manager or environment, not committed to the repo. A leaked signing secret lets anyone forge requests you will happily trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotatable.&lt;/strong&gt; Build for rotation before you need it. Support two valid secrets at once for a short window so you can roll a secret without dropping live traffic, then retire the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Make your handler idempotent
&lt;/h2&gt;

&lt;p&gt;Providers retry. A delivery that times out, or one where your app returns a 500, will be sent again, and sometimes a provider sends the same event twice even on success. If your handler charges a card, sends an email, or increments a counter without checking whether it already processed that event, retries turn into duplicate side effects.&lt;/p&gt;

&lt;p&gt;Every provider gives you a stable event ID. Record the IDs you have processed and make the second delivery of the same ID a no-op. This is the difference between a retry being a safety net and a retry being a second charge. The &lt;a href="https://eventdock.app/blog/exactly-once-webhook-processing-pattern" rel="noopener noreferrer"&gt;exactly-once processing pattern&lt;/a&gt; covers how to do this without a race.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. HTTPS only, and validate the payload shape
&lt;/h2&gt;

&lt;p&gt;Serve the webhook endpoint over HTTPS so the signed request is not readable or modifiable in transit. Then treat the verified payload as input you still have to validate. A signature proves the provider sent it. It does not prove the event type is one you handle, that the fields you need are present, or that an amount is within a sane range. Check the event type against an allowlist and validate the fields you read before acting on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The item a security review usually forgets: the events you never receive
&lt;/h2&gt;

&lt;p&gt;Every item above hardens the requests that arrive. None of them help with the request that never gets to you. The webhook that fired while your server was mid-deploy, the burst that timed out during a traffic spike, the event dropped because your handler threw before it acknowledged. Provider retry behavior is uneven: Stripe and Shopify retry for days, Slack retries a few times and can then disable your endpoint, Twilio barely retries at all. The events tied to money and to state are exactly the ones you can least afford to silently lose, and a signature check does nothing to catch a delivery that never happened.&lt;/p&gt;

&lt;p&gt;That is the layer EventDock adds under the same checklist. You point any provider at EventDock instead of your app. EventDock verifies the signature against the raw body, stores the event, and acknowledges the provider immediately so no timeout window matters, then delivers to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers instead of vanishing. Signature verification and delivery reliability are two halves of the same problem, and a webhook is only as secure as it is durable.&lt;/p&gt;

&lt;p&gt;You can wire up any provider on the free tier and watch every event get verified, stored, and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>security</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>How to Verify Slack Request Signatures (and Not Get Your Endpoint Disabled)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:37:59 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-verify-slack-request-signatures-and-not-get-your-endpoint-disabled-4jp2</link>
      <guid>https://dev.to/eventdock/how-to-verify-slack-request-signatures-and-not-get-your-endpoint-disabled-4jp2</guid>
      <description>&lt;p&gt;Slack signs every request it sends your app with a scheme it calls v0. The verification itself is a few lines, but two details trip people up more than anything else: you have to hash the raw request body (not a re-encoded copy of it), and the timestamp is part of the signed string. Get either wrong and every request looks forged. This post shows the exact v0 verification, the raw-body trap, and why a failed check can quietly get your endpoint disabled.&lt;/p&gt;

&lt;p&gt;For how this compares to the other major providers, see &lt;a href="https://eventdock.app/blog/webhook-signature-verification-compared" rel="noopener noreferrer"&gt;webhook signature verification compared across Stripe, Paddle, HubSpot, Slack, and Twilio&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two headers Slack sends
&lt;/h2&gt;

&lt;p&gt;Every request from Slack carries two headers you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;X-Slack-Signature&lt;/code&gt;: the signature, always prefixed with &lt;code&gt;v0=&lt;/code&gt; followed by a hex digest.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-Slack-Request-Timestamp&lt;/code&gt;: a Unix timestamp (seconds) for when Slack sent the request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signing secret you verify against is not a bot token. You find it in your app config under Basic Information, and it looks like a 32-character hex string. One signing secret per Slack app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Slack actually signs
&lt;/h2&gt;

&lt;p&gt;Slack builds one string and signs it:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v0:{timestamp}:{raw_request_body}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Three parts joined by colons: the literal &lt;code&gt;v0&lt;/code&gt;, the value from the &lt;code&gt;X-Slack-Request-Timestamp&lt;/code&gt; header, and the exact raw body of the request. It then computes an HMAC-SHA256 of that string using your signing secret as the key, hex-encodes it, and prefixes &lt;code&gt;v0=&lt;/code&gt;. That final value is what arrives in &lt;code&gt;X-Slack-Signature&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The part people miss: the timestamp is inside the signed string. If you hash only the body, or only the body plus secret, you will never match Slack. The timestamp being signed is also what lets you reject replays, because an attacker cannot swap in a fresh timestamp without breaking the signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw-body trap
&lt;/h2&gt;

&lt;p&gt;HMAC signs bytes. Slack computed its digest over the exact bytes it put on the wire. Slash commands arrive as &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; and event and interaction payloads arrive as JSON, but in both cases you must verify against the untouched body.&lt;/p&gt;

&lt;p&gt;Here is how the bytes get changed without you noticing. Your web framework reads the body and parses it into an object for you. You then rebuild a string from that object to pass to your verify function. That rebuilt string is not byte-identical to what Slack sent, because form encoding, key order, and escaping can all shift. The HMAC no longer matches and you reject every real request. Verify against the raw body first, parse it only after the signature passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying a v0 signature in Node.js
&lt;/h2&gt;

&lt;p&gt;This reads the two headers, rebuilds the signed string, computes the HMAC, compares in constant time, and rejects anything older than five minutes.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import crypto from 'crypto';

function verifySlackRequest(
  rawBody,          // the exact bytes Slack sent, NOT a re-encoded object
  signatureHeader,  // X-Slack-Signature, e.g. "v0=a2b1c3..."
  timestamp,        // X-Slack-Request-Timestamp
  signingSecret     // from Basic Information -&amp;gt; App Credentials
) {
  // Reject replays. The timestamp is signed, so it cannot be forged fresh.
  const fiveMinutes = 60 * 5;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) &amp;gt; fiveMinutes) {
    return false;
  }

  const base = 'v0:' + timestamp + ':' + rawBody;
  const expected = 'v0=' + crypto
    .createHmac('sha256', signingSecret)
    .update(base, 'utf8')
    .digest('hex');

  // Constant-time compare to avoid leaking the digest through timing.
  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  return a.length === b.length &amp;amp;&amp;amp; crypto.timingSafeEqual(a, b);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;There is no &lt;code&gt;JSON.parse&lt;/code&gt; or form parser in that function. Parsing happens in your handler, after the check returns true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the raw body in Express and Next.js
&lt;/h2&gt;

&lt;p&gt;The verify function is the easy part. Handing it the untouched body is where frameworks fight you, because they parse the body by default.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Express: raw parser on the Slack route so req.body stays a Buffer
app.post('/slack/events',
  express.raw({ type: '*/*' }),   // Slack sends both urlencoded and JSON
  (req, res) =&amp;gt; {
    const sig = req.get('X-Slack-Signature');
    const ts = req.get('X-Slack-Request-Timestamp');
    if (!verifySlackRequest(req.body.toString('utf8'), sig, ts, process.env.SLACK_SIGNING_SECRET)) {
      return res.status(401).send('bad signature');
    }
    res.status(200).send('');       // ack within 3 seconds
    handleSlackEvent(req.body.toString('utf8')).catch(console.error);
  }
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;In a Next.js route handler, read the body once with &lt;code&gt;await req.text()&lt;/code&gt; and do not call &lt;code&gt;req.json()&lt;/code&gt; or &lt;code&gt;req.formData()&lt;/code&gt; first, because the stream can only be read once. In Cloudflare Workers, &lt;code&gt;await request.text()&lt;/code&gt; gives you the raw bytes and the Web Crypto API does the HMAC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a failed check is worse than a rejected request
&lt;/h2&gt;

&lt;p&gt;Slack expects a 2xx response within 3 seconds. For the Events API, if your endpoint is slow, errors, or rejects a request Slack thinks is valid, Slack retries the delivery up to three times: nearly immediately, then after 1 minute, then after 5 minutes. And if your failure rate climbs past 95 percent of delivery attempts inside a 60-minute window, Slack temporarily disables event delivery to your app, so you stop receiving events entirely until you notice and re-enable it. A bad deploy or a database timeout during a busy window can push you there, and a missed &lt;code&gt;app_mention&lt;/code&gt; or interaction is a user action your app silently ignored.&lt;/p&gt;

&lt;p&gt;Signature verification protects you from forged requests. It does nothing for the requests you drop because your handler was mid-deploy, or the ones Slack stops sending after it decides your endpoint is unhealthy. That is the gap EventDock fills. You point Slack at EventDock instead of your app. EventDock verifies the request, stores it, and acknowledges Slack right away so the 3-second window is never the thing that fails, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait in the queue and arrive once it recovers.&lt;/p&gt;

&lt;p&gt;You can wire it up on the free tier and watch every Slack event get captured and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free&lt;/a&gt;, or read how the &lt;a href="https://eventdock.app/blog/exactly-once-webhook-processing-pattern" rel="noopener noreferrer"&gt;exactly-once processing pattern&lt;/a&gt; handles the duplicate deliveries that Slack, like every retrying sender, will eventually send you.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>slack</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Validate Twilio Webhook Signatures (and the Proxy Trap That Breaks Them)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:36:55 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-validate-twilio-webhook-signatures-and-the-proxy-trap-that-breaks-them-3jpl</link>
      <guid>https://dev.to/eventdock/how-to-validate-twilio-webhook-signatures-and-the-proxy-trap-that-breaks-them-3jpl</guid>
      <description>&lt;p&gt;Twilio's webhook signature is different from most, and it catches people in a specific way: the signature is computed over the full request URL, not just the body. So the moment your app sits behind a load balancer or a proxy that rewrites the scheme or host, the URL your server reconstructs stops matching the URL Twilio signed, and every request fails validation even though nothing is wrong. This post shows the exact scheme, the URL-reconstruction trap, and why losing a Twilio status callback is usually permanent.&lt;/p&gt;

&lt;p&gt;For how this compares to the other major providers, see &lt;a href="https://eventdock.app/blog/webhook-signature-verification-compared" rel="noopener noreferrer"&gt;webhook signature verification compared across Stripe, Paddle, HubSpot, Slack, and Twilio&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Twilio signs
&lt;/h2&gt;

&lt;p&gt;Twilio sends an &lt;code&gt;X-Twilio-Signature&lt;/code&gt; header with every request to your webhook. It is built like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Start with the full URL Twilio requested, exactly as you configured it, including the scheme, host, path, and any query string.&lt;/li&gt;
&lt;li&gt; If the request is a POST with form-encoded parameters, take those POST params, sort them alphabetically by key, and append each one to the URL string as the key immediately followed by its value, with no separators.&lt;/li&gt;
&lt;li&gt; Compute an HMAC-SHA1 of that combined string, keyed with your account &lt;strong&gt;Auth Token&lt;/strong&gt; (not an API key).&lt;/li&gt;
&lt;li&gt; Base64-encode the result. That is what lands in &lt;code&gt;X-Twilio-Signature&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To validate, you rebuild the same string, compute your own HMAC-SHA1, and compare. The key is the Auth Token from your Twilio Console. Two things about this scheme surprise people: the URL is part of the signed material, and the POST parameters get concatenated onto it in sorted order rather than being hashed as a body.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL-reconstruction trap
&lt;/h2&gt;

&lt;p&gt;Because the URL is signed, your server has to reconstruct the exact URL Twilio used. That is harder than it sounds in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scheme.&lt;/strong&gt; You configured an &lt;code&gt;https://&lt;/code&gt; webhook, but your app sits behind a load balancer that terminates TLS and forwards plain &lt;code&gt;http://&lt;/code&gt; internally. Your framework sees &lt;code&gt;http&lt;/code&gt;, rebuilds an &lt;code&gt;http://&lt;/code&gt; URL, and the signature no longer matches. Use the &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; header to recover the original scheme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host and port.&lt;/strong&gt; A proxy can change the host your app sees. The signed URL used the public host Twilio called, so you have to reconstruct that one, not the internal one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query string.&lt;/strong&gt; If you configured the webhook with query parameters, they are part of the signed URL and must be present, in the same order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the single most common reason Twilio validation fails in production and passes on localhost. On localhost there is no proxy, so the reconstructed URL matches. Ship it behind a load balancer and it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating in Node.js
&lt;/h2&gt;

&lt;p&gt;Twilio ships an official validator in its SDK, and you should use it rather than hand-rolling the HMAC. The important part is feeding it the correct URL and the raw POST params.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import twilio from 'twilio';

app.post('/webhooks/twilio',
  express.urlencoded({ extended: false }),   // Twilio posts form-encoded params
  (req, res) =&amp;gt; {
    const signature = req.get('X-Twilio-Signature');

    // Rebuild the EXACT public URL Twilio called. Honor the proxy's forwarded proto/host.
    const proto = req.get('X-Forwarded-Proto') || req.protocol;
    const host  = req.get('X-Forwarded-Host')  || req.get('Host');
    const url   = proto + '://' + host + req.originalUrl;

    const valid = twilio.validateRequest(
      process.env.TWILIO_AUTH_TOKEN,   // the signing key
      signature,
      url,
      req.body                         // the parsed POST params, as an object
    );

    if (!valid) return res.status(403).send('bad signature');

    res.type('text/xml').send('&amp;lt;Response/&amp;gt;');   // ack fast
    handleTwilioEvent(req.body).catch(console.error);
  }
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;If you validate by hand instead, the rule is the one above: URL, then each POST param appended as sorted &lt;code&gt;key + value&lt;/code&gt;, HMAC-SHA1 with the Auth Token, Base64. A JSON body uses a different mechanism (Twilio adds a &lt;code&gt;bodySHA256&lt;/code&gt; query parameter), so stick to the SDK if you accept JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust the right forwarded headers
&lt;/h2&gt;

&lt;p&gt;Reading &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and &lt;code&gt;X-Forwarded-Host&lt;/code&gt; only works if a proxy you trust actually sets them. In Express, set &lt;code&gt;app.set('trust proxy', true)&lt;/code&gt; so &lt;code&gt;req.protocol&lt;/code&gt; reflects the forwarded scheme, and make sure your load balancer is configured to pass the original host through. If you do not control the proxy headers, the safest fix is to hardcode the exact public URL you configured in Twilio and validate against that constant, since you already know what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dropped Twilio callback is usually gone for good
&lt;/h2&gt;

&lt;p&gt;Signature validation confirms a request really came from Twilio. It does nothing for the callbacks you never receive. A message-status or call-status callback tells you a text was delivered or a call completed, and Twilio's retry behavior for these is limited, so you should not count on a retry to cover a callback you dropped. If your endpoint is mid-deploy or times out when that callback fires, your records can end up saying a message is still "sending" that actually delivered, or you miss a failed delivery you needed to react to.&lt;/p&gt;

&lt;p&gt;This is where a reliability layer earns its place. You point Twilio at EventDock instead of your app. EventDock validates the request, stores it, and acknowledges Twilio right away inside the timeout, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the callbacks wait in the queue and arrive once it recovers, instead of vanishing.&lt;/p&gt;

&lt;p&gt;You can wire it up on the free tier and watch every Twilio callback get captured and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free&lt;/a&gt;, or read how the &lt;a href="https://eventdock.app/blog/exactly-once-webhook-processing-pattern" rel="noopener noreferrer"&gt;exactly-once processing pattern&lt;/a&gt; handles the duplicate deliveries that any retrying pipeline will eventually produce.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>twilio</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>Webhook Signature Verification Compared: Stripe, Paddle, HubSpot, Slack, Twilio</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:58:19 +0000</pubDate>
      <link>https://dev.to/eventdock/webhook-signature-verification-compared-stripe-paddle-hubspot-slack-twilio-4jfk</link>
      <guid>https://dev.to/eventdock/webhook-signature-verification-compared-stripe-paddle-hubspot-slack-twilio-4jfk</guid>
      <description>&lt;p&gt;Every webhook provider signs its requests differently, and the differences are exactly the kind that cost you an afternoon. Some sign the raw body, one signs the URL, most fold in a timestamp for replay protection and one does not. This is a side-by-side comparison of how Stripe, Paddle, HubSpot, Slack, and Twilio sign webhooks, what each one gets wrong most often, and the one trap they nearly all share.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;What it signs&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;th&gt;Replay protection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;Stripe-Signature&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;timestamp . payload&lt;/td&gt;
&lt;td&gt;hex&lt;/td&gt;
&lt;td&gt;Yes (5-min tolerance)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paddle (Billing)&lt;/td&gt;
&lt;td&gt;Paddle-Signature&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;ts : rawBody&lt;/td&gt;
&lt;td&gt;hex&lt;/td&gt;
&lt;td&gt;Yes (timestamp signed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot (v3)&lt;/td&gt;
&lt;td&gt;X-HubSpot-Signature-v3&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;method + uri + body + timestamp&lt;/td&gt;
&lt;td&gt;Base64&lt;/td&gt;
&lt;td&gt;Yes (5-min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;X-Slack-Signature&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;v0 : timestamp : body&lt;/td&gt;
&lt;td&gt;hex (with v0= prefix)&lt;/td&gt;
&lt;td&gt;Yes (5-min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;X-Twilio-Signature&lt;/td&gt;
&lt;td&gt;HMAC-SHA1&lt;/td&gt;
&lt;td&gt;full URL + sorted POST params&lt;/td&gt;
&lt;td&gt;Base64&lt;/td&gt;
&lt;td&gt;No (URL-based)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out. Twilio is the odd one: it uses SHA1 instead of SHA256, and it signs the request URL rather than the body, which changes the whole failure mode. And four of the five fold a timestamp into the signed string, which is what lets you reject replayed requests. Twilio does not, so its signature is tied to the URL instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap almost all of them share: the raw body
&lt;/h2&gt;

&lt;p&gt;For Stripe, Paddle, HubSpot, and Slack, the signature is computed over the exact bytes of the request body. HMAC signs bytes, not objects. If your web framework parses the JSON and you rebuild a string from the parsed object to verify against, those bytes are not identical to what the provider sent. Key order shifts, whitespace disappears, numbers get reformatted, and your signature never matches. Every one of these four breaks the same way if you verify against a re-serialized body instead of the raw one.&lt;/p&gt;

&lt;p&gt;The fix is always the same: capture the raw, unparsed body, verify the signature against it, and only parse it after the check passes. The mechanics of getting the raw body differ per framework (Express needs &lt;code&gt;express.raw()&lt;/code&gt;, Next.js route handlers use &lt;code&gt;await req.text()&lt;/code&gt;, Cloudflare Workers use &lt;code&gt;await request.text()&lt;/code&gt;), but the rule holds across all four providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each one gets wrong most often
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; signs &lt;code&gt;{timestamp}.{payload}&lt;/code&gt; with your endpoint's signing secret (the &lt;code&gt;whsec_&lt;/code&gt; value, one per endpoint). The common miss is using the wrong endpoint's secret, or verifying against a re-serialized body. &lt;a href="https://eventdock.app/blog/why-stripe-webhooks-failing-how-to-fix" rel="noopener noreferrer"&gt;Deep dive on Stripe webhook failures.&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle Billing&lt;/strong&gt; signs &lt;code&gt;ts:rawBody&lt;/code&gt;, not the body alone. Miss the &lt;code&gt;ts:&lt;/code&gt; prefix and every signature is wrong even with correct HMAC code. Also, Paddle Classic uses a completely different RSA scheme, so first confirm which product you are on. &lt;a href="https://eventdock.app/blog/verify-paddle-webhook-signature" rel="noopener noreferrer"&gt;Paddle signature guide.&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HubSpot&lt;/strong&gt; has three signature versions with similar header names. Use v3, and note it signs the full URL (normalized) plus the body plus the timestamp, and it is Base64 not hex. &lt;a href="https://eventdock.app/blog/verify-hubspot-webhook-signature" rel="noopener noreferrer"&gt;HubSpot v1/v2/v3 guide.&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt; signs &lt;code&gt;v0:{timestamp}:{body}&lt;/code&gt; with your signing secret and prefixes the result with &lt;code&gt;v0=&lt;/code&gt;. A failed check can eventually get Slack to disable event delivery to your app, so it is worth getting right. &lt;a href="https://eventdock.app/blog/verify-slack-request-signature" rel="noopener noreferrer"&gt;Slack v0 guide.&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twilio&lt;/strong&gt; signs the full request URL, so the classic failure is a proxy or load balancer rewriting the scheme or host, making your reconstructed URL differ from the one Twilio signed. Recover the original with &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and the public host. &lt;a href="https://eventdock.app/blog/validate-twilio-webhook-signature" rel="noopener noreferrer"&gt;Twilio validation guide.&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Signatures are the easy half
&lt;/h2&gt;

&lt;p&gt;Every scheme above answers the same question: is this request really from the provider? None of them answer the harder one: what about the webhooks you never receive? A verified signature does nothing for the event that arrived while your server was mid-deploy, or the burst that timed out during a traffic spike. Stripe and Shopify retry for days, Slack retries a few times then can disable your endpoint, and Twilio barely retries at all. The behavior you can rely on is uneven, so the events that matter, the ones tied to money and to state, are the ones you can least afford to drop.&lt;/p&gt;

&lt;p&gt;That is the layer EventDock adds. You point any of these providers at EventDock instead of your app. EventDock verifies the signature, stores the event, acknowledges the provider immediately so no timeout window matters, then delivers to your app with its own retries and a dead-letter queue you can replay by hand. One reliability layer, the same behavior across every provider, whether the provider itself retries generously or not at all.&lt;/p&gt;

&lt;p&gt;You can wire up any provider on the free tier and watch every event get captured and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Verify HubSpot Webhook Signatures (v1, v2, and the v3 You Should Actually Use)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Wed, 08 Jul 2026 11:14:13 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-verify-hubspot-webhook-signatures-v1-v2-and-the-v3-you-should-actually-use-5e2p</link>
      <guid>https://dev.to/eventdock/how-to-verify-hubspot-webhook-signatures-v1-v2-and-the-v3-you-should-actually-use-5e2p</guid>
      <description>&lt;p&gt;HubSpot has shipped three different webhook signature schemes over the years, and they all use similar header names. Most of the broken HubSpot webhook validation I have seen comes from implementing v1 when HubSpot is sending v3, or hashing the wrong string. This post shows the three versions, the exact v3 verification HubSpot recommends today, and the raw-body and URL mistakes that make every request look invalid.&lt;/p&gt;

&lt;p&gt;If you just want the short version: use v3, hash the method plus the full URI plus the raw body plus the timestamp, and reject anything older than five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three signature versions, and which one you are getting
&lt;/h2&gt;

&lt;p&gt;HubSpot did not replace its old signatures when it added new ones. An app can receive any of them depending on how the webhook was configured and how old the app is. The three schemes are not interchangeable, so the first job is knowing which one HubSpot is sending you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v1&lt;/strong&gt; (&lt;code&gt;X-HubSpot-Signature&lt;/code&gt;, no version header): a SHA-256 hash of your app client secret concatenated with the raw request body. No timestamp, so no replay protection. This is the oldest and weakest scheme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v2&lt;/strong&gt; (&lt;code&gt;X-HubSpot-Signature&lt;/code&gt; with &lt;code&gt;X-HubSpot-Signature-Version: v2&lt;/code&gt;): a SHA-256 hash of the client secret plus the HTTP method plus the full URI plus the raw body. Still no timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v3&lt;/strong&gt; (&lt;code&gt;X-HubSpot-Signature-v3&lt;/code&gt; plus &lt;code&gt;X-HubSpot-Request-Timestamp&lt;/code&gt;): an HMAC-SHA256, keyed with the client secret, over the HTTP method plus the URI plus the raw body plus the timestamp, then Base64 encoded. The timestamp is signed, which is what gives you replay protection. This is the one HubSpot recommends now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Look at the headers on an actual delivery. If you see &lt;code&gt;X-HubSpot-Signature-v3&lt;/code&gt;, you are on v3 and everything below applies. If you only see &lt;code&gt;X-HubSpot-Signature&lt;/code&gt;, check the version header to tell v1 from v2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What v3 actually signs
&lt;/h2&gt;

&lt;p&gt;The v3 signature is computed over one concatenated string, in this exact order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP_METHOD + FULL_URI + RAW_REQUEST_BODY + TIMESTAMP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things people get wrong here, in order of how often I see them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The raw body.&lt;/strong&gt; HMAC signs bytes. If your framework parsed the JSON and you re-serialized it with &lt;code&gt;JSON.stringify&lt;/code&gt;, the bytes changed and the signature will never match. Verify against the untouched body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full URI, normalized.&lt;/strong&gt; This means the complete URL HubSpot called, including the &lt;code&gt;https://&lt;/code&gt; scheme, the host, the path, and any query string. Two extra rules for v3 that bite people: HubSpot URL-decodes a specific set of characters in the URI before signing, and it drops any fragment. So the string you sign is the normalized URI, not the raw one off the wire. Match HubSpot's normalization or the hash will differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timestamp.&lt;/strong&gt; It comes from the &lt;code&gt;X-HubSpot-Request-Timestamp&lt;/code&gt; header and it is part of the signed string. Leave it out and every signature is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base64, not hex.&lt;/strong&gt; v3 is Base64 encoded. v1 and v2 are hex. If you compare a hex digest to the Base64 header, it fails even when everything else is right.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Verifying a v3 signature in Node.js
&lt;/h2&gt;

&lt;p&gt;This reads the two headers, rebuilds the signed string, computes the HMAC, compares in constant time, and rejects stale timestamps to block replays.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyHubSpotV3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// 'POST'&lt;/span&gt;
  &lt;span class="nx"&gt;fullUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// 'https://your-app.com/webhooks/hubspot?x=1'  (exactly what HubSpot called)&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// the untouched request body as a string, NOT re-stringified JSON&lt;/span&gt;
  &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// X-HubSpot-Signature-v3&lt;/span&gt;
  &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// X-HubSpot-Request-Timestamp&lt;/span&gt;
  &lt;span class="nx"&gt;clientSecret&lt;/span&gt;      &lt;span class="c1"&gt;// your app's client secret&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Reject replays: the timestamp is signed, so it cannot be forged past this window.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIVE_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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="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="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;FIVE_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;signedString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fullUri&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timestamp&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&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;clientSecret&lt;/span&gt;&lt;span class="p"&gt;)&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;signedString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&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;signatureHeader&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;b&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;expected&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;a&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="nx"&gt;b&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;Notice there is no &lt;code&gt;JSON.parse&lt;/code&gt; in that function. Parse the body only after the signature passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the raw body (the part frameworks fight you on)
&lt;/h2&gt;

&lt;p&gt;The verification is easy. Handing it the exact bytes HubSpot sent is where most setups break, because web frameworks parse the body for you by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express: use the raw parser on the webhook route so req.body stays a Buffer&lt;/span&gt;
&lt;span class="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/hubspot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-HubSpot-Signature-v3&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;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-HubSpot-Request-Timestamp&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;fullUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-app.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// include the query string&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="nf"&gt;verifyHubSpotV3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fullUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HUBSPOT_CLIENT_SECRET&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad signature&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// ack fast&lt;/span&gt;
    &lt;span class="nf"&gt;handleHubSpotEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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;In Next.js route handlers, read the body once with &lt;code&gt;await req.text()&lt;/code&gt; and do not call &lt;code&gt;req.json()&lt;/code&gt; first, because you cannot read the stream twice. In Cloudflare Workers, &lt;code&gt;await request.text()&lt;/code&gt; gives you the raw bytes and the Web Crypto API does the HMAC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signatures prove authenticity. They do nothing for lost events.
&lt;/h2&gt;

&lt;p&gt;HubSpot batches webhook events and retries failed deliveries for up to a few days, then gives up. That window feels safe until a deploy, a database timeout, or a cold start eats a burst of &lt;code&gt;contact.propertyChange&lt;/code&gt; or &lt;code&gt;deal.creation&lt;/code&gt; events during the minutes your handler is down. A missed &lt;code&gt;deal.creation&lt;/code&gt; is a lead your CRM automation never fired on. A missed &lt;code&gt;contact.deletion&lt;/code&gt; is a record you keep syncing after the customer asked to be forgotten.&lt;/p&gt;

&lt;p&gt;This is where EventDock fits. You point HubSpot at EventDock instead of directly at your app. EventDock verifies the signature, stores the event, acknowledges HubSpot right away so the delivery is safely captured, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait in the queue and arrive once it recovers.&lt;/p&gt;

&lt;p&gt;You can wire it up on the free tier and watch every HubSpot event get captured and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free&lt;/a&gt;, or read how the &lt;a href="https://dev.to/blog/exactly-once-webhook-processing-pattern"&gt;exactly-once processing pattern&lt;/a&gt; handles the duplicate deliveries that HubSpot, like every retrying provider, will eventually send you.&lt;/p&gt;

</description>
      <category>hubspot</category>
      <category>webhooks</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Verify Paddle Billing Webhook Signatures (and the Raw-Body Bug That Breaks Them)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Mon, 06 Jul 2026 14:39:49 +0000</pubDate>
      <link>https://dev.to/eventdock/how-to-verify-paddle-billing-webhook-signatures-and-the-raw-body-bug-that-breaks-them-2a14</link>
      <guid>https://dev.to/eventdock/how-to-verify-paddle-billing-webhook-signatures-and-the-raw-body-bug-that-breaks-them-2a14</guid>
      <description>&lt;p&gt;Most Paddle webhook signature failures come down to one thing: you verified the signature against the wrong bytes. Your framework parsed the JSON, you re-serialized it, and the HMAC no longer matches what Paddle signed. The event looks tampered with even though nothing is wrong. This post shows the exact verification Paddle Billing expects, the raw-body trap that breaks it, and the difference between Paddle Billing and the older Paddle Classic scheme that sends people down the wrong path.&lt;/p&gt;

&lt;p&gt;EventDock's own billing runs on Paddle Billing, so the code below is the same shape we use in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: are you on Paddle Billing or Paddle Classic?
&lt;/h2&gt;

&lt;p&gt;Paddle has two products with two completely different webhook signature schemes, and a lot of the older blog posts and Stack Overflow answers describe the wrong one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Paddle Classic&lt;/strong&gt; (the original product) sends a &lt;code&gt;p_signature&lt;/code&gt; field inside the form-encoded body. It requires PHP serialization and is verified with Paddle's public key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle Billing&lt;/strong&gt; (the current product) sends a &lt;code&gt;Paddle-Signature&lt;/code&gt; HTTP header. It is an HMAC-SHA256 over the raw request body, keyed with a per-notification-destination secret. No public key needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your webhook secret looks like &lt;code&gt;pdl_ntfset_...&lt;/code&gt; and you see a &lt;code&gt;Paddle-Signature&lt;/code&gt; header, you are on Paddle Billing. Accounts created after August 2023 are on Paddle Billing by default, and Classic is legacy. Everything below is for Paddle Billing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Paddle-Signature header actually contains
&lt;/h2&gt;

&lt;p&gt;Every Paddle Billing webhook arrives with a header like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Paddle-Signature: ts=1717000000;h1=eb4d0a2f...&amp;lt;64 hex chars&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields, separated by a semicolon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ts&lt;/code&gt; is a Unix timestamp (seconds) for when Paddle sent the request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;h1&lt;/code&gt; is the HMAC-SHA256 digest, in lowercase hex.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detail that the field names hide: Paddle does not sign the body on its own. It signs the string &lt;code&gt;{ts}:{raw_body}&lt;/code&gt;. The timestamp is part of the signed material, which is what lets you reject replayed requests. Miss the &lt;code&gt;ts:&lt;/code&gt; prefix and every signature you compute will be wrong even though your HMAC code is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw-body trap
&lt;/h2&gt;

&lt;p&gt;HMAC signs bytes, not objects. Paddle computed its digest over the exact bytes it put on the wire. If you hand your verification function anything other than those exact bytes, the digest will not match.&lt;/p&gt;

&lt;p&gt;Here is how the bytes get changed without you noticing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your web framework reads the request body and parses it into a JSON object for you.&lt;/li&gt;
&lt;li&gt;You call your verify function and pass it &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt; to turn the object back into a string.&lt;/li&gt;
&lt;li&gt;That re-serialized string is not byte-identical to what Paddle sent. Key order can change, whitespace disappears, unicode escapes differ, floating-point numbers get reformatted.&lt;/li&gt;
&lt;li&gt;Your HMAC is computed over different bytes, so &lt;code&gt;h1&lt;/code&gt; never matches, and you reject every real webhook as invalid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is always the same: verify against the raw, unparsed request body, then parse it only after the signature passes. Every framework has its own way to get the raw body, and getting it wrong is the most common cause of Paddle verification failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correct verification in Node.js
&lt;/h2&gt;

&lt;p&gt;It reads the two header fields, rebuilds the signed string as &lt;code&gt;ts:body&lt;/code&gt;, computes the HMAC, compares in constant time, and rejects requests whose timestamp is too old to be fresh.&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyPaddleSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&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="c1"&gt;// the exact bytes Paddle sent, NOT a re-stringified object&lt;/span&gt;
  &lt;span class="nx"&gt;signatureHeader&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="c1"&gt;// your notification destination secret, pdl_ntfset_...&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Header looks like: ts=1717000000;h1=abc123...&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &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;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&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;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&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;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;h1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&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;ts&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Reject replays: the timestamp is signed, so an attacker cannot forge a fresh one.&lt;/span&gt;
  &lt;span class="c1"&gt;// Paddle's own examples use a tight 5-second window. Widen it a little if your&lt;/span&gt;
  &lt;span class="c1"&gt;// servers see clock skew, but keep it short.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&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;floor&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&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;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Paddle signs the string `${ts}:${rawBody}`, not the body alone.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedPayload&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;ts&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;rawBody&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&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;secret&lt;/span&gt;&lt;span class="p"&gt;)&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;signedPayload&lt;/span&gt;&lt;span class="p"&gt;)&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;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Constant-time compare to avoid leaking the digest through timing.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&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;h1&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;b&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;expected&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;a&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="nx"&gt;b&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;Notice there is no &lt;code&gt;JSON.parse&lt;/code&gt; anywhere in that function. Parsing happens after it returns true, in your handler, never before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the raw body in Express, Next.js, and Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;The verify function is the simple part. Feeding it the untouched body is where frameworks fight you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Express
&lt;/h3&gt;

&lt;p&gt;The default &lt;code&gt;express.json()&lt;/code&gt; middleware consumes the stream and leaves you only the parsed object. Use the raw parser on the webhook route so you keep the bytes:&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="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/paddle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;   &lt;span class="c1"&gt;// req.body is now a Buffer&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Paddle-Signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&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="nf"&gt;verifyPaddleSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PADDLE_WEBHOOK_SECRET&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad signature&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ack fast, process after&lt;/span&gt;
    &lt;span class="nf"&gt;handlePaddleEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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;h3&gt;
  
  
  Next.js (App Router)
&lt;/h3&gt;

&lt;p&gt;Route handlers give you the raw body directly through &lt;code&gt;await req.text()&lt;/code&gt;. Do not call &lt;code&gt;req.json()&lt;/code&gt; first, because you cannot read the body twice.&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="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;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&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;raw&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// exact bytes&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paddle-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&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="nf"&gt;verifyPaddleSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PADDLE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad signature&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="na"&gt;status&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="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cloudflare Workers
&lt;/h3&gt;

&lt;p&gt;Workers give you the raw text with &lt;code&gt;await request.text()&lt;/code&gt; and ship the Web Crypto API, so you do not even need the Node &lt;code&gt;crypto&lt;/code&gt; module. This is close to what EventDock runs.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paddle-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// verify with crypto.subtle.importKey + crypto.subtle.sign('HMAC', ...)&lt;/span&gt;
&lt;span class="c1"&gt;// compare the hex digest to h1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A checklist when it still fails
&lt;/h2&gt;

&lt;p&gt;If you did all that and Paddle webhooks still read as invalid, walk this list in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Are you signing &lt;code&gt;ts:body&lt;/code&gt; and not just &lt;code&gt;body&lt;/code&gt;?&lt;/strong&gt; This is the second most common miss after the raw body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the secret the right one?&lt;/strong&gt; Paddle Billing gives each notification destination its own secret. If you have a sandbox destination and a production destination, they have different secrets. A sandbox secret against production traffic fails silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox versus production.&lt;/strong&gt; Sandbox events come from your sandbox destination and must be checked with the sandbox secret. Do not share one secret across both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is a proxy rewriting the body?&lt;/strong&gt; Some API gateways and body-logging middleware re-encode JSON before it reaches your handler. Check the bytes at the very edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is &lt;code&gt;h1&lt;/code&gt; compared as lowercase hex?&lt;/strong&gt; Paddle sends lowercase. If you uppercase or Base64 anywhere, the compare fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verification is step one. Delivery is the real problem.
&lt;/h2&gt;

&lt;p&gt;Signature verification tells you a webhook is authentic. It does nothing for the webhooks that never reach you, or the ones you drop because your handler was mid-deploy when Paddle called.&lt;/p&gt;

&lt;p&gt;Paddle Billing retries a failed webhook with backoff over about three days, then stops. That window sounds forgiving until a bad deploy, a database timeout, or a cold start eats a burst of &lt;code&gt;subscription.activated&lt;/code&gt; and &lt;code&gt;transaction.completed&lt;/code&gt; events during the minutes a customer is actually paying you. Lose one and you get a paying customer stuck on a free-tier account, or a churned customer your system still thinks is active.&lt;/p&gt;

&lt;p&gt;This is the problem EventDock solves. You point Paddle at EventDock instead of directly at your app. EventDock verifies the signature, stores the event, and acknowledges Paddle right away so the delivery is safely captured before the retry window matters, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait in the queue and arrive once it recovers.&lt;/p&gt;

&lt;p&gt;You can wire it up with the free tier and watch every Paddle event get captured and delivered. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;Start with EventDock free&lt;/a&gt;, or read how the &lt;a href="https://dev.to/blog/exactly-once-webhook-processing-pattern"&gt;exactly-once processing pattern&lt;/a&gt; handles the duplicate deliveries that every retrying provider, Paddle included, will eventually send you.&lt;/p&gt;

</description>
      <category>paddle</category>
      <category>webhooks</category>
      <category>node</category>
      <category>api</category>
    </item>
    <item>
      <title>Fix "Required headers missing: x-hub-signature-256" in @octokit/webhooks</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Fri, 03 Jul 2026 13:17:40 +0000</pubDate>
      <link>https://dev.to/eventdock/fix-required-headers-missing-x-hub-signature-256-in-octokitwebhooks-48c2</link>
      <guid>https://dev.to/eventdock/fix-required-headers-missing-x-hub-signature-256-in-octokitwebhooks-48c2</guid>
      <description>&lt;p&gt;&lt;em&gt;This guide also lives on &lt;a href="https://eventdock.app/fix-octokit-required-headers-missing" rel="noopener noreferrer"&gt;eventdock.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your GitHub webhook handler responds &lt;strong&gt;400&lt;/strong&gt; with:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Required headers missing: x-hub-signature-256"&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;(older &lt;code&gt;@octokit/webhooks&lt;/code&gt; versions: &lt;code&gt;x-hub-signature&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;The middleware checks three headers on every delivery — &lt;code&gt;x-github-event&lt;/code&gt;, &lt;code&gt;x-hub-signature-256&lt;/code&gt;, &lt;code&gt;x-github-delivery&lt;/code&gt; — and rejects the request if any is absent. Here are the causes, ordered by how often they're the culprit.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. No webhook secret configured (the usual one)
&lt;/h2&gt;

&lt;p&gt;GitHub only signs deliveries &lt;strong&gt;when the webhook has a secret&lt;/strong&gt;. No secret → GitHub omits &lt;code&gt;X-Hub-Signature&lt;/code&gt; and &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; entirely → octokit reports them missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Repo (or org) → &lt;strong&gt;Settings → Webhooks → your hook → Secret&lt;/strong&gt;: set a strong random value.&lt;/li&gt;
&lt;li&gt;Pass the &lt;em&gt;same&lt;/em&gt; value to your handler:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Webhooks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createNodeMiddleware&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@octokit/webhooks&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;webhooks&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;Webhooks&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// must match the webhook's Secret field&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After setting the secret, use &lt;strong&gt;Redeliver&lt;/strong&gt; on a recent delivery (webhook settings → Recent Deliveries) and confirm the request headers now include &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The request isn't from GitHub
&lt;/h2&gt;

&lt;p&gt;Health checks, uptime probes, security scanners, and your own &lt;code&gt;curl&lt;/code&gt; tests hit the same route without GitHub's headers and trip the 400. Real GitHub deliveries have a &lt;code&gt;User-Agent&lt;/code&gt; starting with &lt;code&gt;GitHub-Hookshot/&lt;/code&gt; and a unique &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; id. To test locally, copy a payload from &lt;strong&gt;Recent Deliveries&lt;/strong&gt; including all four headers, or just use Redeliver.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A proxy or framework strips the headers
&lt;/h2&gt;

&lt;p&gt;Some reverse proxies, API gateways, and serverless adapters drop or rename non-standard &lt;code&gt;X-&lt;/code&gt; headers. Diagnose by logging exactly what arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/webhooks&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// is x-hub-signature-256 present at all?&lt;/span&gt;
  &lt;span class="nf"&gt;next&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;If the header reaches your edge but not your handler, the thing in between (nginx &lt;code&gt;underscores_in_headers&lt;/code&gt; rules, an API gateway header allowlist, a Lambda adapter) is eating it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Old @octokit/webhooks expecting the legacy header
&lt;/h2&gt;

&lt;p&gt;Very old versions (v5-era) validated the SHA-1 &lt;code&gt;x-hub-signature&lt;/code&gt; header; current versions require the SHA-256 variant. GitHub sends &lt;em&gt;both&lt;/em&gt; when a secret is set, so the practical fix is the same: set the secret, then upgrade &lt;code&gt;@octokit/webhooks&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Wrong route wired to the middleware
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;createNodeMiddleware(webhooks, { path: "/api/webhooks" })&lt;/code&gt; is mounted on a path that also serves other traffic (or a catch-all), every non-webhook request to it produces this 400 in your logs. Give the webhook endpoint its own dedicated path.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Debugging webhook headers blind?&lt;/strong&gt; &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt; sits in front of your handler, captures every GitHub delivery with its full original headers, verifies signatures, and retries when your server is down. Free tier: 5,000 events/mo, no card.&lt;/p&gt;

</description>
      <category>github</category>
      <category>webhooks</category>
      <category>node</category>
      <category>debugging</category>
    </item>
    <item>
      <title>What is the worst production bug you have hit on Cloudflare Workers?</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 31 Mar 2026 17:02:41 +0000</pubDate>
      <link>https://dev.to/eventdock/what-is-the-worst-production-bug-you-have-hit-on-cloudflare-workers-3aja</link>
      <guid>https://dev.to/eventdock/what-is-the-worst-production-bug-you-have-hit-on-cloudflare-workers-3aja</guid>
      <description>&lt;p&gt;I recently shipped a webhook relay service on Cloudflare Workers and hit some genuinely painful production bugs — the kind where everything &lt;em&gt;looks&lt;/em&gt; fine but data is silently disappearing.&lt;/p&gt;

&lt;p&gt;The worst one: an &lt;code&gt;async&lt;/code&gt; function that was not &lt;code&gt;await&lt;/code&gt;ed in a queue consumer. In Node.js, the fire-and-forget promise would probably complete. On Cloudflare Workers, the runtime kills the execution context once the handler returns, so the promise just... vanishes. Events that needed retries silently stopped existing.&lt;/p&gt;

&lt;p&gt;Another fun one: a cron-based recovery system that was supposed to catch exactly these failures — except the cron trigger in &lt;code&gt;wrangler.toml&lt;/code&gt; was commented out. The safety net did not exist for months.&lt;/p&gt;

&lt;p&gt;I wrote up all four bugs in detail here: &lt;a href="https://dev.to/eventdock/i-built-a-webhook-relay-on-cloudflare-workers-here-is-every-bug-that-almost-killed-it-22dc"&gt;I Built a Webhook Relay on Cloudflare Workers. Here Are the Bugs That Killed It&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the worst production bug you have hit on Cloudflare Workers (or any edge/serverless platform)?&lt;/strong&gt; The "distributed systems fail in ways that look like success" category especially — where metrics look fine but data is quietly being lost.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
