[{"data":1,"prerenderedAt":12},["ShallowReactive",2],{"$fdotY0vMhm4HSr-QFY8vlSZew1jxUnTpmVW_u4EFMa1E":3},{"title":4,"slug":5,"excerpt":6,"category":7,"order":8,"screens":9,"html":11},"Webhook signing","webhook-signing","Secure your webhook endpoints with HMAC-SHA256 signatures following the Standard Webhooks convention","security",4,[10],"webhooks","\u003Cp>Webhook signing adds a cryptographic signature to each webhook request, allowing your server to verify that requests genuinely came from EmailConnect. This prevents attackers from sending forged webhook payloads to your endpoint.\u003C\u002Fp>\n\u003Cp>EmailConnect follows the \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fstandard-webhooks\u002Fstandard-webhooks\">Standard Webhooks\u003C\u002Fa> convention, an open specification adopted by services like Svix, Resend, and others. You can use any Standard Webhooks-compatible library to verify signatures.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Migrating from legacy headers?\u003C\u002Fstrong> Before 1 June 2026, EmailConnect sends both Standard Webhooks headers and legacy \u003Ccode>X-Webhook-Signature\u003C\u002Fcode> \u002F \u003Ccode>X-Webhook-Timestamp\u003C\u002Fcode> headers. The two formats differ: legacy signatures use the secret as a raw string and produce a hex-encoded \u003Ccode>sha256=...\u003C\u002Fcode> value, while standard signatures use a base64-decoded \u003Ccode>whsec_\u003C\u002Fcode> secret and produce a base64-encoded \u003Ccode>v1,...\u003C\u002Fcode> value. After 1 June 2026, only the standard headers are sent. Update your verification code to use the standard headers described below.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>How it works\u003C\u002Fh2>\n\u003Cp>When webhook signing is enabled:\u003C\u002Fp>\n\u003Col>\n\u003Cli>EmailConnect generates a base64-encoded secret key for your webhook (prefixed with \u003Ccode>whsec_\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>For each request, EmailConnect constructs a signed content string from the message ID, timestamp, and body\u003C\u002Fli>\n\u003Cli>An HMAC-SHA256 signature is computed and included as an HTTP header\u003C\u002Fli>\n\u003Cli>Your server verifies the signature using the shared secret\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>Signature headers\u003C\u002Fh2>\n\u003Cp>EmailConnect sends the following headers with signed webhooks:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Header\u003C\u002Fth>\n\u003Cth>Description\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>\u003Ccode>webhook-id\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Unique message identifier (e.g., \u003Ccode>msg_2KWP...\u003C\u002Fcode>)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>webhook-timestamp\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Unix timestamp (seconds) when the request was signed\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>webhook-signature\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Signature in format \u003Ccode>v1,&lt;base64-signature&gt;\u003C\u002Fcode> (may contain multiple space-separated signatures during key rotation)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\n\u003Ch2>The signing scheme\u003C\u002Fh2>\n\u003Cp>The signed content is the concatenation of the message ID, timestamp, and JSON body, separated by dots:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>{webhook-id}.{webhook-timestamp}.{body}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This content is signed with HMAC-SHA256 using the base64-decoded secret (strip the \u003Ccode>whsec_\u003C\u002Fcode> prefix before decoding). The resulting signature is base64-encoded and prefixed with \u003Ccode>v1,\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch2>Enabling webhook signing\u003C\u002Fh2>\n\u003Ch3>For new webhooks\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Create a new webhook in your dashboard\u003C\u002Fli>\n\u003Cli>Toggle &quot;Webhook signing&quot; in the Advanced options section\u003C\u002Fli>\n\u003Cli>Click &quot;Create webhook&quot;\u003C\u002Fli>\n\u003Cli>Copy the generated secret (starts with \u003Ccode>whsec_\u003C\u002Fcode>) immediately — it won&#39;t be shown again\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3>For existing webhooks\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Edit an existing webhook\u003C\u002Fli>\n\u003Cli>Toggle &quot;Webhook signing&quot; to enable it\u003C\u002Fli>\n\u003Cli>Click &quot;Save&quot;\u003C\u002Fli>\n\u003Cli>Copy the generated secret immediately\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3>Regenerating a secret\u003C\u002Fh3>\n\u003Cp>If you need a new secret (e.g., after a potential compromise):\u003C\u002Fp>\n\u003Col>\n\u003Cli>Edit your webhook\u003C\u002Fli>\n\u003Cli>Click &quot;Regenerate&quot; next to the signing status\u003C\u002Fli>\n\u003Cli>Copy the new secret\u003C\u002Fli>\n\u003Cli>Update your server with the new secret\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>Verifying signatures\u003C\u002Fh2>\n\u003Ch3>Step-by-step process\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Extract the \u003Ccode>webhook-id\u003C\u002Fcode>, \u003Ccode>webhook-timestamp\u003C\u002Fcode>, and \u003Ccode>webhook-signature\u003C\u002Fcode> headers\u003C\u002Fli>\n\u003Cli>Reject the request if the timestamp is too far from the current time (recommended: 5 minutes)\u003C\u002Fli>\n\u003Cli>Construct the signed content: \u003Ccode>{webhook-id}.{webhook-timestamp}.{raw-body}\u003C\u002Fcode>\u003C\u002Fli>\n\u003Cli>Base64-decode your secret (after removing the \u003Ccode>whsec_\u003C\u002Fcode> prefix)\u003C\u002Fli>\n\u003Cli>Compute HMAC-SHA256 of the signed content using the decoded secret\u003C\u002Fli>\n\u003Cli>Base64-encode the result and compare against the signature(s) in the header (after the \u003Ccode>v1,\u003C\u002Fcode> prefix)\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch3>Using a Standard Webhooks library\u003C\u002Fh3>\n\u003Cp>The easiest approach is to use an off-the-shelf Standard Webhooks library:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">npm install standardwebhooks     # Node.js\npip install standardwebhooks     # Python\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Node.js \u002F Express\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-javascript\">const crypto = require(&#39;crypto&#39;);\n\nfunction verifyWebhook(req, secret) {\n  const msgId = req.headers[&#39;webhook-id&#39;];\n  const timestamp = req.headers[&#39;webhook-timestamp&#39;];\n  const signatures = req.headers[&#39;webhook-signature&#39;];\n\n  if (!msgId || !timestamp || !signatures) {\n    return false;\n  }\n\n  \u002F\u002F Reject old timestamps (5-minute tolerance)\n  const now = Math.floor(Date.now() \u002F 1000);\n  if (Math.abs(now - parseInt(timestamp)) &gt; 300) {\n    return false;\n  }\n\n  \u002F\u002F Construct signed content\n  const rawBody = req.rawBody || JSON.stringify(req.body);\n  const signedContent = `${msgId}.${timestamp}.${rawBody}`;\n\n  \u002F\u002F Decode secret (strip whsec_ prefix, then base64-decode)\n  const secretBytes = Buffer.from(secret.replace(\u002F^whsec_\u002F, &#39;&#39;), &#39;base64&#39;);\n\n  \u002F\u002F Compute expected signature\n  const expected = crypto\n    .createHmac(&#39;sha256&#39;, secretBytes)\n    .update(signedContent, &#39;utf8&#39;)\n    .digest(&#39;base64&#39;);\n\n  \u002F\u002F Check against all provided signatures (handles key rotation)\n  return signatures\n    .split(&#39; &#39;)\n    .some(sig =&gt; {\n      const [version, value] = sig.split(&#39;,&#39;);\n      return version === &#39;v1&#39; &amp;&amp; crypto.timingSafeEqual(\n        Buffer.from(expected),\n        Buffer.from(value)\n      );\n    });\n}\n\n\u002F\u002F Express middleware to preserve raw body\napp.use(&#39;\u002Fwebhook&#39;, express.json({\n  verify: (req, res, buf) =&gt; {\n    req.rawBody = buf.toString();\n  }\n}));\n\napp.post(&#39;\u002Fwebhook\u002Femails&#39;, (req, res) =&gt; {\n  if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) {\n    return res.status(401).json({ error: &#39;Invalid signature&#39; });\n  }\n\n  const emailData = req.body;\n  console.log(&#39;Verified email from:&#39;, emailData.message.sender.email);\n\n  res.status(200).json({ success: true });\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Python \u002F Flask\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-python\">import hmac\nimport hashlib\nimport base64\nimport time\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\nWEBHOOK_SECRET = os.environ.get(&#39;WEBHOOK_SECRET&#39;)\n\ndef verify_webhook(request, secret):\n    msg_id = request.headers.get(&#39;webhook-id&#39;)\n    timestamp = request.headers.get(&#39;webhook-timestamp&#39;)\n    signatures = request.headers.get(&#39;webhook-signature&#39;)\n\n    if not all([msg_id, timestamp, signatures]):\n        return False\n\n    # Reject old timestamps (5-minute tolerance)\n    now = int(time.time())\n    if abs(now - int(timestamp)) &gt; 300:\n        return False\n\n    # Construct signed content\n    raw_body = request.get_data(as_text=True)\n    signed_content = f&#39;{msg_id}.{timestamp}.{raw_body}&#39;\n\n    # Decode secret (strip whsec_ prefix, then base64-decode)\n    secret_str = secret.removeprefix(&#39;whsec_&#39;)\n    secret_bytes = base64.b64decode(secret_str)\n\n    # Compute expected signature\n    expected = base64.b64encode(\n        hmac.new(secret_bytes, signed_content.encode(&#39;utf-8&#39;), hashlib.sha256).digest()\n    ).decode()\n\n    # Check against all provided signatures (handles key rotation)\n    for sig in signatures.split(&#39; &#39;):\n        version, _, value = sig.partition(&#39;,&#39;)\n        if version == &#39;v1&#39; and hmac.compare_digest(expected, value):\n            return True\n\n    return False\n\n@app.route(&#39;\u002Fwebhook\u002Femails&#39;, methods=[&#39;POST&#39;])\ndef handle_webhook():\n    if not verify_webhook(request, WEBHOOK_SECRET):\n        return jsonify({&#39;error&#39;: &#39;Invalid signature&#39;}), 401\n\n    email_data = request.json\n    print(f&quot;Verified email from: {email_data[&#39;message&#39;][&#39;sender&#39;][&#39;email&#39;]}&quot;)\n\n    return jsonify({&#39;success&#39;: True})\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Security best practices\u003C\u002Fh2>\n\u003Ch3>Store secrets securely\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>Use environment variables, never hardcode secrets\u003C\u002Fli>\n\u003Cli>Use a secrets manager in production (AWS Secrets Manager, HashiCorp Vault, etc.)\u003C\u002Fli>\n\u003Cli>Rotate secrets periodically or after any potential exposure\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Verify timestamps\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>Reject requests where \u003Ccode>webhook-timestamp\u003C\u002Fcode> is more than 5 minutes from the current time\u003C\u002Fli>\n\u003Cli>This prevents replay attacks\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Use timing-safe comparison\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>Always use constant-time string comparison (\u003Ccode>crypto.timingSafeEqual\u003C\u002Fcode>, \u003Ccode>hmac.compare_digest\u003C\u002Fcode>, etc.)\u003C\u002Fli>\n\u003Cli>Regular string comparison can leak information through timing differences\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Log verification failures\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>Log failed signature verifications for security monitoring\u003C\u002Fli>\n\u003Cli>Include relevant details (timestamp, source IP) but never log the secret\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Use HTTPS\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>Always use HTTPS for webhook endpoints\u003C\u002Fli>\n\u003Cli>HTTPS encrypts the entire request including headers and signature\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Troubleshooting\u003C\u002Fh2>\n\u003Ch3>&quot;Invalid signature&quot; errors\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>Check your secret\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Ensure you&#39;re using the correct \u003Ccode>whsec_\u003C\u002Fcode>-prefixed secret for this webhook\u003C\u002Fli>\n\u003Cli>Secrets are shown only once — regenerate if lost\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>\u003Cstrong>Check raw body handling\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul>\n\u003Cli>The signature is computed on the raw JSON request body\u003C\u002Fli>\n\u003Cli>Parsing JSON before verification can change whitespace\u002Fformatting\u003C\u002Fli>\n\u003Cli>Use middleware that preserves the raw body\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>\u003Cstrong>Check signed content construction\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cul>\n\u003Cli>The signed content must be \u003Ccode>{webhook-id}.{webhook-timestamp}.{body}\u003C\u002Fcode> — all three parts, dot-separated\u003C\u002Fli>\n\u003Cli>Use the raw body string, not a re-serialised object\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Testing signature verification\u003C\u002Fh3>\n\u003Cp>You can test your verification logic with a simple script:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">const crypto = require(&#39;crypto&#39;);\n\nconst secret = &#39;whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw&#39;; \u002F\u002F example\nconst msgId = &#39;msg_test123&#39;;\nconst timestamp = Math.floor(Date.now() \u002F 1000).toString();\nconst body = &#39;{&quot;test&quot;: &quot;payload&quot;}&#39;;\n\nconst signedContent = `${msgId}.${timestamp}.${body}`;\nconst secretBytes = Buffer.from(secret.replace(\u002F^whsec_\u002F, &#39;&#39;), &#39;base64&#39;);\n\nconst signature = &#39;v1,&#39; + crypto\n  .createHmac(&#39;sha256&#39;, secretBytes)\n  .update(signedContent, &#39;utf8&#39;)\n  .digest(&#39;base64&#39;);\n\nconsole.log(&#39;webhook-id:&#39;, msgId);\nconsole.log(&#39;webhook-timestamp:&#39;, timestamp);\nconsole.log(&#39;webhook-signature:&#39;, signature);\nconsole.log(&#39;Body:&#39;, body);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>Need help?\u003C\u002Fh2>\n\u003Cp>Having issues with webhook signing?\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Contact support at \u003Ca href=\"mailto:support@emailconnect.eu\">support@emailconnect.eu\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>Include your webhook ID and any error messages\u003C\u002Fli>\n\u003Cli>See the \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fstandard-webhooks\u002Fstandard-webhooks\">Standard Webhooks specification\u003C\u002Fa> for protocol details\u003C\u002Fli>\n\u003C\u002Ful>\n",1781207681653]