Verify a signed webhook
You’ll get: a receiver that only acts on a webhook that genuinely came from your InfraInbox instance, using the same check on both a Python and a Go/Node stack.
Before you start
Section titled “Before you start”- A webhook destination already set up in InfraInbox, pointed at your receiver’s URL (see Signed webhook).
- The signing secret InfraInbox showed you when you created it, shaped like
whsec_….
How the signature works
Section titled “How the signature works”Every webhook InfraInbox sends follows the Standard Webhooks convention. Three headers ride along with the JSON body:
| Header | Holds |
|---|---|
webhook-id |
The delivery’s ID — the same value on every retry of the same notice, so your receiver can drop duplicates. |
webhook-timestamp |
Unix seconds, when this attempt was signed. |
webhook-signature |
v1, followed by the base64-encoded HMAC-SHA256 of the signed content, keyed with your secret. |
The signed content is exactly {webhook-id}.{webhook-timestamp}.{raw body} — the two header values, each followed by a literal ., then the request body bytes exactly as sent (compute the signature before any pretty-printing or re-encoding). The secret itself is whsec_ followed by the base64 encoding of the actual key; decode past the prefix before using it in the HMAC.
Python
Section titled “Python”import hashlibimport hmacimport base64
def verify(secret: str, webhook_id: str, timestamp: str, body: bytes, signature_header: str) -> bool: key = base64.b64decode(secret.removeprefix("whsec_")) signed_content = f"{webhook_id}.{timestamp}.".encode() + body expected = "v1," + base64.b64encode( hmac.new(key, signed_content, hashlib.sha256).digest() ).decode()
# InfraInbox sends exactly one "v1,<sig>" value (never several # space-separated ones — that's a generic Standard Webhooks allowance # for other senders, not something this server's sender does). return hmac.compare_digest(expected, signature_header)package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "strings")
// InfraInbox sends exactly one "v1,<sig>" value in webhook-signature — never// several space-separated ones — so a plain equality check is enough.func verify(secret, webhookID, timestamp string, body []byte, signatureHeader string) bool { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(webhookID + "." + timestamp + ".")) mac.Write(body) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))}Node.js
Section titled “Node.js”const crypto = require('crypto');
// InfraInbox sends exactly one "v1,<sig>" value in webhook-signature — never// several space-separated ones — so a plain equality check is enough.function verify(secret, webhookId, timestamp, body, signatureHeader) { const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = 'v1,' + crypto .createHmac('sha256', key) .update(`${webhookId}.${timestamp}.`) .update(body) // the raw request body Buffer, not a re-serialized object .digest('base64');
const a = Buffer.from(expected); const b = Buffer.from(signatureHeader); return a.length === b.length && crypto.timingSafeEqual(a, b);}Whatever language you use, read the three headers (webhook-id, webhook-timestamp, webhook-signature) and the raw, unparsed request body — verify the signature first, and only then parse the body as JSON.
Check it works
Section titled “Check it works”- In InfraInbox, open the webhook destination and click Send test.
- Your receiver should log a valid signature for the test payload; deliberately change one character of the secret and confirm it now reports invalid.
- Trigger a real incident and confirm the same check passes on a real payload — the body’s shape is documented in Signed webhook.
If it doesn’t work
Section titled “If it doesn’t work”| Symptom | Likely cause |
|---|---|
| Every signature fails, including the test | You’re hashing a re-serialized/pretty-printed body instead of the exact bytes InfraInbox sent — read the request body as raw bytes before any JSON parsing. |
| Works, then breaks after regenerating the secret | The webhook destination’s secret was regenerated (there’s no overlap period — the old secret stops working immediately) — update the value your receiver reads it from. |
| The destination went quiet and now shows as broken | While you were getting the verifier right, your receiver refused five notices in a row, so InfraInbox stopped sending there (see Signed webhook). Answer 2xx for anything you accept — even while you’re only logging — then Send test to bring the destination back. |
- Signed webhook — headers, retry behavior and the full payload shape.
- REST API — if you’re building against InfraInbox’s own API rather than just receiving from it.