Skip to main content
Changes that affect how you integrate: new endpoints and fields, behavior changes, and new error or failure codes. Breaking changes are called out explicitly.
New feature

Transactions now carry received_amount — what actually landed

GET /v1/transactions and GET /v1/transactions/{transactionId} now return received_amount: the amount the recipient actually received, formatted to the destination currency’s precision.This closes a gap. destination_amount on a transaction is the amount quoted, and on corridors that charge a transaction fee separately that figure is the amount before the fee — an off-ramp quoted at 110 ZAR can credit the recipient’s bank 100. The real figure was already on GET /v1/onramps/{onrampId}, GET /v1/offramps/{offrampId} and GET /v1/conversions/{conversionId}, but reading it from a transactions list meant a follow-up request per row. Now it is on the transaction itself, and on each entry in related_transactions.When it is null. For onramp, offramp and conversion transactions, received_amount is null until the transaction reaches a terminal success, and stays null on one that did not pay out. Only a settled payout has a delivered amount; before then there is nothing to report, and we would rather say so than hand back the quote dressed up as a result. Treat null as “not known yet”, never as zero — while a transaction is in flight, destination_amount remains your best estimate of what will be delivered.When it equals destination_amount. On rails where nothing is deducted — deposits, and funds returned to your wallet — the two are the same by definition, and received_amount simply mirrors destination_amount. Reading received_amount is therefore safe across every transaction type; you do not need to branch on source_type to know which field to trust.Reconciling against the quote. Compare received_amount with destination_amount to see the gap a transaction fee opened up. destination_amount is unchanged and still the quoted figure, so nothing you already read has shifted.One exception worth knowing: cross-currency deposits. On an auto-converted deposit, destination_amount is priced from the quote at deposit time, so the received_amount that mirrors it is a quote-time figure too — not a settled one. The amount that actually landed is actual_destination_amount on the matching conversion.success event. Reconcile those against the conversion, as you do today; received_amount on the deposit is for display, not for settlement.Deposit webhooks carry the field too — their event_object is the transaction record — so deposit.received, deposit.held, deposit.confirmed and deposit.failed all include it. See Webhook events for the full payloads.
New feature

Pay businesses over PayShap by ShapName

A PayShap beneficiary can now be addressed by a ShapName as well as a mobile number. A ShapName is the name@bank identifier that businesses usually register for PayShap, for example acme@nedbank. Put it in account_number, exactly as the recipient gives it, including the part after @:
Everything else works as it does for a mobile number:
  • We resolve the ShapName before we create the beneficiary. For a business, account_name is the registered business name.
  • An unregistered ShapName returns 400 invalid_input, and no beneficiary is created.
  • A value that cannot be a ShapName also returns 400 invalid_input: nothing before or after the @, more than one @, or a space inside it.
Mobile-number proxies are unchanged.
New feature

Fiat deposits now name the sender, and onramps report the payout hash

Two additions to what you already receive. Both are additive and optional — nothing you read today has changed shape.

Who sent a fiat deposit

A deposit into a fiat credential now carries the payer in metadata, on both the deposit.* webhook and GET /v1/transactions/{id}:
Enough to reconcile an unreferenced transfer, or to show a human who paid, without asking your customer.A key you do not see was not reported. Keys are omitted rather than blank, so check for presence instead of comparing against "" — banks report these inconsistently, and you may get the name without the bank, or nothing at all. sender_account_number is the last 4 digits, always: use it to help someone recognise a payment, not as an account identifier.One case worth knowing: on deposits into a Paystack NGN virtual account, the bank and account describe the virtual account the money landed in, while the name is the payer’s. That rail does not expose the payer’s own bank details to us, and we would rather say so than guess.Crypto deposits are unchanged — the on-chain from_address in metadata is the equivalent.

The onramp payout hash

onramp.success now carries transaction_hash in event_object: the on-chain hash of the payout that delivered your crypto, so you can link a settlement to a block explorer without asking us for it.Present or absent, never null. It appears on onramps and effectively nowhere else — an offramp pays a bank account and has no on-chain leg, and a conversion between two of your own balances moves nothing on-chain. Some onramp rails also settle before a hash exists. So write your handler to tolerate the key being missing.Store it when you receive it. This webhook is the only place it appears — GET /v1/onramps/{id} does not return it, and a hash that reaches us after the terminal event does not trigger a second webhook, because an onramp delivers onramp.success exactly once.See Webhook events for the full payloads.
Behavior change

