Skip to content

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:

  1. Verifies the signature over the raw body.
  2. Deduplicates on dedupKey — delivery is at least once.
  3. Answers 2xx within 10 s, then does its work.
  4. 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).

EventFires whendata key
customer.createdA customer you created exists and is addressablecustomer
customer.kyc_changedA KYC version was approved or rejectedcustomer
customer.status_changedCustomer restricted, suspended, closed, KYC expiredcustomer
wallet.status_changedWallet blocked, frozen, dormant, closedwallet
wallet_credit.completedA wallet credit completedoperation
wallet_debit.completedA wallet debit completedoperation
wallet_debit.failedA scheduled debit was refused after acceptance (insufficient funds)operation
transfer.completedA wallet transfer posted; carries both wallet UIDsoperation
mandate.createdA mandate was registered, active or pendingmandate
mandate.revokedA mandate was revoked, by anyonemandate
mandate.status_changedA mandate was suspended or resumedmandate
mandate.expiredA mandate passed its validTomandate
topup.confirmedA top-up payment matched its intent; wallet creditedoperation
topup.expiredA top-up intent expired unpaidoperation
payout.confirmedThe payment provider confirmed the payoutoperation
payout.failedThe payment provider rejected it; funds are back in the walletoperation
payout.uncertainThe provider has not answered; the payout is held until resolvedoperation
ledger_adjustment.completedAn 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 touchedoperation
wallet_closure.completedA dormant wallet of your customer was closed; amount is the balance moved out of it. wallet.status_changed followsoperation
closed_balance_refund.completedThe balance of a closed wallet of your customer was returned to them, over a payment provider or into a new walletoperation

This is the complete list of event types.

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 keyGuaranteed fields
operationThe same operation object every write and GET /operations/{uid} returns
customeruid, status (+ clientReference on customer.created; + kycVersionUid, kycTierCode, kycVersionState, reviewType, rejectionReason on customer.kyc_changed)
walletuid, customerUid, status
mandateuid, status, customerUid, walletUid, clientReference

Payloads never contain personal data. Fields may be added under v1; none is removed or renamed.

Verify the signature ​

HeaderValue
X-Webhook-IdThe envelope id
X-Webhook-TimestampEpoch seconds of this attempt
X-Webhook-Key-IdThe kid of the secret that signed it
X-Webhook-SignatureHex 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)) <= 300

Keep 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 other 4xx.
  • 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.