ndeipipayments APIv1 · 0.1.0-draftAPI reference

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

  1. Split the header on commas. Read t and every v1. While a key is being rotated there is one v1 per key.
  2. Reject the delivery if t is more than 10 minutes from your clock.
  3. Build the message: t, a full stop, then the raw body bytes exactly as received.
  4. Accept the delivery if any one v1 verifies with the endpoint's public key.

Code

Node.js
// 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
});
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.

text
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}}