Failed transactions now tell you why: quote_expired, refund_failed, returned

A conversion, onramp, or offramp that ended badly used to report one thing no matter what happened: failure_reason: "failed", “This transaction could not be completed.” A quote nobody funded, which simply expired, was indistinguishable from something breaking mid-processing — which inflated apparent failure rates and misrouted alerting. Three new codes separate them.quote_expired is the common one — by a wide margin. It means nobody paid in time, not that anything went wrong on our side. No funds moved, so it is always safe to create a fresh quote and try again. If you alert on failures, this is the code to exclude.This can change what you already receive. Cases that previously arrived as failed now arrive as quote_expired, refund_failed, or returned. Nothing was removed and no field changed shape — but if you branch on failure_reason === "failed", that branch will stop catching them. failed remains the generic catch-all for anything we do not surface specifically.returned is the one that is not a failed status. The other codes accompany status: "failed"; returned accompanies status: "returned", which is its own terminal state. If you only read failure_reason when status is "failed", you will not see it — gate on the transaction not having succeeded instead.Note that returned deliberately does not promise a credit. Whether funds come back depends on how far the transaction got: an offramp whose payout had already settled is credited back in full, while one that never debited you has nothing to return. Check your balance or transactions list rather than inferring a credit from the code.Statuses themselves are unchanged — an expired conversion still reports status: "failed", so the six status values stay exactly as documented. The distinction travels on failure_reason alone, which is why this needed no new field.The full list, with the exact failure_reason_message for each, is in Failure reasons.
Behavior change

South African account numbers are now validated before payout

A ZAR bank account_number that cannot structurally belong to its bank is now rejected with 400 invalid_input, instead of being accepted and failing at the bank days later. The check is local to us — no extra latency, no new field, no schema change.This can reject requests that previously succeeded. Two places:
  • POST /v1/beneficiaries and PATCH /v1/beneficiaries/{id} — at the point the details are captured, so the person entering them can still fix it.
  • POST /v1/quotes for an offramp — which means a beneficiary saved before today can fail here, even though it saved cleanly at the time. That is deliberate: the quote is the last moment we hold the number before it goes to the payout network. Update the beneficiary’s account_number and quote again.
Two rules, and neither is a reformatting:
  • Digits only. Separators are rejected, not stripped — 6200-4626-662 is invalid even though removing the dashes would leave a valid number. We never alter what you send, so a number we accept is exactly the number we pay. Trim your inputs before sending them.
  • Length must match the bank on the beneficiary’s account_provider — FNB 11, Capitec 10, Nedbank 10, Standard Bank 9 or 11, Absa 8–11, and so on; any other South African bank is checked against 8–11 digits. The full table is in Errors → South African account numbers.
The message names the bank and both digit counts — FNB accounts are 11 digits; this account number has 10. — and is written to be shown to whoever entered the value. "Invalid account" becomes a support ticket; the specific string gets fixed.PayShap is unaffected. On the payshap rail account_number is an E.164 proxy, not a bank account number, so none of these rules apply to it.What this does not do: it cannot tell you whether an account exists, is open, or accepts credits. A structurally perfect number can still be returned by the bank as unrecognised or closed. This only removes the failures that were impossible from the start.
Behavior change

A returned offramp now refunds your wallet automatically

When a payout provider sends an offramp’s funds back, the offramp goes returned as before — but if we had already taken the source amount from your wallet, we now credit it straight back. Previously the offramp went terminal while your balance stayed short, and recovering the funds took a support request.The refund is the full source amount of the offramp. No fee is withheld: the money movement never happened, so nothing is retained.How to see it:
  • offramp.returned is still the signal. There is no new webhook — no deposit.* event is emitted for the refund.
  • The credit appears in GET /v1/transactions as a deposit-type transaction with a new source_type of offramp_reversal, linked to the original offramp as its parent.
  • Your wallet balance reflects it immediately.
If you reconcile on source_type, add offramp_reversal to the values you expect. A payout that was never funded from your balance is unaffected — there is nothing to return, and no refund transaction is written.
New feature

Pay South African beneficiaries over PayShap

