# Verify webhooks

> Check that each webhook request really came from Soosh, answer it in time, and skip the ones you've already handled.

Source: https://docs.soosh.io/guides/api/verify-webhooks/

Anyone who finds your webhook URL can send it a request. Soosh signs every
request with your endpoint's secret, following
[Standard Webhooks](https://www.standardwebhooks.com), so your server can turn
away anything Soosh didn't send.

## Before you start

- [A webhook endpoint in Soosh](https://docs.soosh.io/guides/integrations/set-up-a-webhook/), or one made with [`POST /v1/webhooks`](https://docs.soosh.io/reference/api/webhooks/create-webhook/)
- A server that can read the raw body of a request, before it parses the JSON

## 1. Get the signing secret

Every endpoint has its own secret. It starts with `whsec_`.

- **In the app:** Soosh shows the secret once you create the webhook. To see
  it again, go to [**Settings > Webhooks**](https://app.soosh.io/settings/webhooks), open the webhook and
  click **Show secret**. You need a role that can edit webhooks.
- **Over the API:** `POST /v1/webhooks` returns it as `secret`, this once.
  See [Create a webhook endpoint](https://docs.soosh.io/reference/api/webhooks/create-webhook/).

Keep it with your other secrets, for example in an environment variable
called `SOOSH_WEBHOOK_SECRET`. Never put it in your code.

## 2. Read the three headers and the raw body

Each request is a `POST` with a JSON body and these headers. The body is an
[event](https://docs.soosh.io/reference/api/webhook-events/#the-envelope). The full request is in [Webhook delivery](https://docs.soosh.io/reference/webhook-delivery/#the-request).

| Header | What it holds |
|---|---|
| `webhook-id` | The ID of this delivery. It's the same on every try. |
| `webhook-timestamp` | When Soosh sent this try, in Unix seconds. |
| `webhook-signature` | `v1,` and a base64 signature. While a secret is being rotated there are two, separated by a space. |

The signature covers the body exactly as Soosh sent it. Parsing the JSON and
turning it back into text changes spaces and order, and the signature no
longer matches. So read the body as raw bytes, check it, and only then parse
it.

## 3. Check the signature

Copy the code for your language. It:

- turns away requests whose timestamp is more than 5 minutes from your
  server's clock, so a captured request can't be sent again later;
- signs `webhook-id`, `.`, `webhook-timestamp`, `.` and the body with
  HMAC-SHA256, using the base64-decoded part of the secret after `whsec_`
  as the key;
- compares the result with each `v1` signature in the header, in constant
  time, and accepts the request if any one matches.

**Node.js**

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

// Returns true when a webhook request really came from Soosh.
//   secret   the endpoint's signing secret, starting whsec_
//   headers  the request headers, with lower-case names (as Node gives them)
//   body     the raw request body, exactly as received: a Buffer or string,
//            before any JSON parsing
export function verifyWebhook(secret, headers, body, now = Math.floor(Date.now() / 1000)) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !signatures || !/^\d+$/.test(timestamp ?? '')) return false;

  // Refuse requests more than 5 minutes old (or ahead), so a captured
  // request can't be replayed later.
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  // The key is the base64-decoded part of the secret after whsec_.
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();

  // There can be several signatures, separated by spaces: during a secret
  // rotation, one per secret. Any one that matches is enough.
  return signatures.split(' ').some((signature) => {
    const [version, value] = signature.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

Use it with the raw body. In Express:

```js
import express from 'express';
import { verifyWebhook } from './verify.mjs';

const app = express();

app.post('/soosh', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhook(process.env.SOOSH_WEBHOOK_SECRET, req.headers, req.body)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  console.log(event.type, event.data);
  res.sendStatus(204);
});

app.listen(3000);
```

**Python**

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_webhook(secret, headers, body, now=None):
    """Return True when a webhook request really came from Soosh.

    secret   the endpoint's signing secret, starting whsec_
    headers  the request headers (in Flask or Django, request.headers)
    body     the raw request body as bytes, exactly as received, before any
             JSON parsing (in Flask, request.get_data())
    """
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp") or ""
    signatures = headers.get("webhook-signature")
    if not msg_id or not signatures or not (timestamp.isascii() and timestamp.isdigit()):
        return False

    # Refuse requests more than 5 minutes old (or ahead), so a captured
    # request can't be replayed later.
    if now is None:
        now = time.time()
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    # The key is the base64-decoded part of the secret after whsec_.
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    # There can be several signatures, separated by spaces: during a secret
    # rotation, one per secret. Any one that matches is enough.
    for signature in signatures.split(" "):
        version, _, value = signature.partition(",")
        if version != "v1":
            continue
        try:
            received = base64.b64decode(value, validate=True)
        except ValueError:  # not base64
            continue
        if hmac.compare_digest(received, expected):
            return True
    return False
```

Use it with the raw body. In Flask:

```python
import os
from flask import Flask, request
from verify import verify_webhook

app = Flask(__name__)

@app.post("/soosh")
def soosh():
    if not verify_webhook(os.environ["SOOSH_WEBHOOK_SECRET"], request.headers, request.get_data()):
        return "", 401
    event = request.get_json()
    print(event["type"], event["data"])
    return "", 204
```

**Go**

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

// Verify reports whether a webhook request really came from Soosh.
//   - secret is the endpoint's signing secret, starting whsec_.
//   - body is the raw request body, exactly as received, before any JSON
//     parsing.
//   - now is the current time: pass time.Now().
func Verify(secret string, header http.Header, body []byte, now time.Time) bool {
	id := header.Get("webhook-id")
	timestamp := header.Get("webhook-timestamp")
	signatures := header.Get("webhook-signature")
	if id == "" || signatures == "" || timestamp == "" || strings.Trim(timestamp, "0123456789") != "" {
		return false
	}

	// Refuse requests more than 5 minutes old (or ahead), so a captured
	// request can't be replayed later.
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
		return false
	}

	// The key is the base64-decoded part of the secret after whsec_.
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(body)
	expected := mac.Sum(nil)

	// There can be several signatures, separated by spaces: during a secret
	// rotation, one per secret. Any one that matches is enough.
	for _, signature := range strings.Split(signatures, " ") {
		version, value, ok := strings.Cut(signature, ",")
		if !ok || version != "v1" {
			continue
		}
		received, err := base64.StdEncoding.DecodeString(value)
		if err == nil && hmac.Equal(received, expected) {
			return true
		}
	}
	return false
}
```

Use it with the raw body:

```go
http.HandleFunc("/soosh", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil || !webhooks.Verify(os.Getenv("SOOSH_WEBHOOK_SECRET"), r.Header, body, time.Now()) {
		http.Error(w, "signature doesn't match", http.StatusUnauthorized)
		return
	}
	var event struct {
		ID   string          `json:"id"`
		Type string          `json:"type"`
		Data json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(body, &event); err != nil {
		http.Error(w, "not JSON", http.StatusBadRequest)
		return
	}
	log.Println(event.Type, string(event.Data))
	w.WriteHeader(http.StatusNoContent)
})
```

**PHP**

```php
<?php

const SOOSH_WEBHOOK_TOLERANCE_SECONDS = 5 * 60;

/**
 * Returns true when a webhook request really came from Soosh.
 *
 * $secret   the endpoint's signing secret, starting whsec_
 * $headers  the request headers, with lower-case names:
 *           array_change_key_case(getallheaders(), CASE_LOWER)
 * $body     the raw request body, exactly as received:
 *           file_get_contents('php://input')
 */
function soosh_verify_webhook(string $secret, array $headers, string $body, ?int $now = null): bool
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatures = $headers['webhook-signature'] ?? '';
    if ($id === '' || $signatures === '' || !ctype_digit($timestamp)) {
        return false;
    }

    // Refuse requests more than 5 minutes old (or ahead), so a captured
    // request can't be replayed later.
    $now ??= time();
    if (abs($now - (int) $timestamp) > SOOSH_WEBHOOK_TOLERANCE_SECONDS) {
        return false;
    }

    // The key is the base64-decoded part of the secret after whsec_.
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    if ($key === false) {
        return false;
    }
    $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));

    // There can be several signatures, separated by spaces: during a secret
    // rotation, one per secret. Any one that matches is enough.
    foreach (explode(' ', $signatures) as $signature) {
        [$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }
    return false;
}
```

Use it with the raw body:

```php
<?php
require __DIR__ . '/verify.php';

$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$body = file_get_contents('php://input');

if (!soosh_verify_webhook(getenv('SOOSH_WEBHOOK_SECRET'), $headers, $body)) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
error_log($event['type']);
http_response_code(204);
```

This code is tested against the Standard Webhooks test vector. If your
server's clock is wrong, every request looks too old or too new: keep it in
sync with NTP.

## 4. Answer within 10 seconds

Answer with any `2xx` status within 10 seconds. Anything else counts as a
failure, and so does a redirect: Soosh doesn't follow them. A failed request
is sent again later, for about 2 days. See
[Webhook delivery](https://docs.soosh.io/reference/webhook-delivery/#retries) for when.

If handling an event takes longer, save it, answer, and do the work
afterwards, for example from a queue.

> **Note:** After 10 failures in a row Soosh pauses the endpoint for a while. After 24
> hours of nothing but failures, and at least 20 failed tries, it turns the
> endpoint off and emails the people who manage webhooks. See
> [When an endpoint keeps failing](https://docs.soosh.io/reference/webhook-delivery/#when-an-endpoint-keeps-failing).

## 5. Skip events you've already handled

Soosh can send the same event more than once: when your server handled it
but answered too late, for example. A delivery keeps its `webhook-id` every
time it's sent: each retry, and a resend or replay from the app.

1. When a request comes in, look up its `webhook-id`. If you've handled it,
   answer `204` and stop.
2. Handle the event.
3. Only once it's handled, save its `webhook-id` and answer with a `2xx`.
   If handling fails, don't save it: answer with an error, and the event is
   sent again.
4. Keep the IDs for at least 30 days. Retries of a delivery go on for about
   2 days, but a failed delivery is kept for 30 days, and can be resent or
   replayed from the app until then.

Events can also arrive out of order. Order them by the times in `data`, such
as `at` in [`message.status`](https://docs.soosh.io/reference/api/webhook-events/message-status/),
not by when they arrive.

## 6. Send a test event

Go to [**Settings > Webhooks**](https://app.soosh.io/settings/webhooks), open the webhook and click **Test
webhook**. Soosh sends an event of type `webhook.test`, signed like every
other request. Its `data` is `{"test": true, "message": "This is a test event
from Soosh"}`. Your server should accept it.

If your server turns it away, check that you sign the raw body, that the
secret is the whole value starting `whsec_`, and that your clock is right.

## Result

Your server accepts requests from Soosh and turns away everything else, so
only real events reach your systems.

## Change the secret without missing events

If a secret may have leaked, give the endpoint a new one. In the app, click
**Rotate secret** on the webhook. Over the API, call
[`POST /v1/webhooks/{id}/rotate-secret`](https://docs.soosh.io/reference/api/webhooks/rotate-webhook-secret/).

You choose how long the old secret keeps signing, from 0 to 168 hours (24 by
default). Until then, every request carries both signatures, and the code
above accepts either. Put the new secret on your server, then let the old
one run out.

## Related

- [Webhook events](https://docs.soosh.io/reference/api/webhook-events/)
- [Webhook delivery](https://docs.soosh.io/reference/webhook-delivery/)
- [Create a webhook endpoint](https://docs.soosh.io/reference/api/webhooks/create-webhook/)
- [Rotate a webhook endpoint's secret](https://docs.soosh.io/reference/api/webhooks/rotate-webhook-secret/)
- [Set up a webhook](https://docs.soosh.io/guides/integrations/set-up-a-webhook/)
- [Handle errors and limits](https://docs.soosh.io/guides/api/errors-and-limits/)
