Webhooks
Verifying signatures
Check every delivery's signature before you trust it. It proves the event came from Ndeipi and wasn't changed on the way.
The scheme
The Ndeipi-Signature header has the form t=<unix seconds>,v1=<base64>. The signature is Ed25519 over the bytes <t>.<raw body>, made with your endpoint's private key. It's one standard sign operation with no hashing first, so any Ed25519 library verifies it directly.
t is the time of this delivery attempt, not of the event, so retries over the two days still pass a short tolerance.
Steps
- Split the header on commas. Read
tand everyv1. While a key is being rotated there is onev1per key. - Reject the delivery if
tis more than 10 minutes from your clock. - Build the message:
t, a full stop, then the raw body bytes exactly as received. - Accept the delivery if any one
v1verifies with the endpoint's public key.
Code
// No package needed: Node 16+ verifies Ed25519 natively.
import crypto from "node:crypto";
export function verifyNdeipiWebhook(rawBody, header, publicKeyPem, toleranceSeconds = 600) {
let t;
const signatures = [];
for (const part of (header ?? "").split(",")) {
const i = part.indexOf("=");
const key = part.slice(0, i).trim(), value = part.slice(i + 1).trim();
if (key === "t") t = Number(value);
else if (key === "v1") signatures.push(Buffer.from(value, "base64"));
}
if (!Number.isInteger(t) || signatures.length === 0) throw new Error("Malformed Ndeipi-Signature header");
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) throw new Error("Webhook timestamp outside tolerance");
const message = Buffer.concat([Buffer.from(`${t}.`), rawBody]);
const publicKey = crypto.createPublicKey(publicKeyPem);
if (!signatures.some((sig) => crypto.verify(null, message, publicKey, sig))) throw new Error("Invalid signature");
return JSON.parse(rawBody.toString("utf8"));
}
// Express: keep the body raw for this route.
app.post("/ndeipi/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyNdeipiWebhook(req.body, req.get("Ndeipi-Signature"), process.env.NDEIPI_WEBHOOK_PUBLIC_KEY);
} catch {
return res.sendStatus(400);
}
res.sendStatus(200);
handleEvent(event); // deduplicate on event.id; skip snapshots older than you have
});# pip install cryptography
import base64, json, time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.serialization import load_pem_public_key
def verify_ndeipi_webhook(raw_body: bytes, header: str, public_key_pem: str, tolerance: int = 600) -> dict:
t, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = int(value)
elif key == "v1":
signatures.append(base64.b64decode(value))
if t is None or not signatures:
raise ValueError("Malformed Ndeipi-Signature header")
if abs(time.time() - t) > tolerance:
raise ValueError("Webhook timestamp outside tolerance")
public_key = load_pem_public_key(public_key_pem.encode())
message = f"{t}.".encode() + raw_body
for signature in signatures:
try:
public_key.verify(signature, message)
return json.loads(raw_body)
except InvalidSignature:
continue
raise ValueError("Invalid signature")// dotnet add package BouncyCastle.Cryptography
using System.Text;
using System.Text.Json;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Security;
public static class NdeipiWebhook
{
public static JsonDocument Verify(byte[] rawBody, string header, string publicKeyPem, TimeSpan? tolerance = null)
{
long? t = null;
var signatures = new List<byte[]>();
foreach (var part in (header ?? "").Split(','))
{
var kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t" && long.TryParse(kv[1], out var seconds)) t = seconds;
else if (kv[0] == "v1") signatures.Add(Convert.FromBase64String(kv[1]));
}
if (t is null || signatures.Count == 0)
throw new InvalidOperationException("Malformed Ndeipi-Signature header");
var limit = (tolerance ?? TimeSpan.FromMinutes(10)).TotalSeconds;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t.Value) > limit)
throw new InvalidOperationException("Webhook timestamp outside tolerance");
var der = Convert.FromBase64String(string.Concat(publicKeyPem.Split('\n')
.Select(l => l.Trim()).Where(l => l.Length > 0 && !l.StartsWith("-----"))));
var publicKey = (Ed25519PublicKeyParameters)PublicKeyFactory.CreateKey(der);
var message = Encoding.ASCII.GetBytes($"{t}.").Concat(rawBody).ToArray();
foreach (var signature in signatures)
{
var verifier = new Ed25519Signer();
verifier.Init(false, publicKey);
verifier.BlockUpdate(message, 0, message.Length);
if (verifier.VerifySignature(signature)) return JsonDocument.Parse(rawBody);
}
throw new InvalidOperationException("Invalid signature");
}
}Most web frameworks parse JSON before your handler runs. Read the raw body for this route instead: re-serialising parsed JSON changes the bytes, and the signature won't match.
Test vector
Check your implementation against this delivery, signed by Ndeipi's own signer. Verify it with a tolerance wide enough to accept t=1791302400; it should pass, and fail if you change any byte of the body.
Public key:
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAEu+SJIWlScv5fJmnV1iNNpGw345qGqFu0DJR6GdDIaA=
-----END PUBLIC KEY-----
Ndeipi-Signature:
t=1791302400,v1=ed/v9bqUhRje5vmBtn02u1WV9Xn2eBWy0ib4qgu6eYpxn46v2IWgMz9/1qkKH5WLtLvbMeO0YrjxNuEXlsp5CQ==
Body (exact bytes):
{"id":"evt_01JA8ZC4QK7N2Y5W3V0T9S8R6P","object":"event","type":"transfer.state_changed","api_version":"v1","created_at":"2026-10-16T10:00:00Z","object_type":"transfer","object_version":2,"data":{"id":"trf_01JA8ZC4QK7N2Y5W3V0T9S8R6P","object":"transfer","kind":"user_to_user","state":"completed","amount":"25.00","version":2}}