Offramp beneficiaries in ZAR can now be paid over PayShap, South Africa’s instant-payment scheme. A beneficiary has a new optional field, rail, which says how money reaches the account. network still says what the account is. A PayShap beneficiary is a bank account (network: "bank") that you pay with rail: "payshap".You address a PayShap beneficiary by its proxy, the recipient’s mobile number in E.164 form. Put the proxy in account_number:
Do not send provider_id. A proxy resolves to the bank that holds it, so you do not choose a provider. If you send one, we ignore it. PayShap beneficiaries return provider: null.Do not send account_name. We resolve the proxy to its registered holder before we create the beneficiary, and we store that name. Any name you send is replaced. This is the reason to prefer a proxy to a raw account number: we can verify who holds a proxy before you pay it.If we cannot verify the proxy, we reject the request with 400 invalid_input and create no beneficiary. This happens when the number is not registered for PayShap, and also when the recipient’s bank does not answer in time. You cannot tell the two cases apart, so retry once before you treat a number as invalid. If PayShap itself is unavailable, you get 500 external_service_unavailable; retry later.rail: "payshap" is valid only with network: "bank", currency ZAR and country ZA. Any other combination returns 400 invalid_input. If PayShap is switched off, creating a PayShap beneficiary returns 403 forbidden.

What changes in responses

  • Beneficiaries echo details.rail when it is set. Beneficiaries without a rail do not get the field.
  • Offramps paid over PayShap report payment_instrument.type as "bank_account", the same value as any bank payout, with payment_instrument.rail: "payshap" beside it. If you branch on type, you do not need to change anything.
  • provider can be null on a fiat beneficiary’s details and on payment_instrument, but only when rail is "payshap". If you read provider.id or provider.name, handle null before you create PayShap beneficiaries.
DocumentationBehavior change

Every webhook event is now documented, with payloads

Webhook events is a new reference covering all 27 events we emit: which category each belongs to, exactly when it fires, and a complete example payload per family — onramp, offramp, conversion, transfer, deposit and verification.Nothing about the events themselves changed. If you have been reverse-engineering payloads from deliveries, the page confirms the shape and the guarantees: event_object mirrors the matching REST resource, plus event-only fields (actual_source_amount, actual_destination_amount, actual_rate, terminal_at, deposit_timestamp) that the REST body does not carry. Reconcile money against the actual_* values, not the quoted ones.A few behaviours the page pins down that were easy to get wrong:
  • A held deposit sends two events — deposit.held, then deposit.confirmed on release.
  • A cross-currency deposit never reaches a terminal deposit.* event. It stays at received; the conversion.* events carry the outcome.
  • funds_received and processing are separate events on the FX families, and a resource emits any given event at most once — so a cancelled-then-reversed transfer reaches you as a single transfer.failed.

Removed: kyc.expired

The kyc category previously registered a kyc.expired topic that nothing ever emitted — no endpoint has ever received one. It has been removed, so the subscribable list now matches what we actually send. Verification events are kyc.approved and kyc.rejected; an expired verification link is visible on GET /v1/customers/{customerId}/verifications, and the customer can request a new one. No action needed unless you wrote a handler branch for an event you never received.
New featureFix

Cross-currency deposits now tell you when the money arrives

Deposits on a cross-currency credential used to be silent until the conversion started — your first webhook was conversion.created, and if the conversion never started, you heard nothing at all.They now emit deposit.received on arrival. The event waits for the FX quote, so it carries what the deposit is worth in your wallet’s currency rather than just the amount the payer sent:
The wallet, customer and credential blocks are new, and appear only on cross-currency deposit events. They tell you which credential was funded — including the payer-facing bank details of the virtual account — without a second API call. Same-currency deposit payloads are unchanged.Two things to know before you switch on handling:
  • destination_amount is the quoted amount, not the settled one. Reconcile against actual_destination_amount on conversion.success.
  • A cross-currency deposit never reaches a terminal deposit.* state. It stays at received, and the conversion.* topics carry the outcome. Do not wait for deposit.confirmed on this path.
A deposit that breaches your customer’s rolling money-in limit now emits deposit.held with metadata.hold_reason of tier_limit and a null destination_amount — no rate is quoted while it is under review. On release you receive deposit.received with the amount quoted at the then-current rate.

Fixed: credential currency on a cross-currency credential

