Skip to content

Changelog ​

Additive changes land under /api/v1; a breaking change ships as /api/v2 alongside it, with at least 90 days' notice. See Versioning.

A top-up prompted on the customer's phone lives long enough to be answered ​

On a payment provider that prompts the customer's phone, a top-up intent now lives at least twice as long as the provider allows the customer to answer — two minutes on telebirr — whatever expiresInSeconds asks for. A customer who approves the prompt near the end of a short lifetime is credited instead of losing the race with the intent's expiry.

  • expiresAt in the 201 is the lifetime granted; read it rather than adding expiresInSeconds to the time of your request.
  • A lifetime longer than the minimum is kept as asked, up to 24 hours as before.
  • The outcome still reaches you as topup.confirmed or topup.expired.

A phone number is always an msisdn identifier ​

An identifier you send with type phone, mobile, tel or contact_number is stored as msisdn, and every response and webhook names it msisdn. A phone number now finds the same customer whichever of these names you, or anyone else, sent it under.

  • POST /customers and POST /customers/{uid}/identifiers accept these names as before.
  • Identifiers already added under one of them read as msisdn from now on.

Transfers pay any Neo Wallet by account number (breaking) ​

POST /wallet-transfers now names the wallet to pay by its account number, and a p2p transfer may pay any Neo Wallet, not only your own customers'. New route GET /wallets/lookup takes an accountNumber and returns the masked name of the wallet's holder, so the customer can confirm the payee before sending.

  • The request takes destinationAccountNumber in place of destinationWalletUid: 13 digits, the last a check digit. A malformed number is a 400; a number that names no wallet able to receive the transfer is the usual 422.
  • The operation (the 201, GET /operations/{uid} and transfer.completed) carries destinationAccountNumber in place of destinationWalletUid.
  • The assertion's binding target for a transfer is destinationAccountNumber. The canonical template is unchanged.
  • A merchant transfer still pays only your own business collection wallet.
  • GET /wallets/lookup needs wallet-transfers:write. It answers 404 for a number that names no wallet able to receive a transfer, and 429 past a per-account limit each minute.
  • The holder's name comes masked as the first name and the initials of the rest (Abebe B.). A transfer receipt names its other side the same way, in place of the last three letters.
  • The same request sent twice within one second signs the same string, which is refused as a replay: give the second the next second's timestamp. The kit's TypeScript and Python helpers now do this for you, as the Postman collection already did.
  • The receiving wallet takes the amount whatever its holder's KYC level allows. Its holder's other services are told by wallet.balance_changed.

Every movement on your customer's wallet reaches you ​

New webhook wallet.balance_changed: when your customer's wallet balance moves by something you did not start, you are told which wallet, which way, how much, the operation type and when. Its data key is movement: walletUid, accountNumber, direction (credit or debit), amount, operationType, occurredAt. With your own operations' events it accounts for every change in the balance you read.

  • customer.status_changed and wallet.status_changed now carry clientReference, your own reference for the customer (or null).
  • wallet_closure.completed and closed_balance_refund.completed are now Neo Wallet's own operations, like ledger_adjustment.completed: GET /operations/{uid} and its receipt answer 404, so keep the payload as your record.
  • Treat dedupKey as an opaque string: it is unique to the event and to you.
  • Events for one resource arrive in order for you alone; a failing delivery of yours holds back only your own later events.
  • The delivery log and retry in the client portal list every event sent to you, including customer and wallet events.

A customer you onboard may already have a Neo Wallet ​

A person is one customer with one wallet. When the primary phone number you send to POST /customers already belongs to a customer, that customer becomes yours: the 201 carries the same customer, wallet and account number, under your own KYC level, agreements, wallet profile and reference. Requests and responses keep their shape.

  • The customer's name, date of birth and identifiers stay as they are. An identifier you send that the customer lacks is added as yours, and identifiers in the response lists every identifier the customer holds, masked.
  • customer.created is sent to you with your clientReference. createdAt, on the response and on GET /customers/{uid}, is when the customer became yours.
  • wallets[].rulesProfileKey on GET /customers/{uid} is your profile for the customer. The balance is the customer's own, so it can also move through operations you did not start.
  • A 409 on POST /customers now means the customer is yours already, an identifier you sent belongs to another customer, or the request cannot be accepted for this customer.
  • You can change only the identifiers you added: PATCH /customers/{uid}/identifiers/{iid} on another is refused with 422, as is a new primary that would replace one you did not add. Replacing or retiring the customer's primary phone number may also be refused with 422.

KYC levels and limits apply to the operations you start ​

