# Signatures

Source: https://docs.revoplyai.com/webhooks/signatures/

> How X-Revoply-Signature is computed and exactly how to verify it, with test vectors and verifiers in four languages.

Every request we send carries an `X-Revoply-Signature` header. Verify it before you trust
the body: it proves the request came from us, was not changed on the way, and is recent.

```http
X-Revoply-Signature: t=1790847005,v1=3f96ce90286a8c1479a3ae099a34b3b2db87392c627ffbe8e8c7b3956aa878a0
```

## How it is computed [#how-it-is-computed]

1. `t` is the time of signing, in Unix seconds.
2. The signed payload is `t`, a full stop, and the request body exactly as sent (UTF-8
   bytes): `1790847005.{"id":"evt_…",…}`.
3. `v1` is the HMAC-SHA256 of the signed payload, in lower-case hexadecimal. The key is the
   endpoint's **whole secret as a string, `whsec_` included**, as UTF-8 bytes. Do not
   decode the part after `whsec_`.

While a rotated secret still signs (for 24 hours after a rotation), the header carries two
`v1` values, one per secret:

```http
X-Revoply-Signature: t=1790847005,v1=3f96ce90…,v1=20fd3acd…
```

## How to verify it [#how-to-verify-it]

1. Read the **raw body** before any JSON parsing. A body that was parsed and serialised
   again is not the bytes we signed, even when it looks the same. See the
   [raw-body pitfalls](/guides/verify-webhook-signatures/#get-the-raw-body) for your
   framework.
2. Split the header on `,`, and each part on its first `=`. Take `t`, and every `v1`.
   Ignore keys you do not know: other schemes may be added.
3. Refuse the request if there is no `t` or no `v1`, or if `t` is more than **300 seconds**
   away from your clock in either direction. This stops a captured request being replayed
   later. Keep your server's clock synchronised.
4. Compute the HMAC-SHA256 of `{t}.{raw body}` with your secret. While you move to a
   rotated secret, compute it with both.
5. Accept the request if any computed value equals any `v1`. Compare in constant time.

Answer a request that fails verification with `400` and do not act on it. It is recorded
as a failed delivery and retried, which helps if the cause was a secret you had not
deployed yet.

## Verifiers [#verifiers]

Each of these passes every [test vector](#test-vectors). Copy it into your project as it
is.

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

For a whole receiver, see [Receive messages with webhooks](/guides/receive-messages-with-webhooks/).

## Test vectors [#test-vectors]

[`webhook-signature-vectors.json`](/openapi/webhook-signature-vectors.json) holds signed
requests generated by the code that signs our deliveries: an ASCII body, an Arabic body,
a rotation with two `v1` values, and three that must fail (a changed body, a stale
timestamp, the wrong secret).

For each vector, verify `header` against `body` with each secret in `verifyWith`, taking
`now` as the current time. The result must equal `valid`.

## Rotation [#rotation]

When you rotate a secret, deliveries are signed with both the old and the new secret for
24 hours. Deploy the new secret within that time; during the switch, accept either. After
a leak, rotate twice to retire the leaked secret at once. See
[Security](/get-started/security/#rotating-a-webhook-secret).
