# Signature Verification

Every Probo webhook includes an HMAC signature. Verify it before parsing the body or performing side effects. Signature verification proves that the payload and timestamp were produced with your subscription's signing secret. A timestamp freshness check limits replay attacks.

:::danger[Verify the raw request body]
Use the exact bytes received from the network. Parsing and re-serializing JSON can change whitespace or key order and will produce a different signature. Configure your framework to expose the raw body before its JSON middleware runs.
:::

## How it works

Probo signs each webhook payload using **HMAC-SHA256** with the signing secret from your webhook subscription. The signature is sent in the `X-Probo-Webhook-Signature` header.

The signed message is the concatenation of the timestamp and the raw request body, separated by a colon:

```
{timestamp}:{body}
```

Where:

- `timestamp` is the value of the `X-Probo-Webhook-Timestamp` header (Unix seconds)
- `body` is the raw JSON request body

Use the **full signing secret string** (including the `whsec_` prefix) as the HMAC key. Do not strip the prefix or hex-decode the secret.

Probo generates that secret when you create the subscription. It does not change if you later update the endpoint URL or selected events. Store it in a secret manager, scope it to the receiving service, and never log it. See [Signing secret](/docs/developers/api/webhooks/delivery-and-recovery#signing-secret) for rotation.

## Verification steps

1. **Extract the headers**

   Read `X-Probo-Webhook-Timestamp` and `X-Probo-Webhook-Signature` from the request.

2. **Build the signed message**

   Concatenate the timestamp, a colon (`:`), and the raw request body.

3. **Compute the expected signature**

   Calculate `HMAC-SHA256` using your full signing secret (including the `whsec_` prefix) as the key and the signed message as the input. Hex-encode the result.

4. **Compare signatures**

   Use a constant-time comparison to check if the computed signature matches the `X-Probo-Webhook-Signature` header.

5. **Check timestamp freshness**

   After the signature matches, reject the request if its timestamp is more than 5 minutes in the past or future. The signed timestamp prevents an attacker from substituting a fresh value.

Cloud and default self-hosted senders stamp the timestamp at the start of each attempt, so a delayed retry still passes a 5-minute check. Self-hosted operators can change sender lease recovery with `PROBOD_WEBHOOK_STALE_AFTER`; that setting does not change this receiver check. Keep a 5-minute window unless you operate both sides and have a reason to change it.

## Preserve the raw body

Read the body as bytes before any JSON parser touches it.

| Runtime | Typical approach |
| ------- | ---------------- |
| Go `net/http` | `io.ReadAll(r.Body)` before `json.Unmarshal` |
| Python Flask | `request.get_data()` — not `request.json` or `request.get_json()` |
| Python FastAPI / Starlette | `await request.body()` before `request.json()` |
| Node `http` | Concatenate `data` chunks from the request stream |
| Express | Mount `express.raw({ type: "application/json" })` on the webhook route so `req.body` is a `Buffer`. Do not use `express.json()` on that path |

Express example:

```javascript

const app = express();
app.post(
  "/webhooks/probo",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body; // Buffer
    // verify, then JSON.parse(rawBody.toString("utf8"))
  },
);
```

## Choose the signing secret

Verify with the secret that belongs to the subscription that sent the request. `subscriptionId` is only in the JSON body, so you cannot use it to select a secret until after verification succeeds.

Give each subscription its own URL path (or its own endpoint) and load the matching secret from your secret manager before you compute the HMAC. Do not try every stored secret on a shared URL.

## Examples

  

```go
package main

	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"
)

func verifyWebhook(r *http.Request, signingSecret string) ([]byte, error) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, err
	}

	timestamp := r.Header.Get("X-Probo-Webhook-Timestamp")
	signature := r.Header.Get("X-Probo-Webhook-Signature")
	if timestamp == "" || signature == "" {
		return nil, fmt.Errorf("missing signature headers")
	}

	mac := hmac.New(sha256.New, []byte(signingSecret))
	mac.Write([]byte(timestamp))
	mac.Write([]byte(":"))
	mac.Write(body)

	received, err := hex.DecodeString(signature)
	if err != nil || !hmac.Equal(mac.Sum(nil), received) {
		return nil, fmt.Errorf("invalid signature")
	}

	signedAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return nil, fmt.Errorf("invalid timestamp")
	}
	delta := time.Now().Unix() - signedAt
	if delta > 300 || delta < -300 {
		return nil, fmt.Errorf("stale timestamp")
	}

	return body, nil
}
```

  
  

```python

def verify_webhook(
    body: bytes,
    timestamp: str | None,
    signature: str | None,
    signing_secret: str,
) -> bool:
    if (
        timestamp is None
        or signature is None
        or re.fullmatch(r"[0-9]+", timestamp) is None
    ):
        return False

    expected = hmac.new(
        signing_secret.encode(),
        timestamp.encode("ascii") + b":" + body,
        hashlib.sha256,
    ).digest()

    if re.fullmatch(r"[0-9a-fA-F]{64}", signature) is None:
        return False
    if not hmac.compare_digest(expected, bytes.fromhex(signature)):
        return False

    signed_at = int(timestamp)
    return abs(time.time() - signed_at) <= 300
```

  
  

```typescript

function verifyWebhook(
  rawBody: Buffer,
  timestamp: string | undefined,
  signature: string | undefined,
  signingSecret: string,
): boolean {
  if (
    !Buffer.isBuffer(rawBody) ||
    typeof timestamp !== "string" ||
    typeof signature !== "string" ||
    !/^[0-9]+$/.test(timestamp) ||
    !/^[0-9a-fA-F]{64}$/.test(signature)
  ) {
    return false;
  }

  const expected = createHmac("sha256", signingSecret)
    .update(timestamp)
    .update(":")
    .update(rawBody)
    .digest();
  const received = Buffer.from(signature, "hex");

  if (
    expected.length !== received.length ||
    !timingSafeEqual(expected, received)
  ) {
    return false;
  }

  const signedAt = Number(timestamp);
  return (
    Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= 300
  );
}
```

  

Complete HTTP handlers that verify, parse, deduplicate, and acknowledge are in the [quickstart](/docs/developers/api/webhooks/quickstart).

## Failure responses

Return a generic `400` or `403` when verification fails. Do not reveal whether the timestamp, signature, or secret was the problem.

Probo treats most `4xx` responses as terminal failures and does not retry them. A rejected signature should fail closed. Do not return `5xx` or `429` for an invalid signature — those codes look like transient errors and trigger retries. See [Delivery and recovery](/docs/developers/api/webhooks/delivery-and-recovery) for retryable status codes.

## Security recommendations

- Verify the signature before parsing JSON, authorizing the organization, or queuing work.
- Reject missing, malformed, stale, and future-dated timestamps. The examples use a 5-minute tolerance.
- Compare decoded signature bytes in constant time. Check their length first where the comparison API requires equal-length inputs.
- Keep a separate secret for each subscription. Load it by endpoint path, not by parsing the body first.
- After verification, record `eventId` and process it only once. The same value is sent as `Idempotency-Key` and `X-Probo-Webhook-Delivery-Id`. Retries reuse it with a new timestamp and signature.

## Troubleshooting

| Symptom                                       | Likely cause                                                                   |
| --------------------------------------------- | ------------------------------------------------------------------------------ |
| Every signature fails                         | The framework parsed or modified the body before verification                  |
| Only non-ASCII payloads fail                  | The receiver decoded and re-encoded the body instead of hashing raw bytes      |
| `timingSafeEqual` throws                      | The received signature was not validated as 32-byte hexadecimal first          |
| Valid deliveries are reported as stale        | The receiver clock is out of sync or the timestamp was treated as milliseconds |
| Verification works with one subscription only | The endpoint is selecting the wrong subscription secret                        |
| Express handlers always fail                  | `express.json()` ran on the webhook route instead of `express.raw()`           |

## Next steps

- [Quickstart](/docs/developers/api/webhooks/quickstart) — Copy a complete receiver that verifies, deduplicates, and acknowledges
- [Event types](/docs/developers/api/webhooks/event-types) — Inspect data and updatedFrom for each event
- [Delivery and recovery](/docs/developers/api/webhooks/delivery-and-recovery) — Handle retries, idempotency, and failed deliveries
