Prerequisites
You will need:- A webhook subscription secret from Subscriptions
- Access to the raw, unmodified request body in your webhook handler
- The
X-Hook0-Signaturerequest header
Verify signatures
Verify webhook signatures to ensure that requests are authentic. This protects your application from forged webhooks and potential security threats.
How signature verification works
When you create a webhook subscription, the response includes asecret. This secret is used to verify that incoming webhooks are authentic.
Each webhook request includes an X-Hook0-Signature header containing:
tfield - the timestamphfield - list of headers included in the signaturev1field - the signature
Verification implementation
Here’s an example of how to verify webhook signatures:import crypto from "crypto";
type HeadersMap = Record<string, string>;
function verifyWebhookSignature(
payload: string,
signatureHeader: string,
secret: string,
headers: HeadersMap,
maxAgeMinutes = 5
): boolean {
try {
const elements = signatureHeader.split(",");
const timestamp = elements.find((e) => e.startsWith("t="))?.split("=")[1] ?? "";
const headerNames = elements.find((e) => e.startsWith("h="))?.split("=")[1] ?? "";
const providedSignature = elements.find((e) => e.startsWith("v1="))?.split("=")[1] ?? "";
const headerNameList = headerNames.split(" ");
const headerValues = headerNameList.map((name) => headers[name] ?? "").join(".");
const signedPayload = `${timestamp}.${headerNames}.${headerValues}.${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
const signaturesMatch = crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(providedSignature, "hex")
);
const webhookTime = parseInt(timestamp, 10) * 1000;
const currentTime = Date.now();
const ageMinutes = (currentTime - webhookTime) / (1000 * 60);
if (ageMinutes > maxAgeMinutes) {
console.error(
`Webhook timestamp exceeds maximum age: ${ageMinutes.toFixed(1)} minutes > ${maxAgeMinutes} minutes`
);
return false;
}
return signaturesMatch;
} catch (error) {
console.error("Webhook verification error:", error);
return false;
}
}
import hashlib
import hmac
import time
def verify_webhook_signature(payload, signature_header, secret, headers, max_age_minutes=5):
"""Verify webhook signature and timestamp."""
try:
elements = signature_header.split(",")
timestamp = next(e for e in elements if e.startswith("t=")).split("=", 1)[1]
header_names = next(e for e in elements if e.startswith("h=")).split("=", 1)[1]
provided_signature = next(e for e in elements if e.startswith("v1=")).split("=", 1)[1]
header_name_list = header_names.split(" ")
header_values = ".".join(headers.get(name, "") for name in header_name_list)
signed_payload = f"{timestamp}.{header_names}.{header_values}.{payload}"
expected_signature = hmac.new(
secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
signatures_match = hmac.compare_digest(expected_signature, provided_signature)
webhook_time_ms = int(timestamp) * 1000
current_time_ms = int(time.time() * 1000)
age_minutes = (current_time_ms - webhook_time_ms) / (1000 * 60)
if age_minutes > max_age_minutes:
print(
f"Webhook timestamp exceeds maximum age: {age_minutes:.1f} minutes > {max_age_minutes} minutes"
)
return False
return signatures_match
except Exception as error:
print(f"Webhook verification error: {error}")
return False
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func verifyWebhookSignature(payload, signatureHeader, secret string, headers map[string]string, maxAgeMinutes int) bool {
elements := strings.Split(signatureHeader, ",")
var timestamp, headerNames, providedSignature string
for _, element := range elements {
if strings.HasPrefix(element, "t=") {
timestamp = strings.TrimPrefix(element, "t=")
}
if strings.HasPrefix(element, "h=") {
headerNames = strings.TrimPrefix(element, "h=")
}
if strings.HasPrefix(element, "v1=") {
providedSignature = strings.TrimPrefix(element, "v1=")
}
}
headerNameList := strings.Split(headerNames, " ")
headerValues := make([]string, 0, len(headerNameList))
for _, name := range headerNameList {
headerValues = append(headerValues, headers[name])
}
signedPayload := fmt.Sprintf("%s.%s.%s.%s", timestamp, headerNames, strings.Join(headerValues, "."), payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
signaturesMatch := hmac.Equal([]byte(expectedSignature), []byte(providedSignature))
webhookTime, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
ageMinutes := float64(time.Now().UnixMilli()-webhookTime*1000) / (1000 * 60)
if ageMinutes > float64(maxAgeMinutes) {
fmt.Printf("Webhook timestamp exceeds maximum age: %.1f minutes > %d minutes\n", ageMinutes, maxAgeMinutes)
return false
}
return signaturesMatch
}
require "openssl"
def verify_webhook_signature(payload, signature_header, secret, headers, max_age_minutes = 5)
elements = signature_header.split(",")
timestamp = elements.find { |e| e.start_with?("t=") }&.split("=", 2)&.last.to_s
header_names = elements.find { |e| e.start_with?("h=") }&.split("=", 2)&.last.to_s
provided_signature = elements.find { |e| e.start_with?("v1=") }&.split("=", 2)&.last.to_s
header_values = header_names.split(" ").map { |name| headers[name] || "" }.join(".")
signed_payload = "#{timestamp}.#{header_names}.#{header_values}.#{payload}"
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
signatures_match = expected_signature.bytesize == provided_signature.bytesize &&
expected_signature.bytes.zip(provided_signature.bytes).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero?
webhook_time = timestamp.to_i * 1000
age_minutes = (Time.now.to_i * 1000 - webhook_time) / (1000.0 * 60.0)
if age_minutes > max_age_minutes
puts "Webhook timestamp exceeds maximum age: #{age_minutes.round(1)} minutes > #{max_age_minutes} minutes"
return false
end
signatures_match
rescue StandardError => error
puts "Webhook verification error: #{error}"
false
end
<?php
function verifyWebhookSignature($payload, $signatureHeader, $secret, $headers, $maxAgeMinutes = 5): bool
{
$elements = explode(",", $signatureHeader);
$timestamp = "";
$headerNames = "";
$providedSignature = "";
foreach ($elements as $element) {
if (str_starts_with($element, "t=")) {
$timestamp = substr($element, 2);
}
if (str_starts_with($element, "h=")) {
$headerNames = substr($element, 2);
}
if (str_starts_with($element, "v1=")) {
$providedSignature = substr($element, 3);
}
}
$headerNameList = explode(" ", $headerNames);
$headerValues = array_map(
fn($name) => $headers[$name] ?? "",
$headerNameList
);
$signedPayload = $timestamp . "." . $headerNames . "." . implode(".", $headerValues) . "." . $payload;
$expectedSignature = hash_hmac("sha256", $signedPayload, $secret);
$signaturesMatch = hash_equals($expectedSignature, $providedSignature);
$webhookTimeMs = intval($timestamp) * 1000;
$currentTimeMs = intval(microtime(true) * 1000);
$ageMinutes = ($currentTimeMs - $webhookTimeMs) / (1000 * 60);
if ($ageMinutes > $maxAgeMinutes) {
echo "Webhook timestamp exceeds maximum age: {$ageMinutes} minutes > {$maxAgeMinutes} minutes\n";
return false;
}
return $signaturesMatch;
}
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class WebhookVerifier {
public static boolean verifyWebhookSignature(
String payload,
String signatureHeader,
String secret,
Map<String, String> headers,
int maxAgeMinutes
) {
try {
String[] elements = signatureHeader.split(",");
String timestamp = "";
String headerNames = "";
String providedSignature = "";
for (String element : elements) {
if (element.startsWith("t=")) {
timestamp = element.substring(2);
} else if (element.startsWith("h=")) {
headerNames = element.substring(2);
} else if (element.startsWith("v1=")) {
providedSignature = element.substring(3);
}
}
String[] headerNameList = headerNames.split(" ");
StringBuilder headerValues = new StringBuilder();
for (int i = 0; i < headerNameList.length; i++) {
if (i > 0) {
headerValues.append(".");
}
headerValues.append(headers.getOrDefault(headerNameList[i], ""));
}
String signedPayload = String.join(".", timestamp, headerNames, headerValues.toString(), payload);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expectedBytes = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = bytesToHex(expectedBytes);
boolean signaturesMatch = MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.UTF_8),
providedSignature.getBytes(StandardCharsets.UTF_8)
);
long webhookTimeMs = Long.parseLong(timestamp) * 1000;
long currentTimeMs = Instant.now().toEpochMilli();
double ageMinutes = (currentTimeMs - webhookTimeMs) / (1000.0 * 60.0);
if (ageMinutes > maxAgeMinutes) {
System.out.printf(
"Webhook timestamp exceeds maximum age: %.1f minutes > %d minutes%n",
ageMinutes,
maxAgeMinutes
);
return false;
}
return signaturesMatch;
} catch (Exception error) {
System.out.println("Webhook verification error: " + error);
return false;
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
public static void main(String[] args) {
Map<String, String> headers = new HashMap<>();
verifyWebhookSignature("payload", "t=0,h=host,v1=signature", "secret", headers, 5);
}
}
import express from "express";
const app = express();
// Important: Get raw body for signature verification
app.use(express.raw({ type: "application/json" }));
app.post("/webhook", (req, res) => {
const payload = req.body.toString();
const signature = req.headers["x-hook0-signature"];
const secret = process.env.WEBHOOK_SECRET ?? "";
if (verifyWebhookSignature(payload, String(signature || ""), secret, req.headers as Record<string, string>)) {
const event = JSON.parse(payload);
// Handle your transfer event here
res.status(200).send("OK");
return;
}
res.status(400).send("Invalid signature");
});
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhook")
def webhook():
payload = request.get_data(as_text=True)
signature = request.headers.get("X-Hook0-Signature", "")
secret = os.environ.get("WEBHOOK_SECRET", "")
if verify_webhook_signature(payload, signature, secret, request.headers):
event = request.get_json()
# Handle your transfer event here
return ("OK", 200)
return ("Invalid signature", 400)
package main
import (
"io"
"net/http"
"os"
"strings"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payloadBytes, _ := io.ReadAll(r.Body)
payload := string(payloadBytes)
signature := r.Header.Get("X-Hook0-Signature")
secret := os.Getenv("WEBHOOK_SECRET")
headers := map[string]string{}
for name := range r.Header {
headers[strings.ToLower(name)] = r.Header.Get(name)
}
if _, ok := headers["host"]; !ok {
headers["host"] = r.Host
}
if verifyWebhookSignature(payload, signature, secret, headers, 5) {
// Handle your transfer event here
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
return
}
http.Error(w, "Invalid signature", http.StatusBadRequest)
}
require "sinatra"
post "/webhook" do
payload = request.body.read
signature = request.env["HTTP_X_HOOK0_SIGNATURE"] || ""
secret = ENV.fetch("WEBHOOK_SECRET", "")
headers = { "host" => request.host }
if verify_webhook_signature(payload, signature, secret, headers)
# Handle your transfer event here
status 200
body "OK"
else
status 400
body "Invalid signature"
end
end
<?php
$payload = file_get_contents("php://input");
$signature = $_SERVER["HTTP_X_HOOK0_SIGNATURE"] ?? "";
$secret = getenv("WEBHOOK_SECRET") ?: "";
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, "HTTP_")) {
$headerName = strtolower(str_replace("_", "-", substr($key, 5)));
$headers[$headerName] = $value;
}
}
if (!array_key_exists("host", $headers)) {
$headers["host"] = $_SERVER["HTTP_HOST"] ?? "";
}
if (verifyWebhookSignature($payload, $signature, $secret, $headers)) {
// Handle your transfer event here
http_response_code(200);
echo "OK";
} else {
http_response_code(400);
echo "Invalid signature";
}
import static spark.Spark.port;
import static spark.Spark.post;
import java.util.HashMap;
import java.util.Map;
public class WebhookServer {
public static void main(String[] args) {
port(3000);
post("/webhook", (request, response) -> {
String payload = request.body();
String signature = request.headers("X-Hook0-Signature");
String secret = System.getenv().getOrDefault("WEBHOOK_SECRET", "");
Map<String, String> headers = new HashMap<>();
for (String headerName : request.headers()) {
headers.put(headerName.toLowerCase(), request.headers(headerName));
}
if (WebhookVerifier.verifyWebhookSignature(payload, signature, secret, headers, 5)) {
// Handle your transfer event here
response.status(200);
return "OK";
}
response.status(400);
return "Invalid signature";
});
}
}