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 thename@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 @:- We resolve the ShapName before we create the beneficiary. For a business,
account_nameis 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.
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 inmetadata, on both the deposit.*
webhook and GET /v1/transactions/{id}:"" — 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 bankaccount_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/beneficiariesandPATCH /v1/beneficiaries/{id}— at the point the details are captured, so the person entering them can still fix it.POST /v1/quotesfor 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’saccount_numberand quote again.
- Digits only. Separators are rejected, not stripped —
6200-4626-662is 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.
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 goesreturned 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.returnedis still the signal. There is no new webhook — nodeposit.*event is emitted for the refund.- The credit appears in
GET /v1/transactionsas adeposit-type transaction with a newsource_typeofofframp_reversal, linked to the original offramp as its parent. - Your wallet balance reflects it immediately.
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 inZAR 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: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.railwhen it is set. Beneficiaries without a rail do not get the field. - Offramps paid over PayShap report
payment_instrument.typeas"bank_account", the same value as any bank payout, withpayment_instrument.rail: "payshap"beside it. If you branch ontype, you do not need to change anything. providercan benullon a fiat beneficiary’sdetailsand onpayment_instrument, but only whenrailis"payshap". If you readprovider.idorprovider.name, handlenullbefore 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, thendeposit.confirmedon release. - A cross-currency deposit never reaches a terminal
deposit.*event. It stays atreceived; theconversion.*events carry the outcome. funds_receivedandprocessingare 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 singletransfer.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 wasconversion.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: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_amountis the quoted amount, not the settled one. Reconcile againstactual_destination_amountonconversion.success.- A cross-currency deposit never reaches a terminal
deposit.*state. It stays atreceived, and theconversion.*topics carry the outcome. Do not wait fordeposit.confirmedon this path.
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.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_balanceandtotal_balancealways 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.
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 acustomer 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 optionalfee 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 reasonfunding_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.