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 postedoperation
wallet_debit.completedA wallet debit postedoperation
wallet_debit.failedA scheduled debit was refused after acceptance (insufficient funds)operation
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; funds postedoperation
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

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 keyGuaranteed fields
operationThe same projection every write and GET /operations/{uid} returns
customeruid, status (+ 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.