Skip to main content
View as Markdown

Verify webhook signatures

Every webhook 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 before you act on it.

The format is Standard Webhooks. The standardwebhooks and svix libraries verify it unchanged; the code below has no dependencies.

How the signature is made​

Mobile Next sends three headers:

HeaderValue
webhook-idThe message id.
webhook-timestampWhen this attempt was sent, in Unix seconds.
webhook-signatureOne 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, 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​

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​

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​

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​

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​

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​

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​

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
Secretwhsec_Me7mpKXPB0KdmoMmq4x6R1h8dXElmlii
Previous secret (for rotation)whsec_vXc7xhXm8AT0rbx5lxTikYXuCh6kA80A
webhook-id2f6b1c3a-8d4e-4b7f-9a1c-5e2d3f4a6b7c
webhook-timestamp1790000000
webhook-signaturev1,1xPyhRV/UPW2y1MaASMXIt9Qrrq4UpaskY7bYNvk2LE=
now1790000000

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:

CaseChange from the values above
ValidNone.
Valid, at the edge of the windownow is 1790000300.
Rotation, current secretwebhook-signature is v1,1xPyhRV/UPW2y1MaASMXIt9Qrrq4UpaskY7bYNvk2LE= v1,XPw2uHUkKKS/ihgeWUqtMfGlQBPwB2qEhIaBq35+20E=
Rotation, previous secretSame two signatures; verify with the previous secret.

And false for:

CaseChange from the values above
Tampered bodywebhook.test in the body becomes webhook.tesT.
Bad signaturewebhook-signature is v1,1xPyhRVAUPW2y1MaASMXIt9Qrrq4UpaskY7bYNvk2LE=
Stale timestampnow is 1790000301.
Timestamp from the futurenow is 1789999699.
Wrong secretSecret is whsec_Wa3HUzMgBp6ILGNYuY6PoJu4208ZunMj
Rotation, wrong secretThe two rotation signatures; verify with the wrong secret above.
Missing headerNo webhook-signature header, or no webhook-id.
Repeated headerTwo webhook-signature headers.
Non-numeric timestampwebhook-timestamp is 1790000000.0.
Unknown version onlywebhook-signature is v2, and the valid signature's base64.
Other message idwebhook-id is 0c9e2b6a-1111-4222-8333-944455556666.

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.