Appearance
Changelog
Additive changes land under /api/v1; a breaking change ships as /api/v2 alongside it, with at least 90 days' notice. See Versioning.
Choose the phone a top-up prompts
POST /topup-intents takes an optional payerIdentifierUid: the payment prompt goes to that phone number, for this top-up only. It must be the uid of one of the wallet's customer's verified phone numbers — identifiers[].uid on GET /customers/{uid}, or the uid returned when you add the number — otherwise the request is refused with 422. Leave it out and nothing changes: the prompt goes to the customer's primary verified phone number, or, if the primary contact is not a verified phone number, to the verified phone number added first. The customer's primary number is not changed. A payment provider that does not prompt the customer's phone refuses the field with 422.
IP allowlists are enforced (fix)
If your account has an IP allowlist, a request from an address outside it is now answered 401. The authentication guide has always said so, but the allowlist was not being checked: a correctly signed request was accepted from any address. Signing is unchanged. If you set an allowlist in the client portal, make sure it lists every address your servers call from. An account with no allowlist is not restricted by address.
Top-ups prompt the customer's phone (breaking)
A top-up intent now sends the customer's phone a payment prompt for the amount; the customer approves it there instead of paying in and quoting intentReference. The request and response are unchanged. The prompt goes to the customer's verified phone number (the primary one when there are several): a customer without one is now refused with 422. A declined or unanswered prompt is not retried — the intent ends with topup.expired, as before. payerInstructions tells the customer what to do.
ledger_adjustment.completed is not readable by uid (documentation fix)
The webhook catalogue said you could read a ledger_adjustment.completed operation by its uid; you cannot. The adjustment is Neo Wallet's own correction, which may touch the wallets of more than one institution, so GET /operations/{uid} and GET /operations/{uid}/receipt answer 404 for it and the payload's links do not resolve. Nothing on the wire changed: keep the payload — the operation's uid, type, amount and times — as your record of the movement.
customer.status_changed payload (fix)
customer.status_changed now carries data.customer: { uid, status }, as the customer family has always been documented. It carried customerUid, from, to, reason and occurredAt at the top of data instead, so a receiver reading data.customer.status found nothing. status is the customer's new status; the envelope's occurredAt is when it changed. Neo Wallet's reason for a restriction, suspension or closure is no longer sent: read the customer (GET /customers/{uid}) for its current status.
Receipts of failed payouts (fix)
totalDebited on the receipt of a failed or reversed operation now says what the customer paid in the end. It read amount + fee + tax whatever happened. A payout the payment provider refuses returns the amount and every refundable charge to the wallet, so the receipt now shows only the charges that were not refunded — 0.00 when all of them were. Receipts of completed operations are unchanged, receiptHash included; the receipt of a failed operation carries a new receiptHash.
Starter kit: a Postman collection
The starter kit's Bruno collection is replaced by a Postman collection, and the API no longer serves its own description. The TypeScript and Python helpers are the same.
- Removed route — the GET route
/openapi.json. Use the API reference on the developer portal, and the Postman collection to call the API from an API client. This lands under/api/v1as a clean-up, since no integration is live in production yet. - Every route, in guided steps — check the connection, onboard a customer and keep its identifiers, agreements and KYC current, credit the wallet and retry under the same
Idempotency-Key, top up, pay out, register a mandate and debit on it, transfer between wallets. Each request saves what the next one needs. - Customer authentication included — on a payout, a mandate, a debit and a transfer the collection fills in the
assertionand computes its transaction binding from the body it sends. - Signed for you — the collection signs every request and adds
X-Request-Idand, on writes,Idempotency-Key. It runs in Postman and in Newman. - Documented in Postman — the set-up, the variables and a
401checklist are in the collection's overview, and every request describes itself.
A smaller surface: writes, webhooks and one read per resource (clean-up)
Six read routes, two scopes and one receipt field are gone; three webhook events and three response fields are new. Everything removed returned data you already hold: you created every customer, identifier, agreement, mandate and operation you could list, got each one back on the write, and receive a webhook on every change. History, search and exports live in the client portal. This lands under /api/v1 as a clean-up — no /api/v2 and no deprecation window, since no integration is live in production yet.
- Removed routes — the GET routes
/wallets/{uid}/transactions,/operations,/mandates,/customers/{uid}/identifiers,/customers/{uid}/agreementsand/topup-intents/{uid}. Read an operation, a mandate or a customer by uid instead; the customer summary carries the identifiers and standing agreements. - Removed scopes — customers:pii and customer-sessions:write. Every identifier on the API is masked;
identifiers[].valueleaves the customer summary. A grant that carried either scope has had it removed. - Removed field —
references.journalUidson the receipt. The operation uid is your traceable reference; no internal accounting identifier is returned anywhere. - New fields —
identifiers[].payoutEligibleon the customer summary (a verified contact identifier that is valid now), andintentReference/expiresAtonGET /operations/{uid}for atopup_c2b(nullon every other type). - New webhooks —
ledger_adjustment.completed,wallet_closure.completedandclosed_balance_refund.completed: the three back-office movements on a customer wallet that no call of yours starts. With them, your writes plus the webhook feed show every balance movement. - The starter kit follows: the Bruno collection drops the removed requests and reads a mandate by uid.
Amounts as decimal strings (breaking)
Every money field is now a decimal string with two decimal places, and its name no longer ends in Minor. What used to be the integer 250000 is now the string "2500.00". This affects every request body, every response and every webhook payload, so update your integration before your next call. It lands under /api/v1: there is no /api/v2.
- Responses carry exactly two decimal places, including the trailing
.00and a leading-on a negative figure:"1250.00","0.00","-0.01". Do no rounding of your own. - Requests may send zero, one or two places —
"1250","1250.5"and"1250.50"are the same amount. A JSON number is refused with400 BadRequest, and so are an exponent, a+, a leading zero, surrounding whitespace, a thousands separator and a bare.5or12.. - A third decimal place is refused with the new
AmountPrecisionExceededcode and a400, as is an amount above 18 integer digits. An amount you send is never rounded for you. - Field names drop the suffix:
amountMinorisamount,feeMinorisfee,taxMinoristax,totalMinoristotal,totalDebitedMinoristotalDebited,fixedOrMaxAmountMinorisfixedOrMaxAmount,maxBalanceMinorismaxBalance,maxSingleTxnMinorismaxSingleTxn,aggregateDailyLimitMinorisaggregateDailyLimit,monthlyOutLimitMinorismonthlyOutLimit, and a wallet balance readscurrent,heldandavailable. - The transaction binding hashes the amount as two decimal places under the new template
customerUid|walletUid|amount|currency|destinationIdentifierUid|clientReference. Echo that template astransactionBinding.canonicaland hash7500.00where you hashed750000; the old template is refused withAssertionNotBoundToTransaction. - The starter kit follows:
transactionBinding()takesamountas a string, and the test vectors carry the new bodies, signatures and binding hash.
v1
Initial release.
- Access — every request is signed with your API key and Ed25519 key; scopes decide what each route allows; every write takes an
Idempotency-Keyand anX-Request-Id; every error has the same shape. - Customers — onboard a customer with its main wallet; read the summary; manage identifiers, agreement acceptances and KYC level changes.
- Wallets — balance and statement.
- Money movement — wallet credits, wallet debits (customer present, or on a standing mandate), payouts, top-up intents and fee quotes.
- Mandates — create, list, read and revoke.
- Operations — list, status with timeline, and receipt.
- Webhooks — 16 signed event types, retried for about 38 hours.
Added since the initial release:
- Wallet profiles —
POST /customerstakes an optionalrulesProfileKey, the wallet profile to open the customer's wallet on; omit it for your account's default. The customer responses return therulesProfileKeythe wallet opened on. A profile, KYC level (kyc.tierCode) or payment provider (railCode) your account is not approved for is refused with422. - Wallet transfers —
POST /wallet-transfersmoves money between two wallets of your own customers: to another customer (transferCategory: "p2p") or into your business collection wallet ("merchant"). It needs the newwallet-transfers:writescope, which no existing account holds until it is granted, and the sending customer'sassertion, bound withdestinationWalletUidas the binding target. The newtransfer.completedwebhook carries the operation withsourceWalletUidanddestinationWalletUid; those two fields appear on wallet transfers only. The transfer shows on both wallets' statements, with the other party as a masked name, andGET /operations/{uid}/receipttakes an optionalwalletUid: set it to the wallet that was paid for the recipient's receipt, which has no fee lines. - Customer conflicts — a
409onPOST /customersis returned only when your account already has a customer with that verified identifier. - Required agreements —
POST /customersis refused with422 RequiredAgreementsMissingwhenagreementsdoes not carry the customer's acceptance ofwallet_opening,privacyandfee_schedule; nothing is created. Earlier such a request created the customer and wallet inpending, a state no request could leave. - Leaner onboarding —
POST /customersnow needs only what Neo Wallet cannot work out:customerTypedefaults toperson; an identifier needs justtypeandvalue(classis derived for phone, email and identity-document types, the first identifier is primary, a contact identifier opensverifiedand a documentpending);kyc.verificationSourceandkyc.sourceReferenceare optional; an agreement'sdocumentTypedefaults to its type andacceptedChanneltoclient_channel. A value you send always wins.kyc.reviewTypeis removed — sending it is refused with400like any other undeclared field. - Customer reference —
clientReferenceonPOST /customersis now unique among your customers: a second customer with the same value is refused with409. It is returned on the customer responses as before and now also on thecustomer.createdwebhook, and the client portal's customer list finds a customer by it.