# Verify webhook signatures

Every [webhook](https://mobilenext.ai/docs/webhooks.md) request is signed with your webhook's secret. Verify the signature before you trust the body: anyone can send a `POST` to your URL, and a valid signature proves the request came from Mobile Next. The timestamp check limits replays to a 5-minute window; a copy replayed inside it still verifies, so also [deduplicate on the event id](https://mobilenext.ai/docs/webhooks.md#at-least-once-in-any-order) before you act on it.

The format is [Standard Webhooks](https://www.standardwebhooks.com/). The `standardwebhooks` and `svix` libraries verify it unchanged; the code below has no dependencies.

## How the signature is made[​](#how-the-signature-is-made "Direct link to How the signature is made")

Mobile Next sends three headers:

| Header              | Value                                                                  |
| ------------------- | ---------------------------------------------------------------------- |
| `webhook-id`        | The message id.                                                        |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds.                           |
| `webhook-signature` | One or more space-separated signatures, each `v1,` followed by base64. |

The secret looks like `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`. The part after `whsec_` is base64; decode it to get the key. The signature is:

```
v1,base64( HMAC-SHA256( key, webhook-id + "." + webhook-timestamp + "." + raw body ) )
```

To verify a request:

1. Read the raw body bytes. Do not parse and re-serialize the JSON; any change in spacing or key order breaks the signature.
2. Require exactly one `webhook-id`, `webhook-timestamp` and `webhook-signature` header. Reject the request if one is missing or repeated.
3. Require `webhook-timestamp` to be a decimal integer within 5 minutes of your clock, in either direction. This rejects replays of old requests.
4. Compute the HMAC-SHA256 of `webhook-id.webhook-timestamp.body` with the decoded key, and base64-encode it.
5. Split `webhook-signature` on spaces. Ignore values that don't start with `v1,`. The request is genuine if any remaining value equals your result. Compare in constant time.

While a [secret is rotated](https://mobilenext.ai/docs/webhooks.md#rotate-the-secret), the header holds two signatures, one made with the new secret and one with the previous secret. Each secret matches one of them, so a receiver that still has the previous secret keeps working until you deploy the new one.

Each function below returns `true` only for a genuine, fresh request and never throws on bad input. Load the secret from your configuration, never from source code. The optional `now` parameter is the current time in Unix seconds; pass it only in tests.

## Node.js[​](#nodejs "Direct link to Node.js")

```
const crypto = require("node:crypto");



const TOLERANCE_SECONDS = 5 * 60;



// rawBody: the request body as a Buffer, before any JSON parsing.

// id, timestamp, signature: the webhook-id, webhook-timestamp and

// webhook-signature header values (undefined when missing).

// Returns true only for a genuine, fresh delivery. Never throws on bad input.

function verifyWebhook(rawBody, id, timestamp, signature, secret, now = Date.now() / 1000) {

  if (typeof id !== "string" || typeof timestamp !== "string" || typeof signature !== "string") {

    return false;

  }

  if (!/^\d{1,12}$/.test(timestamp)) {

    return false;

  }

  if (Math.abs(Math.floor(now) - Number(timestamp)) > TOLERANCE_SECONDS) {

    return false;

  }



  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");

  const expected = Buffer.from(

    crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64"),

  );



  // During a secret rotation the header holds two signatures, separated by a space.

  return signature.split(" ").some((value) => {

    if (!value.startsWith("v1,")) {

      return false;

    }

    const candidate = Buffer.from(value.slice(3));

    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);

  });

}



module.exports = { verifyWebhook };
```

With Express, take the raw body for this route only. `req.get` joins a repeated header into one value, so read each header with `headersDistinct` (Node 18.3 or later) and pass it only when it appears once:

```
// The header's value when it appears exactly once, otherwise undefined.

function onlyHeader(req, name) {

  const values = req.headersDistinct[name];

  return values && values.length === 1 ? values[0] : undefined;

}



app.post("/webhooks/mobilenext", express.raw({ type: "application/json" }), (req, res) => {

  const ok = verifyWebhook(

    req.body,

    onlyHeader(req, "webhook-id"),

    onlyHeader(req, "webhook-timestamp"),

    onlyHeader(req, "webhook-signature"),

    process.env.MOBILENEXT_WEBHOOK_SECRET,

  );

  if (!ok) {

    return res.sendStatus(401);

  }

  // Store the event, then answer.

  res.sendStatus(204);

});
```

## Python[​](#python "Direct link to Python")

```
import base64

import hashlib

import hmac

import re

import time

from typing import Optional



TOLERANCE_SECONDS = 5 * 60





def verify_webhook(

    raw_body: bytes,

    msg_id: Optional[str],

    timestamp: Optional[str],

    signature: Optional[str],

    secret: str,

    now: Optional[float] = None,

) -> bool:

    """True only for a genuine, fresh delivery. raw_body is the unparsed request body."""

    if msg_id is None or timestamp is None or signature is None:

        return False

    if not re.fullmatch(r"[0-9]{1,12}", timestamp):

        return False

    now = time.time() if now is None else now

    if abs(int(now) - int(timestamp)) > TOLERANCE_SECONDS:

        return False



    key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)

    signed = msg_id.encode() + b"." + timestamp.encode() + b"." + raw_body

    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()



    # During a secret rotation the header holds two signatures, separated by a space.

    for value in signature.split(" "):

        # Compare bytes: compare_digest raises on a non-ASCII str.

        if value.startswith("v1,") and hmac.compare_digest(value[3:].encode(), expected.encode()):

            return True

    return False
```

In Flask, pass `request.get_data()` and each header only when `len(request.headers.getlist("webhook-id")) == 1`, and `None` otherwise. In Django, pass `request.body` and `request.headers.get(...)`. Django joins a repeated header into one value with a comma, so it cannot be refused outright, but a joined `webhook-id` or `webhook-timestamp` fails verification, and a joined `webhook-signature` verifies only if one of its values is a genuine signature.

## Go[​](#go "Direct link to Go")

```
package webhooks



import (

	"crypto/hmac"

	"crypto/sha256"

	"encoding/base64"

	"net/http"

	"regexp"

	"strconv"

	"strings"

	"time"

)



const tolerance = 5 * time.Minute



var timestampRe = regexp.MustCompile(`^[0-9]{1,12}$`)



// VerifyWebhook reports whether a delivery is genuine and fresh. body is the raw

// request body; each webhook-* header must appear exactly once.

func VerifyWebhook(body []byte, h http.Header, secret string, now time.Time) bool {

	ids, stamps, sigs := h.Values("Webhook-Id"), h.Values("Webhook-Timestamp"), h.Values("Webhook-Signature")

	if len(ids) != 1 || len(stamps) != 1 || len(sigs) != 1 {

		return false

	}

	if !timestampRe.MatchString(stamps[0]) {

		return false

	}

	ts, err := strconv.ParseInt(stamps[0], 10, 64)

	if err != nil || now.Sub(time.Unix(ts, 0)).Abs() > tolerance {

		return false

	}



	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))

	if err != nil {

		return false

	}

	mac := hmac.New(sha256.New, key)

	mac.Write([]byte(ids[0] + "." + stamps[0] + "."))

	mac.Write(body)

	expected := []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil)))



	// During a secret rotation the header holds two signatures, separated by a space.

	for _, value := range strings.Split(sigs[0], " ") {

		got, ok := strings.CutPrefix(value, "v1,")

		if ok && hmac.Equal([]byte(got), expected) {

			return true

		}

	}

	return false

}
```

In an `http.Handler`, read the body with `io.ReadAll(r.Body)` and pass `r.Header` and `time.Now()`.

## Ruby[​](#ruby "Direct link to Ruby")

Needs Ruby 2.7 or later.

```
require "base64"

require "openssl"



TOLERANCE_SECONDS = 5 * 60



# raw_body is the unparsed request body; msg_id, timestamp and signature are the

# webhook-id, webhook-timestamp and webhook-signature header values (nil when missing).

def verify_webhook(raw_body, msg_id, timestamp, signature, secret, now: Time.now.to_i)

  return false if msg_id.nil? || timestamp.nil? || signature.nil?

  return false unless timestamp.match?(/\A[0-9]{1,12}\z/)

  return false if (now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS



  key = Base64.strict_decode64(secret.delete_prefix("whsec_"))

  signed = "#{msg_id}.#{timestamp}.".b + raw_body.b

  expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, signed))



  # During a secret rotation the header holds two signatures, separated by a space.

  signature.split(" ").any? do |value|

    value.start_with?("v1,") && OpenSSL.secure_compare(value.delete_prefix("v1,"), expected)

  end

end
```

In Rails, pass `request.raw_post` and `request.headers["webhook-id"]` and so on. Rack joins a repeated header into one value with a comma: a joined `webhook-id` or `webhook-timestamp` fails verification, and a joined `webhook-signature` verifies only if one of its values is a genuine signature.

## Java[​](#java "Direct link to Java")

```
import java.nio.charset.StandardCharsets;

import java.security.MessageDigest;

import java.time.Instant;

import java.util.Base64;

import javax.crypto.Mac;

import javax.crypto.spec.SecretKeySpec;



public final class WebhookVerifier {

    private static final long TOLERANCE_SECONDS = 5 * 60;



    /** rawBody is the unparsed request body; the header values are null when missing. */

    public static boolean verify(byte[] rawBody, String id, String timestamp, String signature,

                                 String secret, long nowSeconds) {

        if (id == null || timestamp == null || signature == null) {

            return false;

        }

        if (!timestamp.matches("[0-9]{1,12}")) {

            return false;

        }

        if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {

            return false;

        }



        byte[] expected;

        try {

            byte[] key = Base64.getDecoder().decode(secret.replaceFirst("^whsec_", ""));

            Mac mac = Mac.getInstance("HmacSHA256");

            mac.init(new SecretKeySpec(key, "HmacSHA256"));

            mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));

            expected = Base64.getEncoder().encode(mac.doFinal(rawBody));

        } catch (Exception e) {

            return false;

        }



        // During a secret rotation the header holds two signatures, separated by a space.

        for (String value : signature.split(" ")) {

            if (value.startsWith("v1,")

                    && MessageDigest.isEqual(value.substring(3).getBytes(StandardCharsets.UTF_8), expected)) {

                return true;

            }

        }

        return false;

    }



    public static boolean verify(byte[] rawBody, String id, String timestamp, String signature, String secret) {

        return verify(rawBody, id, timestamp, signature, secret, Instant.now().getEpochSecond());

    }

}
```

## .NET[​](#net "Direct link to .NET")

Needs .NET 6 or later.

```
using System;

using System.Security.Cryptography;

using System.Text;

using System.Text.RegularExpressions;



public static class WebhookVerifier

{

    private const long ToleranceSeconds = 5 * 60;



    // rawBody is the unparsed request body; the header values are null when missing.

    public static bool Verify(byte[] rawBody, string? id, string? timestamp, string? signature,

                              string secret, long? nowSeconds = null)

    {

        if (id is null || timestamp is null || signature is null)

        {

            return false;

        }

        if (!Regex.IsMatch(timestamp, "^[0-9]{1,12}$"))

        {

            return false;

        }

        long now = nowSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();

        if (Math.Abs(now - long.Parse(timestamp)) > ToleranceSeconds)

        {

            return false;

        }



        byte[] key;

        try

        {

            key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);

        }

        catch (FormatException)

        {

            return false;

        }

        byte[] prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");

        byte[] signed = new byte[prefix.Length + rawBody.Length];

        prefix.CopyTo(signed, 0);

        rawBody.CopyTo(signed, prefix.Length);

        byte[] expected = Encoding.ASCII.GetBytes(Convert.ToBase64String(HMACSHA256.HashData(key, signed)));



        // During a secret rotation the header holds two signatures, separated by a space.

        foreach (string value in signature.Split(' '))

        {

            if (value.StartsWith("v1,") &&

                CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(value[3..]), expected))

            {

                return true;

            }

        }

        return false;

    }

}
```

In ASP.NET Core, read the body into a byte array before anything else reads it, and pass each header's value only when `Request.Headers["webhook-id"].Count == 1`, and `null` otherwise.

## Test vectors[​](#test-vectors "Direct link to Test vectors")

Check your code with these values. They are signed with the same code Mobile Next uses. Pass `now` explicitly, or the timestamp is stale.

|                                | Value                                             |
| ------------------------------ | ------------------------------------------------- |
| Secret                         | `whsec_Me7mpKXPB0KdmoMmq4x6R1h8dXElmlii`          |
| Previous secret (for rotation) | `whsec_vXc7xhXm8AT0rbx5lxTikYXuCh6kA80A`          |
| `webhook-id`                   | `2f6b1c3a-8d4e-4b7f-9a1c-5e2d3f4a6b7c`            |
| `webhook-timestamp`            | `1790000000`                                      |
| `webhook-signature`            | `v1,1xPyhRV/UPW2y1MaASMXIt9Qrrq4UpaskY7bYNvk2LE=` |
| `now`                          | `1790000000`                                      |

Body, exactly these bytes with no trailing newline:

```
{"id":"5d2a8f3e-1b4c-4d6e-9f0a-2b3c4d5e6f7a","object":"event","type":"webhook.test","apiVersion":"2026-09-22","createdAt":"2026-09-21T14:13:20.117Z","data":{"webhook":"1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a"}}
```

Your function must return `true` for:

| Case                             | Change from the values above                                                                                             |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Valid                            | None.                                                                                                                    |
| Valid, at the edge of the window | `now` is `1790000300`.                                                                                                   |
| Rotation, current secret         | `webhook-signature` is `v1,1xPyhRV/UPW2y1MaASMXIt9Qrrq4UpaskY7bYNvk2LE= v1,XPw2uHUkKKS/ihgeWUqtMfGlQBPwB2qEhIaBq35+20E=` |
| Rotation, previous secret        | Same two signatures; verify with the previous secret.                                                                    |

And `false` for:

| Case                      | Change from the values above                                             |
| ------------------------- | ------------------------------------------------------------------------ |
| Tampered body             | `webhook.test` in the body becomes `webhook.tesT`.                       |
| Bad signature             | `webhook-signature` is `v1,1xPyhRVAUPW2y1MaASMXIt9Qrrq4UpaskY7bYNvk2LE=` |
| Stale timestamp           | `now` is `1790000301`.                                                   |
| Timestamp from the future | `now` is `1789999699`.                                                   |
| Wrong secret              | Secret is `whsec_Wa3HUzMgBp6ILGNYuY6PoJu4208ZunMj`                       |
| Rotation, wrong secret    | The two rotation signatures; verify with the wrong secret above.         |
| Missing header            | No `webhook-signature` header, or no `webhook-id`.                       |
| Repeated header           | Two `webhook-signature` headers.                                         |
| Non-numeric timestamp     | `webhook-timestamp` is `1790000000.0`.                                   |
| Unknown version only      | `webhook-signature` is `v2,` and the valid signature's base64.           |
| Other message id          | `webhook-id` is `0c9e2b6a-1111-4222-8333-944455556666`.                  |

## Common mistakes[​](#common-mistakes "Direct link to Common mistakes")

* **Verifying a parsed body.** `JSON.stringify(req.body)` is not the body that was signed. Verify the raw bytes, then parse.
* **Decoding the whole secret.** Remove `whsec_` before base64-decoding it.
* **Comparing with `==`.** Use the constant-time comparison shown above.
* **A slow clock.** Requests more than 5 minutes from your server's clock fail. Keep it synced with NTP.
* **A rotated or another webhook's secret.** Each webhook has its own secret. After **Rotate immediately**, only the new secret works.