A customer's KYC level, limits and status are now the ones you hold for them, and they govern only the operations you start. Requests and responses keep their shape.

  • A move to a lower KYC level no longer flags the wallet credit_blocked. While the wallet holds more than the new level's maximum balance, your credits to it are refused with 422; debits continue.
  • Daily and monthly limits count only the operations you start.
  • When the KYC version you submitted reaches its end date, the customer is restricted for you: customer.status_changed is delivered to you and GET /customers/{uid} shows restricted.
  • A customer you onboard with risk rating prohibited is still restricted for you, but the wallet opens active rather than pending. Nothing you start is accepted until Neo Wallet's compliance review lifts the restriction.
  • The recipient of a wallet transfer is no longer checked against its KYC level's maximum balance.

Wallet account numbers ​

Every wallet now has a 13-digit accountNumber, returned beside walletUid by POST /customers, GET /customers/{uid} (on each wallet) and GET /wallets/{uid}/balance. It is the number a person gives to be paid into their wallet. The last digit is a check digit, so a mistyped number is caught before it is used. It is shown in full, never changes and is never given to another wallet. Existing wallets have one too. Nothing else changes, and nothing yet accepts an account number as input.

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/v1 as 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 assertion and computes its transaction binding from the body it sends.
  • Signed for you — the collection signs every request and adds X-Request-Id and, on writes, Idempotency-Key. It runs in Postman and in Newman.
  • Documented in Postman — the set-up, the variables and a 401 checklist 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}/agreements and /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[].value leaves the customer summary. A grant that carried either scope has had it removed.
  • Removed field — references.journalUids on the receipt. The operation uid is your traceable reference; no internal accounting identifier is returned anywhere.
  • New fields — identifiers[].payoutEligible on the customer summary (a verified contact identifier that is valid now), and intentReference / expiresAt on GET /operations/{uid} for a topup_c2b (null on every other type).
  • New webhooks — ledger_adjustment.completed, wallet_closure.completed and closed_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 .00 and 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 with 400 BadRequest, and so are an exponent, a +, a leading zero, surrounding whitespace, a thousands separator and a bare .5 or 12..
  • A third decimal place is refused with the new AmountPrecisionExceeded code and a 400, as is an amount above 18 integer digits. An amount you send is never rounded for you.
  • Field names drop the suffix: amountMinor is amount, feeMinor is fee, taxMinor is tax, totalMinor is total, totalDebitedMinor is totalDebited, fixedOrMaxAmountMinor is fixedOrMaxAmount, maxBalanceMinor is maxBalance, maxSingleTxnMinor is maxSingleTxn, aggregateDailyLimitMinor is aggregateDailyLimit, monthlyOutLimitMinor is monthlyOutLimit, and a wallet balance reads current, held and available.
  • The transaction binding hashes the amount as two decimal places under the new template customerUid|walletUid|amount|currency|destinationIdentifierUid|clientReference. Echo that template as transactionBinding.canonical and hash 7500.00 where you hashed 750000; the old template is refused with AssertionNotBoundToTransaction.
  • The starter kit follows: transactionBinding() takes amount as 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-Key and an X-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 /customers takes an optional rulesProfileKey, the wallet profile to open the customer's wallet on; omit it for your account's default. The customer responses return the rulesProfileKey the wallet opened on. A profile, KYC level (kyc.tierCode) or payment provider (railCode) your account is not approved for is refused with 422.
  • Wallet transfers — POST /wallet-transfers moves money between two wallets of your own customers: to another customer (transferCategory: "p2p") or into your business collection wallet ("merchant"). It needs the new wallet-transfers:write scope, which no existing account holds until it is granted, and the sending customer's assertion, bound with destinationWalletUid as the binding target. The new transfer.completed webhook carries the operation with sourceWalletUid and destinationWalletUid; those two fields appear on wallet transfers only. The transfer shows on both wallets' statements, with the other party as a masked name, and GET /operations/{uid}/receipt takes an optional walletUid: set it to the wallet that was paid for the recipient's receipt, which has no fee lines.
  • Customer conflicts — a 409 on POST /customers is returned only when your account already has a customer with that verified identifier.
  • Required agreements — POST /customers is refused with 422 RequiredAgreementsMissing when agreements does not carry the customer's acceptance of wallet_opening, privacy and fee_schedule; nothing is created. Earlier such a request created the customer and wallet in pending, a state no request could leave.
  • Leaner onboarding — POST /customers now needs only what Neo Wallet cannot work out: customerType defaults to person; an identifier needs just type and value (class is derived for phone, email and identity-document types, the first identifier is primary, a contact identifier opens verified and a document pending); kyc.verificationSource and kyc.sourceReference are optional; an agreement's documentType defaults to its type and acceptedChannel to client_channel. A value you send always wins. kyc.reviewType is removed — sending it is refused with 400 like any other undeclared field.
  • Customer reference — clientReference on POST /customers is now unique among your customers: a second customer with the same value is refused with 409. It is returned on the customer responses as before and now also on the customer.created webhook, and the client portal's customer list finds a customer by it.