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).
A customer of yours may also use their Neo Wallet with other services. Events about an operation reach you when you started it; events about the customer or the wallet always reach you, with your own clientReference and your own dedupKey.
| 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.balance_changed | Your customer's wallet balance moved by something you did not start, such as money sent to their account number | movement |
wallet_credit.completed | A wallet credit completed | operation |
wallet_debit.completed | A wallet debit completed | operation |
wallet_debit.failed | A scheduled debit was refused after acceptance (insufficient funds) | operation |
transfer.completed | A wallet transfer posted; carries sourceWalletUid and destinationAccountNumber | 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; wallet credited | 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 |
ledger_adjustment.completed | An approved correction moved money on a customer's wallet; the operation is ours, its amount the correction's total. Sent to every account whose customer's wallet was touched | operation |
wallet_closure.completed | A dormant wallet of your customer was closed; amount is the balance moved out of it. wallet.status_changed follows | operation |
closed_balance_refund.completed | The balance of a closed wallet of your customer was returned to them, over a payment provider or into a new wallet | operation |
This is the complete list of event types. The last three are our operations, not yours: GET /operations/{uid} answers 404 for them, so keep the payload as your record.
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 operation object every write and GET /operations/{uid} returns |
customer | uid, status (+ clientReference on customer.created and customer.status_changed; + kycVersionUid, kycTierCode, kycVersionState, reviewType, rejectionReason on customer.kyc_changed) |
wallet | uid, customerUid, status, clientReference |
mandate | uid, status, customerUid, walletUid, clientReference |
movement | walletUid, accountNumber, direction (credit or debit), amount, operationType, occurredAt |
Payloads never contain personal data; a wallet's account number is not personal data. clientReference is always your own reference for the customer. Fields may be added under v1; none is removed or renamed. Treat dedupKey as opaque.
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, …) your events arrive in order, and a failing event of yours holds back your 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.