Appearance
Webhooks
Neo Wallet POSTs a signed JSON event to your URL when something happens to your customers, wallets, mandates or operations. Configure the URL, the event filter and the signing secret in the client portal under Webhooks.
A correct receiver does four things:
- Verifies the signature over the raw body.
- Deduplicates on
dedupKey— delivery is at least once. - Answers
2xxwithin 10 s, then does its work. - Treats
GET /operations/{uid}as the truth, not the order webhooks arrived in.
The kit ships a runnable receiver that does all four.
Events
An empty filter subscribes to everything; otherwise list exact types (no wildcards).
| Event | Fires when | data key |
|---|---|---|
customer.created | A customer you created exists and is addressable | customer |
customer.kyc_changed | A KYC version was approved or rejected | customer |
customer.status_changed | Customer restricted, suspended, closed, KYC expired | customer |
wallet.status_changed | Wallet blocked, frozen, dormant, closed | wallet |
wallet_credit.completed | A wallet credit posted | operation |
wallet_debit.completed | A wallet debit posted | operation |
wallet_debit.failed | A scheduled debit was refused after acceptance (insufficient funds) | operation |
mandate.created | A mandate was registered, active or pending | mandate |
mandate.revoked | A mandate was revoked, by anyone | mandate |
mandate.status_changed | A mandate was suspended or resumed | mandate |
mandate.expired | A mandate passed its validTo | mandate |
topup.confirmed | A top-up payment matched its intent; funds posted | operation |
topup.expired | A top-up intent expired unpaid | operation |
payout.confirmed | The payment provider confirmed the payout | operation |
payout.failed | The payment provider rejected it; funds are back in the wallet | operation |
payout.uncertain | The provider has not answered; the payout is held until resolved | operation |
You will never receive a type that is not on this list.
Envelope
json
{
"id": "01J9WEBHOOK00000000000000A",
"type": "wallet_credit.completed",
"occurredAt": "2026-09-03T10:15:30.000Z",
"dedupKey": "01J9OPERATION000000000000A:wallet_credit.completed:3",
"data": { "operation": { "uid": "01J9OPERATION000000000000A", "state": "completed", "…": "…" } },
"apiVersion": "v1"
}data key | Guaranteed fields |
|---|---|
operation | The same projection every write and GET /operations/{uid} returns |
customer | uid, status (+ kycVersionUid, kycTierCode, kycVersionState, reviewType, rejectionReason on customer.kyc_changed) |
wallet | uid, customerUid, status |
mandate | uid, status, customerUid, walletUid, clientReference |
Payloads never contain personal data. Fields may be added under v1; none is removed or renamed.
Verify the signature
| Header | Value |
|---|---|
X-Webhook-Id | The envelope id |
X-Webhook-Timestamp | Epoch seconds of this attempt |
X-Webhook-Key-Id | The kid of the secret that signed it |
X-Webhook-Signature | Hex HMAC-SHA256(secret, timestamp + "." + rawBody) |
ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(
rawBody: Buffer,
headers: Record<string, string | undefined>,
secretsByKid: Record<string, string>,
) {
const secret = secretsByKid[headers["x-webhook-key-id"] ?? ""];
const timestamp = headers["x-webhook-timestamp"] ?? "";
const presented = (headers["x-webhook-signature"] ?? "").toLowerCase();
if (!secret || !/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(presented)) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
if (!timingSafeEqual(Buffer.from(presented, "hex"), expected)) return false;
return Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
}python
import hashlib, hmac, time
def verify(raw_body: bytes, headers: dict, secrets_by_kid: dict) -> bool:
secret = secrets_by_kid.get(headers.get("X-Webhook-Key-Id", ""))
timestamp = headers.get("X-Webhook-Timestamp", "")
if not secret or not timestamp.isdigit():
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(headers.get("X-Webhook-Signature", "").lower(), expected):
return False
return abs(time.time() - int(timestamp)) <= 300Keep the raw body
A framework that parses JSON first hands you re-serialised bytes, and the HMAC will not verify.
Test vector: secret nwhs_secret, timestamp 1700000000, body {"a":1} → 980c2dadae0dbe7bde00818ef6624cc7e1e97cbc37d753b7b09fc5d9be70d9bf.
Delivery
- Retried:
5xx,408,429, timeouts, connection errors. Not retried: any other4xx. - Schedule: up to 8 attempts — immediately, then +1 min, 5 min, 30 min, 2 h, 12 h, 12 h, 12 h (≈ 38.5 h). After that the event is
failed; retry it yourself from the client portal. - Retries are byte-identical in body, with a fresh timestamp and signature.
- Order: per resource (one operation, one customer, …) events arrive in order, and a failing event holds back later ones for the same resource. Across resources there is no order.
- Parallel: expect up to 4 deliveries in flight at once.
- Events that fire while the webhook is disabled or filtered out are not sent later.
Rotating the secret
Rotate it in the client portal (owner role). The new nwhs_… secret and its kid are shown once. The old kid keeps working for 24 h, so store secrets by kid and drop one only when it has expired.