# Verify webhook signatures

Source: https://docs.revoplyai.com/guides/verify-webhook-signatures/

> Verify X-Revoply-Signature in Node.js, Python, PHP or C#, get the raw body in Express, Flask, Laravel and ASP.NET, and debug failures.

Every webhook request carries `X-Revoply-Signature: t=…,v1=…`. Verifying it proves the
request came from us, was not changed, and is recent. The rules are on
[Signatures](/webhooks/signatures/); this guide puts them into code.

## 1. Add a verifier [#1-add-a-verifier]

Copy the verifier for your language into your project. Each is a single function with no
dependencies, and each passes the published [test vectors](/webhooks/signatures/#test-vectors).

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="PHP">
      PHP
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="C#">
      C#
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```js
    // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Node.js 18+, no packages).
    //
    //   X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs]
    //
    // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included.
    // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/.

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

    /** How far the signed time may be from your clock before the request is refused. */
    export const TOLERANCE_SECONDS = 300;

    /**
     * @param {Buffer | string} rawBody The body exactly as it arrived, before any JSON parsing.
     * @param {string | undefined} header The X-Revoply-Signature header.
     * @param {string | string[]} secrets Your whsec_… secret. While you switch to a rotated
     *   secret, pass both.
     * @param {{ now?: number, toleranceSeconds?: number }} [options] `now` in Unix seconds.
     * @returns {boolean}
     */
    export function verifyRevoplySignature(rawBody, header, secrets, options = {}) {
      if (typeof header !== 'string' || header.length === 0) return false;

      let timestamp;
      const signatures = [];
      for (const part of header.split(',')) {
        const index = part.indexOf('=');
        if (index === -1) continue;
        const key = part.slice(0, index).trim();
        const value = part.slice(index + 1).trim();
        if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
        else if (key === 'v1') signatures.push(value);
      }
      if (timestamp === undefined || signatures.length === 0) return false;

      const now = options.now ?? Math.floor(Date.now() / 1000);
      const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS;
      if (Math.abs(now - timestamp) > tolerance) return false;

      const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');

      for (const secret of [secrets].flat()) {
        if (!secret) continue;
        const expected = Buffer.from(
          createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
        );
        for (const signature of signatures) {
          const given = Buffer.from(signature);
          if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
        }
      }
      return false;
    }

    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    """Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Python 3.9+, standard library).

        X-Revoply-Signature: t=1790847005,v1=3f96ce90...[,v1=... while a rotated secret still signs]

    v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included.
    Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/.
    """

    import hashlib
    import hmac
    import time
    from typing import Iterable, Optional, Union

    # How far the signed time may be from your clock before the request is refused.
    TOLERANCE_SECONDS = 300


    def verify_revoply_signature(
        raw_body: Union[bytes, str],
        header: Optional[str],
        secrets: Union[str, Iterable[str]],
        now: Optional[int] = None,
        tolerance_seconds: int = TOLERANCE_SECONDS,
    ) -> bool:
        """raw_body is the body exactly as it arrived. While you switch to a rotated secret,
        pass both secrets."""
        if not header:
            return False

        timestamp = None
        signatures = []
        for part in header.split(","):
            key, sep, value = part.partition("=")
            if not sep:
                continue
            key, value = key.strip(), value.strip()
            if key == "t" and value.isdigit():
                timestamp = int(value)
            elif key == "v1":
                signatures.append(value)

        if timestamp is None or not signatures:
            return False

        current = int(time.time()) if now is None else now
        if abs(current - timestamp) > tolerance_seconds:
            return False

        body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
        for secret in [secrets] if isinstance(secrets, str) else secrets:
            if not secret:
                continue
            expected = hmac.new(
                secret.encode("utf-8"), f"{timestamp}.".encode("ascii") + body, hashlib.sha256
            ).hexdigest()
            if any(hmac.compare_digest(expected, signature) for signature in signatures):
                return True
        return False

    ```
  </CodeBlockTab>

  <CodeBlockTab value="PHP">
    ```php
    <?php
    // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (PHP 8.0+, no packages).
    //
    //   X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs]
    //
    // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included.
    // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/.

    declare(strict_types=1);

    // How far the signed time may be from your clock before the request is refused.
    const REVOPLY_TOLERANCE_SECONDS = 300;

    /**
     * @param string          $rawBody The body exactly as it arrived (php://input), before json_decode.
     * @param string|null     $header  The X-Revoply-Signature header.
     * @param string|string[] $secrets Your whsec_… secret. While you switch to a rotated secret, pass both.
     * @param int|null        $now     Unix seconds; the current time when null.
     */
    function revoply_verify_signature(
        string $rawBody,
        ?string $header,
        string|array $secrets,
        ?int $now = null,
        int $toleranceSeconds = REVOPLY_TOLERANCE_SECONDS
    ): bool {
        if ($header === null || $header === '') {
            return false;
        }

        $timestamp = null;
        $signatures = [];
        foreach (explode(',', $header) as $part) {
            $pair = explode('=', $part, 2);
            if (count($pair) !== 2) {
                continue;
            }
            [$key, $value] = [trim($pair[0]), trim($pair[1])];
            if ($key === 't' && ctype_digit($value)) {
                $timestamp = (int) $value;
            } elseif ($key === 'v1') {
                $signatures[] = $value;
            }
        }

        if ($timestamp === null || $signatures === []) {
            return false;
        }

        if (abs(($now ?? time()) - $timestamp) > $toleranceSeconds) {
            return false;
        }

        foreach ((array) $secrets as $secret) {
            if ($secret === '') {
                continue;
            }
            $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
            foreach ($signatures as $signature) {
                if (hash_equals($expected, $signature)) {
                    return true;
                }
            }
        }

        return false;
    }

    ```
  </CodeBlockTab>

  <CodeBlockTab value="C#">
    ```csharp
    // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (.NET 8+, no packages).
    //
    //   X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs]
    //
    // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included.
    // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/.

    using System.Globalization;
    using System.Security.Cryptography;
    using System.Text;

    public static class RevoplySignature
    {
        /// <summary>How far the signed time may be from your clock before the request is refused.</summary>
        public const int ToleranceSeconds = 300;

        /// <param name="rawBody">The body exactly as it arrived, before any JSON parsing.</param>
        /// <param name="header">The X-Revoply-Signature header.</param>
        /// <param name="secrets">Your whsec_… secret. While you switch to a rotated secret, pass both.</param>
        /// <param name="now">Unix seconds; the current time when null.</param>
        public static bool Verify(
            byte[] rawBody,
            string? header,
            IEnumerable<string> secrets,
            long? now = null,
            int toleranceSeconds = ToleranceSeconds)
        {
            if (string.IsNullOrEmpty(header))
            {
                return false;
            }

            long? timestamp = null;
            var signatures = new List<string>();

            foreach (var part in header.Split(','))
            {
                var pair = part.Split('=', 2);
                if (pair.Length != 2)
                {
                    continue;
                }

                var key = pair[0].Trim();
                var value = pair[1].Trim();

                if (key == "t" && long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var t))
                {
                    timestamp = t;
                }
                else if (key == "v1")
                {
                    signatures.Add(value);
                }
            }

            if (timestamp is not { } signedAt || signatures.Count == 0)
            {
                return false;
            }

            var current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            if (Math.Abs(current - signedAt) > toleranceSeconds)
            {
                return false;
            }

            var prefix = Encoding.ASCII.GetBytes(signedAt.ToString(CultureInfo.InvariantCulture) + ".");

            foreach (var secret in secrets)
            {
                if (string.IsNullOrEmpty(secret))
                {
                    continue;
                }

                using var hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret));
                hmac.AppendData(prefix);
                hmac.AppendData(rawBody);
                var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac.GetHashAndReset()).ToLowerInvariant());

                foreach (var signature in signatures)
                {
                    if (CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(signature)))
                    {
                        return true;
                    }
                }
            }

            return false;
        }
    }

    ```
  </CodeBlockTab>
</CodeBlockTabs>

## 2. Get the raw body [#get-the-raw-body]

The signature covers the body exactly as we sent it. Most frameworks parse JSON before
your handler runs, and a body that was parsed and serialised again differs from ours in
key order, spacing or the escaping of Arabic text, so it never verifies. Read the raw
bytes instead.

### Express [#express]

Use `express.raw()` on the webhook route, registered before any global
`app.use(express.json())`. `req.body` is then a `Buffer`.

```js
// express.raw() on this route, registered before any app.use(express.json()): the signature
// covers the exact bytes we sent, and a parsed-then-re-serialised body is not those bytes.
app.post('/webhooks/revoply', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyRevoplySignature(req.body, req.get('X-Revoply-Signature'), secret)) {
    return res.status(400).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // The same event can arrive more than once: acknowledge a repeat and do nothing.
  if (handled.has(event.id)) return res.sendStatus(200);
  handled.add(event.id);

  // Answer within 10 seconds, then do the work.
  res.sendStatus(200);
  setImmediate(() => handleEvent(event));
});
```

### Flask [#flask]

Use `request.get_data()`, never `json.dumps(request.json)`.

```python
@app.post("/webhooks/revoply")
def revoply_webhook():
    # get_data() is the body as it arrived. Never verify json.dumps(request.json):
    # re-serialised JSON is not the bytes we signed.
    raw_body = request.get_data()
    if not verify_revoply_signature(raw_body, request.headers.get("X-Revoply-Signature"), SECRET):
        return "Invalid signature", 400

    event = json.loads(raw_body)
    if event["id"] in handled:
        return "", 200
    handled.add(event["id"])

    # Answer within 10 seconds; hand slow work to a queue.
    print(event["type"], "(test)" if event["test"] else "")
    return "", 200
```

### Laravel [#laravel]

Use `$request->getContent()`, never `json_encode($request->all())`. Put the route in
`routes/api.php`, which has no CSRF check.

```php
public function __invoke(Request $request)
{
    // getContent() is the body as it arrived. Never verify json_encode($request->all()):
    // re-encoded JSON is not the bytes we signed.
    $rawBody = $request->getContent();

    if (!revoply_verify_signature($rawBody, $request->header('X-Revoply-Signature'), (string) config('services.revoply.webhook_secret'))) {
        return response('Invalid signature', 400);
    }

    $event = json_decode($rawBody, true);

    // The same event can arrive more than once: acknowledge a repeat and do nothing.
    if (!Cache::add('revoply-event:' . $event['id'], true, now()->addDays(30))) {
        return response('', 200);
    }

    // Answer within 10 seconds; queue slow work (dispatch a job) instead of doing it here.
    logger()->info('RevoplyAI event', ['type' => $event['type'], 'test' => $event['test']]);

    return response('', 200);
}
```

In plain PHP, read `php://input`; `$_POST` is empty for JSON bodies.

```php
// php://input is the body as it arrived; $_POST is empty for JSON.
$rawBody = file_get_contents('php://input');

if (!revoply_verify_signature($rawBody, $_SERVER['HTTP_X_REVOPLY_SIGNATURE'] ?? null, (string) getenv('REVOPLY_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

// Deduplicate on $event['id'] in your database, then answer within 10 seconds.
error_log(sprintf('RevoplyAI %s %s%s', $event['id'], $event['type'], $event['test'] ? ' (test)' : ''));
http_response_code(200);
```

### ASP.NET Core [#aspnet-core]

Take `HttpRequest` rather than binding a model with `[FromBody]`, call
`EnableBuffering()`, copy the body, then rewind it so later code can read it again.

```csharp
app.MapPost("/webhooks/revoply", async (HttpRequest request) =>
{
    // Verify the bytes that arrived. If anything reads the body before this handler
    // ([FromBody] binding, a logging middleware), buffer it and rewind, or there is
    // nothing left to verify.
    request.EnableBuffering();
    using var copy = new MemoryStream();
    await request.Body.CopyToAsync(copy);
    request.Body.Position = 0;
    var rawBody = copy.ToArray();

    string? header = request.Headers["X-Revoply-Signature"];
    if (!RevoplySignature.Verify(rawBody, header, [secret]))
    {
        return Results.BadRequest();
    }

    using var envelope = JsonDocument.Parse(rawBody);
    var id = envelope.RootElement.GetProperty("id").GetString()!;
    var type = envelope.RootElement.GetProperty("type").GetString();
    var isTest = envelope.RootElement.GetProperty("test").GetBoolean();

    // The same event can arrive more than once: acknowledge a repeat and do nothing.
    if (!Handled.TryAdd(id, true))
    {
        return Results.Ok();
    }

    // Answer within 10 seconds; queue the real work instead of doing it here.
    Console.WriteLine($"{id} {type}{(isTest ? " (test)" : "")}");
    return Results.Ok();
});
```

## 3. Test it [#3-test-it]

1. Run your verifier against the vectors: for each entry in
   [`webhook-signature-vectors.json`](/openapi/webhook-signature-vectors.json), verify
   `header` against `body` with each secret in `verifyWith`, at the time `now`, and compare
   the result with `valid`.
2. Send a test from **Integrations → Webhooks → Send test**. The dashboard shows what your
   endpoint answered.
3. Change one character of the secret in your configuration and send another test: your
   receiver must answer `400`.

## When verification fails [#when-verification-fails]

| Symptom                                  | Cause                                                                                                           |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Fails for every request                  | The body was parsed before verification; see step 2.                                                            |
| Fails for every request                  | The key is not the whole secret. Use the string as shown, `whsec_` included, as UTF-8; do not base64-decode it. |
| Fails for every request                  | The wrong endpoint's secret: each endpoint has its own.                                                         |
| Fails only for messages with Arabic text | The body was decoded and re-encoded in another encoding. Hash the bytes as received.                            |
| Fails every time, with a correct HMAC    | The server's clock is more than 5 minutes off. Synchronise it.                                                  |
| Fails after a rotation                   | The new secret is not deployed yet. Accept both secrets during the switch.                                      |
| Fails only behind one proxy              | The proxy rewrites the body (compression, re-encoding). Verify before it, or pass the body through untouched.   |

Compare signatures in constant time, as the verifiers above do: `==` on strings leaks how
much of a guess was right.
