Verifying webhook signatures
Every webhook notification sent by Hubpay includes a signature that you must verify before trusting the payload. This prevents attackers from forging requests to your endpoint.
How it works
Each webhook request includes three headers that together allow you to verify authenticity:
| Header | Purpose |
|---|---|
webhook-id | Unique message ID |
webhook-timestamp | Message timestamp (Unix seconds) |
webhook-signature | Versioned signature(s) |
Step 1: Retrieve your signing secret
After registering a webhook, call the webhook signing secret endpoint to obtain your secret key.
The key is returned with a whsec_ prefix (e.g., whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw). When verifying signatures, you must:
- Strip the
whsec_prefix. - Base64-decode the remaining string to obtain the raw secret bytes.
Step 2: Construct the signed content
The signed content is formed by concatenating the message ID, timestamp, and raw request body, separated by a full stop (.):
{msg_id}.{timestamp}.{body}
Where:
{msg_id}is the value of thewebhook-idheader{timestamp}is the value of thewebhook-timestampheader{body}is the raw request body, exactly as received — any modification will invalidate the signature
Step 3: Compute the expected signature
Compute an HMAC-SHA256 of the signed content using the decoded secret bytes as the key. Base64-encode the resulting hash.
Step 4: Compare signatures
The webhook-signature header contains one or more space-delimited signatures, each prefixed with a version identifier:
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
To verify:
- Split the header value on spaces to get individual signatures.
- For each signature, split on the comma to separate the version (
v1) from the base64 signature value. - Compare your computed signature against the extracted signature value(s).
Important: Always use a constant-time string comparison to prevent timing attacks. Do not use standard equality operators.
Step 5: Verify the timestamp
To protect against replay attacks, check that the timestamp header value is within an acceptable tolerance of your server's current time. A tolerance of 5 minutes is recommended.
Reject any webhook where the timestamp falls outside this window.
Code examples
The examples below demonstrate the full verification flow.
Python
import hashlib
import hmac
import base64
import time
secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
secret_bytes = base64.b64decode(secret.split("_")[1])
# Webhook headers
ID_HEADER = "webhook-id"
TIMESTAMP_HEADER = "webhook-timestamp"
SIGNATURE_HEADER = "webhook-signature"
# Extract headers from the incoming request
msg_id = request.headers[ID_HEADER]
timestamp = request.headers[TIMESTAMP_HEADER]
body = request.body.decode("utf-8")
# Verify timestamp tolerance (5 minutes)
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
raise ValueError("Timestamp too old")
# Construct signed content and compute signature
signed_content = f"{msg_id}.{timestamp}.{body}"
expected_signature = base64.b64encode(
hmac.new(secret_bytes, signed_content.encode("utf-8"), hashlib.sha256).digest()
).decode("utf-8")
# Compare with signature header (strip the v1, prefix)
received_signatures = request.headers[SIGNATURE_HEADER].split(" ")
for sig in received_signatures:
version, signature = sig.split(",", 1)
if version == "v1" and hmac.compare_digest(expected_signature, signature):
print("Signature verified")
break
else:
raise ValueError("Invalid signature")
Node.js
const crypto = require("crypto");
const secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const secretBytes = Buffer.from(secret.split("_")[1], "base64");
// Webhook headers
const ID_HEADER = "webhook-id";
const TIMESTAMP_HEADER = "webhook-timestamp";
const SIGNATURE_HEADER = "webhook-signature";
// Extract headers from the incoming request
const msgId = req.headers[ID_HEADER];
const timestamp = req.headers[TIMESTAMP_HEADER];
const body = req.body; // raw request body as a string
// Verify timestamp tolerance (5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
throw new Error("Timestamp too old");
}
// Construct signed content and compute signature
const signedContent = `${msgId}.${timestamp}.${body}`;
const expectedSignature = crypto
.createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64");
// Compare with signature header (strip the v1, prefix)
const receivedSignatures = req.headers[SIGNATURE_HEADER].split(" ");
const isValid = receivedSignatures.some((sig) => {
const [version, signature] = sig.split(",", 2);
return (
version === "v1" &&
crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)
);
});
if (!isValid) {
throw new Error("Invalid signature");
}
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;
String secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
byte[] secretBytes = Base64.getDecoder().decode(secret.split("_")[1]);
// Webhook headers
String idHeader = "webhook-id";
String timestampHeader = "webhook-timestamp";
String signatureHeader = "webhook-signature";
// Extract headers from the incoming request
String msgId = request.getHeader(idHeader);
String timestamp = request.getHeader(timestampHeader);
String body = /* raw request body as a string */;
// Verify timestamp tolerance (5 minutes)
long currentTime = System.currentTimeMillis() / 1000;
if (Math.abs(currentTime - Long.parseLong(timestamp)) > 300) {
throw new SecurityException("Timestamp too old");
}
// Construct signed content and compute signature
String signedContent = msgId + "." + timestamp + "." + body;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretBytes, "HmacSHA256"));
String expectedSignature = Base64.getEncoder()
.encodeToString(mac.doFinal(signedContent.getBytes("UTF-8")));
// Compare with signature header (strip the v1, prefix)
String[] receivedSignatures = request.getHeader(signatureHeader).split(" ");
boolean isValid = false;
for (String sig : receivedSignatures) {
String[] parts = sig.split(",", 2);
if ("v1".equals(parts[0]) && MessageDigest.isEqual(
expectedSignature.getBytes(), parts[1].getBytes())) {
isValid = true;
break;
}
}
if (!isValid) {
throw new SecurityException("Invalid signature");
}
Security best practices
- Always verify signatures — never trust a webhook payload without checking the signature first.
- Check timestamps — reject messages with timestamps outside your tolerance window to prevent replay attacks.
- Use constant-time comparison — standard string equality is vulnerable to timing attacks. Use
hmac.compare_digest(Python),crypto.timingSafeEqual(Node.js), orMessageDigest.isEqual(Java). - Use the raw body — compute the signature over the exact bytes received. Parsing and re-serialising the JSON may change whitespace or field ordering, which will invalidate the signature.
- Store your signing secret securely — treat it like a password. Do not commit it to source control or expose it in client-side code.