Webhook Signature Helpers
Copy-paste ready signature verification functions for any language. No SDK required — these are standalone functions you can drop directly into your application.
How XRNotify signatures work
- XRNotify computes
HMAC-SHA256(raw_body, webhook_secret) - The result is formatted as
sha256=<hex_digest> - This value is sent in the
X-XRNotify-Signaturerequest header - You recompute the same HMAC on your side and compare using a constant-time function
Always use raw bytes: Compute the HMAC over the raw request body bytes before any JSON parsing. Modifying the body (whitespace changes, key reordering) will invalidate the signature.
Node.js
CommonJSconst crypto = require('crypto');
/**
* Verify an XRNotify webhook signature.
* @param {Buffer|string} payload - Raw request body (before JSON.parse)
* @param {string} signature - X-XRNotify-Signature header value
* @param {string} secret - Webhook secret
* @returns {boolean}
*/
function verifyXRNotifySignature(payload, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(expected, 'utf8')
);
} catch {
// Lengths differ — timingSafeEqual throws on mismatched lengths
return false;
}
}Key point: crypto.timingSafeEqual throws if the two buffers have different lengths, so the try/catch is required. Pass the raw req.body Buffer — not a parsed object.
Python
stdlib onlyimport hmac
import hashlib
def verify_xrnotify_signature(payload: bytes, signature: str, secret: str) -> bool:
"""
Verify an XRNotify webhook signature.
payload: raw request body bytes (before json.loads)
signature: value of X-XRNotify-Signature header
secret: webhook secret string
"""
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)Key point: hmac.compare_digest performs a constant-time comparison, preventing timing-based attacks. Pass request.data (bytes) in Flask, or await request.body() in FastAPI.
Go
stdlib onlyimport (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
// VerifyXRNotifySignature verifies an XRNotify webhook signature.
// payload: raw request body bytes (from io.ReadAll(r.Body))
// signature: X-XRNotify-Signature header value
// secret: webhook secret
func VerifyXRNotifySignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := fmt.Sprintf("sha256=%s", hex.EncodeToString(mac.Sum(nil)))
return hmac.Equal([]byte(signature), []byte(expected))
}Key point: hmac.Equal from the standard library performs a constant-time byte comparison. Read the body with io.ReadAll(r.Body) and pass the bytes directly to this function.
Ruby
stdlib + rackrequire 'openssl'
require 'rack'
# Verify an XRNotify webhook signature.
# payload: raw request body string
# signature: X-XRNotify-Signature header value
# secret: webhook secret
def verify_xrnotify_signature(payload, signature, secret)
expected = 'sha256=' + OpenSSL::HMAC.hexdigest('sha256', secret, payload)
Rack::Utils.secure_compare(signature, expected)
end
# Sinatra / Rails example:
# post '/webhooks/xrpl' do
# raw = request.body.read
# sig = request.env['HTTP_X_XRNOTIFY_SIGNATURE']
# halt 401, 'Unauthorized' unless verify_xrnotify_signature(raw, sig, ENV['WEBHOOK_SECRET'])
# event = JSON.parse(raw)
# # handle event...
# status 200
# endKey point: Rack::Utils.secure_compare provides constant-time string comparison. Read the raw body with request.body.read before passing to JSON.parse.
PHP
stdlib only<?php
/**
* Verify an XRNotify webhook signature.
*
* @param string $payload Raw request body
* @param string $signature X-XRNotify-Signature header value
* @param string $secret Webhook secret
* @return bool
*/
function verifyXRNotifySignature(string $payload, string $signature, string $secret): bool {
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}
// Usage example:
// $payload = file_get_contents('php://input');
// $signature = $_SERVER['HTTP_X_XRNOTIFY_SIGNATURE'] ?? '';
// $secret = getenv('WEBHOOK_SECRET');
//
// if (!verifyXRNotifySignature($payload, $signature, $secret)) {
// http_response_code(401);
// exit('Unauthorized');
// }
//
// $event = json_decode($payload, true);
// // handle $event...Key point: hash_equals performs a constant-time string comparison (available since PHP 5.6). Read the raw body with file_get_contents('php://input') before decoding JSON.
Java
JDK onlyimport javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
/**
* Verify an XRNotify webhook signature.
*
* @param payload raw request body bytes
* @param signature X-XRNotify-Signature header value
* @param secret webhook secret string
* @return true if the signature is valid
*/
public static boolean verifyXRNotifySignature(
byte[] payload, String signature, String secret
) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
String expected = "sha256=" + bytesToHex(mac.doFinal(payload));
// Use MessageDigest.isEqual for constant-time comparison
return MessageDigest.isEqual(
expected.getBytes("UTF-8"),
signature.getBytes("UTF-8")
);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}Key point: MessageDigest.isEqual performs a constant-time byte array comparison (available since Java 6). Read the raw request body bytes with request.getInputStream().readAllBytes() in a servlet before parsing JSON.