GET /v1/wallets/{id}/credentials and GET /v1/wallets/{id}/credentials/{credentialId} reported the wallet’s currency for every credential. On a cross-currency credential that was wrong — an NGN credential on a USDC wallet returned "currency": "USDC" alongside its NGN bank details. Both endpoints now return the credential’s own currency. The nested wallet.currency still reports the wallet’s, so you can read both. Same-currency credentials are unaffected, since the two values are identical there.
If you read currency on a cross-currency credential and relied on it meaning “the wallet’s currency”, update that code — it now means “the currency this credential accepts”.
New feature

Forwarding wallets send every deposit straight to your own address

POST /v1/wallets now accepts a type. The default, custodial, is every wallet you have today: it holds a balance you can convert, offramp and transfer out of. Nothing about your existing wallets changes, and omitting type keeps current behaviour.The new forwarding type holds no balance. You give it an address you control at creation, and every inflow is delivered there:
destination_address and destination_chain are required for forwarding and rejected on a custodial wallet. destination_chain is one of solana, base or ethereum and must be supported by the wallet’s currency; the address is validated against the chain you pick. A forwarding wallet must use a crypto currency.Both fields are permanent. They cannot be changed after creation — create a new wallet to change where funds land. Please double-check the address: every future deposit to that wallet goes to it, and we cannot recover funds sent to an address you do not control.Two consequences worth knowing before you integrate:
  • A forwarding wallet’s available_balance, pending_balance and total_balance always read zero, because it never holds funds.
  • It cannot be the source of a transfer, offramp or conversion — those are rejected with 400 invalid_input. It is a destination only.
All four wallet endpoints now return type, destination_address and destination_chain. On a custodial wallet the two destination fields are null.
New feature

Wallets now tell you which customer owns them

Wallet responses now include a customer object alongside the existing customer_id, so you no longer need a second call to /v1/customers just to show an owner’s name. It is returned by POST /v1/wallets, GET /v1/wallets, GET /v1/wallets/{walletId} and PATCH /v1/wallets/{walletId}.The object carries id, type, name, email, tier, created_at and updated_at — the same shape already nested on transfer, onramp, offramp, and conversion responses. For KYC status, metadata, or the customer profile, keep using GET /v1/customers/{customerId}.customer_id is unchanged and still present, so nothing you already key off has moved. Wallets owned by your integration’s default customer return a populated customer too — those are the wallets you could not previously resolve a name for, since the default customer is not listed by GET /v1/customers.
New feature

Quote by the amount the recipient receives with target_amount

Onramp, offramp, and conversion quotes now accept target_amount as an alternative to source_amount. target_amount is the amount the recipient receives — the crypto credited to the wallet on an onramp, the fiat paid to the beneficiary on an offramp, or the amount credited to the destination wallet on a conversion.Provide exactly one of source_amount or target_amount. Sending both, or neither, is rejected with 400 invalid_input before anything is created. When you send target_amount, we compute the required source_amount and return it on the quote, so the response always carries both source_amount and destination_amount.The USDC 2-decimal-place settlement limit applies only when you supply source_amount. A target_amount-driven quote is not constrained this way — we derive a settleable source amount for you.

Quotes now return their fee breakdown

Quote responses now include an optional fee object with a per-component line_items breakdown (name, amount, currency), matching the fee already returned on transactions. Each component keeps its own currency — for example a gateway fee in the source currency and a network fee in the destination currency. The fee field is omitted when a quote carries no fee components.
Behavior change

USDC amounts are limited to 2 decimal places

USDC settles in whole US cents, so USDC amounts now accept at most 2 significant decimal places. Trailing zeros are fine — 2.980000 is accepted, 2.980228 is rejected with 400 invalid_amount_precision.This applies to source_amount on offramp quotes and conversion quotes, and to amount on transfers. The request is rejected before anything is created, so a rejected call leaves no quote and moves no funds.If you derive amounts from a balance or a destination-anchored price, round to 2 decimal places before sending them.

Offramps fail immediately when funding fails

An offramp whose funding transfer cannot be completed now fails as soon as that becomes known, with the new failure reason funding_failed. Previously the offramp stayed in processing until its provider quote expired, roughly an hour later.No funds move in this case. Branch on failure_reason to retry with a new quote — and expect the offramp.failed webhook sooner than before.