> ## Documentation Index
> Fetch the complete documentation index at: https://docs.partners.api.cobee.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying Signatures

> How to verify that a webhook delivery genuinely comes from Cobee and was not tampered with.

Every webhook delivery is signed. **Verify the signature before you parse the JSON body or act on the event** — this is what protects your endpoint from forged requests, detects payload tampering, and rejects replayed deliveries.

Webhook deliveries are signed, adding these headers to every request:

```http theme={null}
webhook-id: msg_2Qz...
webhook-timestamp: 1753939051
webhook-signature: v1,base64-encoded-signature
```

> [!NOTE]
>
> The signing secret returned by [`POST /webhooks/subscriptions`](https://docs.partners.api.cobee.io/en/api-reference/webhooks/endpoint/post_webhook_subscription) (and by [rotation](https://docs.partners.api.cobee.io/en/api-reference/webhooks/endpoint/post_rotate_webhook_secret.md)) looks like `whsec_...`. That prefixed value is base64 — decode it before using it as the HMAC key.

## Verification Steps

1. Read `webhook-id`, `webhook-timestamp`, and `webhook-signature` from the request headers.
2. Build the signed content by concatenating the message ID, the timestamp, and the **raw request body**, separated by dots: `{webhook-id}.{webhook-timestamp}.{body}`. Use the exact raw bytes/string you received — do not parse and re-serialize the JSON first, since that can change field order or whitespace and break the signature.
3. Base64-decode the signing secret (after stripping the `whsec_` prefix) and compute the HMAC-SHA256 digest of the signed content using it as the key.
4. Base64-encode the digest and compare it against each `v1` value in `webhook-signature` using a **constant-time comparison**. The header can contain multiple space-separated signatures (for example during secret rotation); accept the delivery if any one of them matches.
5. Reject the request if no signature matches, or if `webhook-timestamp` is older than **5 minutes** (or further in the future than your allowed clock skew), to prevent replay attacks.
6. Only after verification succeeds, parse the JSON body and process the event.

Respond with a `2xx` status once the event has been safely accepted (even if asynchronous processing is still pending) — see [Delivery Semantics](https://docs.partners.api.cobee.io/en/api-reference/webhooks/overview.md#delivery-semantics) for retry behaviour.

## Code Examples

```javascript Node.js theme={null}
const crypto = require('crypto');

function verifyWebhookSignature({ secret, id, timestamp, rawBody, signatureHeader }) {
  const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signedContent = `${id}.${timestamp}.${rawBody}`;

  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  const receivedSignatures = signatureHeader
    .split(' ')
    .map((part) => part.split(',')[1])
    .filter(Boolean);

  const isValid = receivedSignatures.some((received) => {
    const a = Buffer.from(expectedSignature);
    const b = Buffer.from(received);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });

  const toleranceInSeconds = 5 * 60;
  const isFresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceInSeconds;

  return isValid && isFresh;
}
```

```python Python theme={null}
import base64
import hashlib
import hmac
import time


def verify_webhook_signature(secret, id_, timestamp, raw_body, signature_header):
    secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{id_}.{timestamp}.{raw_body}".encode()

    expected_signature = base64.b64encode(
        hmac.new(secret_bytes, signed_content, hashlib.sha256).digest()
    ).decode()

    received_signatures = [
        part.split(",", 1)[1]
        for part in signature_header.split(" ")
        if "," in part
    ]

    is_valid = any(
        hmac.compare_digest(expected_signature, received)
        for received in received_signatures
    )

    tolerance_in_seconds = 5 * 60
    is_fresh = abs(time.time() - int(timestamp)) <= tolerance_in_seconds

    return is_valid and is_fresh
```

```java Java theme={null}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;

public class VerifyWebhookSignature {

    public static boolean verify(String secret, String id, String timestamp, String rawBody, String signatureHeader) throws Exception {
        String base64Secret = secret.startsWith("whsec_") ? secret.substring(6) : secret;
        byte[] secretBytes = Base64.getDecoder().decode(base64Secret);

        String signedContent = id + "." + timestamp + "." + rawBody;

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secretBytes, "HmacSHA256"));
        byte[] digest = mac.doFinal(signedContent.getBytes("UTF-8"));
        String expectedSignature = Base64.getEncoder().encodeToString(digest);

        boolean isValid = false;
        for (String part : signatureHeader.split(" ")) {
            int commaIdx = part.indexOf(',');
            if (commaIdx < 0) continue;
            String received = part.substring(commaIdx + 1);
            if (constantTimeEquals(expectedSignature, received)) {
                isValid = true;
                break;
            }
        }

        long toleranceInSeconds = 5 * 60;
        long nowSeconds = System.currentTimeMillis() / 1000;
        long eventTimestamp = Long.parseLong(timestamp);
        boolean isFresh = Math.abs(nowSeconds - eventTimestamp) <= toleranceInSeconds;

        return isValid && isFresh;
    }

    private static boolean constantTimeEquals(String a, String b) {
        byte[] aBytes = a.getBytes();
        byte[] bBytes = b.getBytes();
        if (aBytes.length != bBytes.length) return false;
        return MessageDigest.isEqual(aBytes, bBytes);
    }
}
```

```php PHP theme={null}
<?php

function verifyWebhookSignature(string $secret, string $id, string $timestamp, string $rawBody, string $signatureHeader): bool
{
    $base64Secret = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;
    $secretBytes = base64_decode($base64Secret);

    $signedContent = "{$id}.{$timestamp}.{$rawBody}";
    $expectedSignature = base64_encode(hash_hmac('sha256', $signedContent, $secretBytes, true));

    $isValid = false;
    foreach (explode(' ', $signatureHeader) as $part) {
        $commaPos = strpos($part, ',');
        if ($commaPos === false) {
            continue;
        }
        $received = substr($part, $commaPos + 1);
        if (hash_equals($expectedSignature, $received)) {
            $isValid = true;
            break;
        }
    }

    $toleranceInSeconds = 5 * 60;
    $isFresh = abs(time() - (int) $timestamp) <= $toleranceInSeconds;

    return $isValid && $isFresh;
}
```

> [!WARNING]
>
> Store the signing secret securely and never expose it in client-side code or logs. If you suspect it has been compromised, rotate it with [`POST /webhooks/subscriptions/{subscriptionId}/secret`](https://docs.partners.api.cobee.io/en/api-reference/webhooks/endpoint/post_rotate_webhook_secret).

> [!TIP]
>
> If your stack already has a library for verifying `webhook-id` / `webhook-timestamp` / `webhook-signature` style signatures, prefer it over a hand-rolled implementation — it already handles edge cases such as multiple signature versions and secret encoding.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.