Appearance
Customer authentication
Three writes move a customer's money or consent on the customer's say-so, so they carry an assertion: your evidence that the customer authenticated for this request.
| Route | Needs assertion |
|---|---|
POST /mandates | yes |
POST /wallet-debits | yes |
POST /payouts | yes |
POST /mandates/{uid}/debits | no — the standing mandate is the authorization |
json
{
"customerUid": "01J0CUSTOMER00000000000000",
"assuranceLevel": "two_factor",
"factor1Type": "pin",
"factor2Type": "otp",
"authenticatedAt": "2026-09-07T12:00:00.000Z",
"expiresAt": "2026-09-07T12:05:00.000Z",
"channelCode": "ussd",
"sourceReference": "sess_8d2f1c",
"transactionBinding": {
"hash": "9b1e…(64 hex)",
"algorithm": "sha256",
"canonical": "customerUid|walletUid|amountMinor|currency|destinationIdentifierUid|clientReference"
}
}Fields
| Field | Rule |
|---|---|
customerUid | The customer who authenticated — must be the customer the request acts for. |
identifierUid | Optional. The identifier they authenticated with; must be theirs. |
assuranceLevel | single_factor, two_factor or step_up. Above the two-factor threshold, only the last two. |
factor1Type | ≤ 32 chars: pin, password, biometric, … |
factor2Type | Required unless single_factor: otp, device, … |
authenticatedAt | When the customer authenticated (ISO-8601). |
expiresAt | After authenticatedAt, at most 10 minutes after it, and still in the future on arrival. |
channelCode | ≤ 40 chars — the channel the customer used. |
deviceHash | Optional SHA-256 hex of the device fingerprint. |
sourceReference | ≤ 160 chars — your session or login id. |
transactionBinding | Required above the two-factor threshold. See below. |
Authenticate, build the request, send it. Do not mint assertions ahead of time.
The two-factor threshold
Each customer's KYC level sets an amount above which Neo Wallet requires two-factor authentication. If you always send two_factor with a transactionBinding for money-moving requests, you never have to know it.
Transaction binding
Proves the customer approved this transaction, not just a session. Hash the request you are about to send:
canonical = customerUid | walletUid | amountMinor | currency | target | clientReference
hash = sha256_hex(utf8(canonical))- Separator
|, no spaces.amountMinoras the integer you send (750000). targetisdestinationIdentifierUidfor a payout,mandateUidfor a debit, otherwise empty.clientReferenceverbatim; empty if the request has none.algorithmis alwayssha256;canonicalis always the literal template in the example above.
ts
import { createHash } from "node:crypto";
const canonical = [
customerUid,
walletUid,
String(amountMinor),
currency,
target ?? "",
clientReference ?? "",
].join("|");
const hash = createHash("sha256").update(canonical, "utf8").digest("hex");The kit's transactionBinding() does exactly this. The server recomputes the hash from the request it received; a different amount, wallet, destination or reference is refused. On POST /mandates there is no amount to bind — a binding sent there is only shape-checked.
Refusals
All are 422, and nothing is created — the attempt will not show up in GET /operations.
error | Cause |
|---|---|
AuthorizationInsufficient | Expired, lifetime over 10 minutes, or a second factor was required |
AssertionNotBoundToTransaction | The binding does not match the request |
UnprocessableEntity | identifierUid is not the customer's, or sourceReference is live for another customer |
Reusing a live sourceReference for the same customer is fine — one login, two operations.