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:
| 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:
- Read the raw body bytes. Do not parse and re-serialize the JSON; any change in spacing or key order breaks the signature.
- Require exactly one
webhook-id,webhook-timestampandwebhook-signatureheader. Reject the request if one is missing or repeated. - Require
webhook-timestampto be a decimal integer within 5 minutes of your clock, in either direction. This rejects replays of old requests. - Compute the HMAC-SHA256 of
webhook-id.webhook-timestamp.bodywith the decoded key, and base64-encode it. - Split
webhook-signatureon spaces. Ignore values that don't start withv1,. 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 | |
|---|---|
| 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
- 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.