Verify a webhook signature
The signature on a One API webhook, the five steps that check it, and a working implementation in four languages.
On this page5 sections
Build with AI 5 packages
A build package is every page for one task, with the API operations they use. Copy the prompt into a coding assistant, or hand it the package itself: slim links to each page, full inlines all of them in one document.
- Take a paymentEverything needed to take a payment end to end with One API, from credentials through the hosted page to the webhook that confirms it, and the test cases that prove each outcome before you go live.
- Take a recurring paymentEverything needed to collect from a customer on a schedule with One API, from the consent the customer gives once through to each collection and the webhook that reports it.
- Migrate a payin from the Payments API to One APIEverything needed to move an existing redirect payin onto One API, with the legacy guide and its One API counterpart side by side.
Your webhookWebhook A URL of yours that Ozow calls when something happens, rather than you polling to find out. The call carries no credential of yours and arrives at a public URL, so authenticate it before acting on it: a hash field on the Payments API, a Svix signature on One API. URL is public. Anyone who finds it can post a transaction.complete to it claiming a
payment succeeded, and the only thing separating that from a real delivery is the signature. Check
it before you read a single field of the body.
This page covers the One API webhook signature. The Payments API notification is authenticated
differently, with a Hash field over the payload: see the
hash calculator for that one.
Important
Verify against the exact bytes you received. The signature covers the body
byte for byte, and a framework that parses the request as JSON and hands you the object has
already thrown those bytes away: serialising it back reorders keys and changes whitespace, and
nothing matches. Reach for the raw body explicitly. In Express that is express.raw(), in
ASP.NET Core EnableBuffering and reading the stream yourself, in Flask request.get_data(),
in Laravel $request->getContent().
Use the library where you can
Ozow delivers webhooks through Svix, and Svix publishes a verification library for most languages. It gets the constant-time comparison, the replay window and the signature list right, and it is the shortest path to a correct handler.
// svix 2.x. `verify` throws on a bad signature and returns nothing.
import { Webhook } from "svix";
const webhook = new Webhook(process.env.OZOW_WEBHOOK_SECRET);
webhook.verify(rawBody, {
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"],
});
const event = JSON.parse(rawBody);
Pin the major version, and read its signature before you upgrade
verify returned the
parsed body on svix 1.x and returns nothing on 2.x. A handler that keeps const event = webhook.verify(...) across that upgrade reads undefined, fails after it has already replied
200, and looks from our side like a delivery that succeeded.
The rest of this page is what that library does, for when you would rather not add one.
The algorithm
Every delivery carries three headers:
| Header | What it holds |
|---|---|
svix-id |
The message identifier, unchanged across retries of the same event |
svix-timestamp |
When the delivery was signed, in seconds since the epoch |
svix-signature |
One or more signatures, space separated, each written v1,<base64> |
Five steps, and all five are load bearing:
- Require all three headers. A delivery missing any of them is not one of ours.
- Check the timestamp is within five minutes of now, in either direction. Without this, a signature captured once stays valid forever and a recorded delivery can be replayed at will.
- Turn the secret into key bytes. The secret from
Get Webhook Secret arrives as
whsec_followed by Base64Base64 A way of writing binary data using ordinary text characters, so it can travel inside JSON or a URL. It is an encoding, not encryption: anyone can decode it.Wikipedia. Strip the prefix and Base64-decode the rest. The key is those raw bytes, not the string. - Build the signed string and take its HMACHMAC A hash of a message combined with a secret key, so the result proves both that the message is unchanged and that the sender held the key. A plain hash proves only the first: anybody can compute one. This is what makes a webhook signature worth checking.RFC 2104. The string is the message id, the timestamp and
the raw body joined by full stops:
{svix-id}.{svix-timestamp}.{body}. HMAC-SHA256 it with the key bytes and Base64-encode the digest. - Compare against every
v1signature in the header, with a constant-time comparison. The header can carry more than one while a secret is being rotated, and a match on any of them is a pass. Ignore any entry whose version is notv1.
A verifier
Each of these returns true only if the delivery is genuine, current and intact. None of them needs a dependency beyond the standard library.
using System.Security.Cryptography;
using System.Text;
const int ToleranceSeconds = 300;
static bool IsFromOzow(
string secret,
string svixId,
string svixTimestamp,
string svixSignature,
string rawBody
)
{
if (!long.TryParse(svixTimestamp, out var sent))
return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sent) > ToleranceSeconds)
return false;
var key = Convert.FromBase64String(
secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret["whsec_".Length..] : secret
);
using var hmac = new HMACSHA256(key);
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{svixId}.{sent}.{rawBody}"));
var expected = Encoding.UTF8.GetBytes(Convert.ToBase64String(digest));
foreach (var candidate in svixSignature.Split(' '))
{
var parts = candidate.Split(',', 2);
if (parts.Length != 2 || parts[0] != "v1")
continue;
// Returns false on a length mismatch rather than throwing, and takes the
// same time whether the first byte differs or the last one does.
if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(parts[1]), expected))
return true;
}
return false;
}
<?php
const TOLERANCE_SECONDS = 300;
function isFromOzow(
string $secret,
string $svixId,
string $svixTimestamp,
string $svixSignature,
string $rawBody,
): bool {
if (!ctype_digit($svixTimestamp)) {
return false;
}
$sent = (int) $svixTimestamp;
if (abs(time() - $sent) > TOLERANCE_SECONDS) {
return false;
}
$key = base64_decode(
str_starts_with($secret, "whsec_") ? substr($secret, 6) : $secret,
true,
);
if ($key === false) {
return false;
}
$expected = base64_encode(
hash_hmac("sha256", "{$svixId}.{$sent}.{$rawBody}", $key, true),
);
foreach (explode(" ", $svixSignature) as $candidate) {
[$version, $signature] = array_pad(
explode(",", $candidate, 2),
2,
null,
);
if ($version !== "v1" || $signature === null) {
continue;
}
// hash_equals, never ===. A plain comparison returns early on the first
// differing byte and leaks how much of a guess was right.
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
/** `rawBody` is a Buffer, straight off the request. Never a re-serialised object. */
function isFromOzow(secret, headers, rawBody) {
const id = headers["svix-id"];
const timestamp = headers["svix-timestamp"];
const signature = headers["svix-signature"];
if (!id || !timestamp || !signature) return false;
const sent = Number(timestamp);
if (!Number.isInteger(sent)) return false;
if (Math.abs(Date.now() / 1000 - sent) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
createHmac("sha256", key)
.update(`${id}.${sent}.`)
.update(rawBody)
.digest("base64"),
);
return signature.split(" ").some((candidate) => {
const [version, value] = candidate.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value);
// timingSafeEqual throws on a length mismatch, so the lengths are checked first.
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def is_from_ozow(
secret: str,
svix_id: str,
svix_timestamp: str,
svix_signature: str,
raw_body: bytes,
) -> bool:
try:
sent = int(svix_timestamp)
except ValueError:
return False
if abs(time.time() - sent) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{svix_id}.{sent}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for candidate in svix_signature.split(" "):
version, _, signature = candidate.partition(",")
if version != "v1":
continue
if hmac.compare_digest(expected, signature):
return True
return False
What goes wrong
The first two announce themselves the moment you test. The last three pass every test you are likely to write and fail in production, which is why they are worth reading twice.
| Mistake | What happens |
|---|---|
| The secret used as a string | The key is the Base64-decoded bytes after whsec_. Using the characters gives a digest that never matches anything. |
| The body parsed before it is verified | Re-serialising changes the bytes. Nothing matches, on every delivery. |
| No timestamp check | Every signature stays valid forever. One captured delivery can be replayed for as long as the secret lives. |
== instead of a constant-time compare |
The comparison returns as soon as two bytes differ, and how long it took says how much of a guess was right. |
| Only the first signature checked | svix-signature carries both the old and the new signature while a secret is rotated. A verifier that reads one of them starts rejecting real deliveries mid-rotation. |
Check your verifier
Start offline. Svix publishes a signature you can check against without sending anything, which separates a wrong implementation from a wrong endpoint before either can confuse the other:
secret whsec_plJ3nmyCDGBKInavdOK15jsl
body {"event_type":"ping","data":{"success":true}}
svix-id msg_loFOjxBNrRLzqYUf
timestamp 1731705121
signature v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0=
Feed those five values to your verifier and it must produce that signature. The timestamp is from
2024, so a verifier that checks the replay window rejects the delivery even when the signature is
right: check the signature it computed rather than the answer it returned, or hold the clock at
1731705121 for the test.
Then send yourself a real delivery and confirm all four of these, in this order:
- An untouched delivery passes. Anything else and the rest of the list means nothing.
- One changed byte of the body fails. Change a digit of the amount and replay it.
- One changed character of
svix-signaturefails. - The same delivery replayed an hour later fails. If it passes, step 2 of the algorithm is missing.
A verifier that rejects a delivery must log it and alert. A signature that does not match is either a bug of ours or somebody probing your endpoint, and both are worth a person looking at them. Never discard one quietly.
In the API reference
3 entries
Last updated