Skip to content

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.

RouteNeeds assertion
POST /mandatesyes
POST /wallet-debitsyes
POST /payoutsyes
POST /mandates/{uid}/debitsno — 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

FieldRule
customerUidThe customer who authenticated — must be the customer the request acts for.
identifierUidOptional. The identifier they authenticated with; must be theirs.
assuranceLevelsingle_factor, two_factor or step_up. Above the two-factor threshold, only the last two.
factor1Type≤ 32 chars: pin, password, biometric, …
factor2TypeRequired unless single_factor: otp, device, …
authenticatedAtWhen the customer authenticated (ISO-8601).
expiresAtAfter authenticatedAt, at most 10 minutes after it, and still in the future on arrival.
channelCode≤ 40 chars — the channel the customer used.
deviceHashOptional SHA-256 hex of the device fingerprint.
sourceReference≤ 160 chars — your session or login id.
transactionBindingRequired 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. amountMinor as the integer you send (750000).
  • target is destinationIdentifierUid for a payout, mandateUid for a debit, otherwise empty.
  • clientReference verbatim; empty if the request has none.
  • algorithm is always sha256; canonical is 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.

errorCause
AuthorizationInsufficientExpired, lifetime over 10 minutes, or a second factor was required
AssertionNotBoundToTransactionThe binding does not match the request
UnprocessableEntityidentifierUid 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.