# Verify a signed webhook

> Working Python and Go/Node verifiers for InfraInbox's outbound webhook signature, before trusting a payload.

Web page: https://infrainbox.app/docs/cookbook/verify-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

- A **webhook** destination already set up in InfraInbox, pointed at your receiver's URL (see [Signed webhook](https://infrainbox.app/docs/notifications/webhook.md)).
- The signing secret InfraInbox showed you when you created it, shaped like `whsec_…`.

## How the signature works

Every webhook InfraInbox sends follows the [Standard Webhooks](https://www.standardwebhooks.com/) 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.

> **Note:** InfraInbox never follows a redirect and treats anything but a `2xx` reply as a failure worth retrying (a `401`/`403`/`404`/`410` instead disables the destination), so your receiver should answer with a plain `2xx` once it has accepted the payload — don't redirect it anywhere.

> **Tip:** Standard Webhooks recommends rejecting a timestamp too far from your receiver's own clock (five minutes is a common tolerance) once you've verified the signature, as protection against a captured request being replayed later.

## Python

```python
import hashlib
import hmac
import 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)
```

## Go

```go
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

```javascript
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

1. In InfraInbox, open the webhook destination and click **Send test**.
2. Your receiver should log a valid signature for the test payload; deliberately change one character of the secret and confirm it now reports invalid.
3. Trigger a real incident and confirm the same check passes on a real payload — the body's shape is documented in [Signed webhook](https://infrainbox.app/docs/notifications/webhook.md).

## 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](https://infrainbox.app/docs/notifications/webhook.md#how-retries-and-failures-are-treated)). Answer `2xx` for anything you accept — even while you're only logging — then **Send test** to bring the destination back. |

## Next

- [Signed webhook](https://infrainbox.app/docs/notifications/webhook.md) — headers, retry behavior and the full payload shape.
- [REST API](https://infrainbox.app/docs/reference/rest-api.md) — if you're building against InfraInbox's own API rather than just receiving from it.
