PaymentsDeprecatedBreaking
Virtual accounts are deprecated; inflows must now come in as a direct escrow payment
Virtual accounts are no longer issued
POST /v1/payment/virtual-account no longer issues an account. Every call
now returns 410 Gone, whatever the customer’s country, active gateway or
BVN status.CBN rules require every inflow to arrive as a direct escrow payment. Money paid
into a virtual account lands in a wallet with no escrow behind it and nothing
recording what it was for, so the account cannot be offered.What to use instead
- Create an escrow with
POST /v1/escrow/create. - Collect payment for it with the Payluk Checkout SDK, or
server-side with
POST /v1/payment/create-intent. - Confirm it with
POST /v1/payment/verify.
The path is kept and answers
410 rather than 404 on purpose: an
integration still calling it is told what happened and what to use instead,
where a 404 would read as a bad path and send you looking for a typo.Sandbox crypto no longer touches mainnet
Crypto payments previously resolved one blockchain configuration for every request, whatever environment the request was in. A payment made with a test key therefore settled on BSC mainnet, in real cNGN, from the same master wallet as live traffic. Test and live crypto are now fully separated.- A request in the test environment runs on BSC testnet (chain
97), against a separate cNGN contract and a separate pair of master wallets. - A request in the live environment is unchanged: BSC mainnet, chain
56, the same contract and wallets as before. - The
network,networkIdandexplorerUrlfields onGET /v1/payment/get-master-wallet-addressnow describe the chain the address is actually on, so a sandbox address links totestnet.bscscan.comrather than tobscscan.com. TheexplorerUrlrecorded on blockchain payments follows the same rule.
Fixed
- Deposit and withdrawal master wallets are no longer interchangeable. Whichever wallet a process resolved first was reused for both roles, so a payout could be signed by the deposit wallet.
fromAddresson a blockchain payment intent is now the wallet address. It was previously recorded before the address had resolved, leaving the field empty on the stored payment.
Merchant AccountPaymentsNew
Read your own balance and ledger, and set a page size on transaction history
Your merchant account
Every endpoint so far acted on behalf of one of your customers. Two new ones answer for your own merchant account, so you no longer have to infer your earnings from your customers’ activity.GET /v1/merchant/balancereturnsmainBalanceandescrowBalanceon your own merchant wallet. This is where Payluk settles your earnings.GET /v1/merchant/transactionsreturns your own ledger:commissionfor your share of an escrow fee,deliveryfor a delivery fee you charged a buyer, and any withdrawals you made. It takes the samepage,limit,type,reference,fromDateandtoDatefilters as the customer history.- Both require a merchant super-admin key, and both refuse a
customer-idheader. Sending one returns400rather than quietly answering for that customer, so you can never mistake a customer’s balance for your own. - Your customers’ balances are unaffected and are never counted in yours. Read those from Get customer wallet and Get payment history as before.
payment.commission.success and payment.delivery.success events added on
August 21. Treat the webhook as the trigger and these endpoints as the source of
truth, rather than polling for a balance change.Page size on transaction history
GET /v1/payment/history and GET /v1/merchant/transactions now accept a
limit query parameter.limitsets the transactions per page, between 1 and 100.- The default is unchanged at 10, so existing calls return exactly what they returned before. Nothing on your side needs to change.
- A value above 100 is capped at 100. An unusable value falls back to 10, so a malformed request still returns a sensible page rather than an error or an empty result.
limitapplies to the paginated response only. Supplyingreference,fromDateortoDatestill returns a flat array, which is not paged.
Phone numbers and countryId
Creating a customer rejected every phone number withUnable to validate phone number, whatever format you sent. That message was wrong: the failure was in
resolving countryId, not in the phone number, and the request never reached a
phone rule at all. It is fixed, along with the request example in these docs,
which showed a + that the API has never accepted.- Send
phoneas digits only, with no leading+. Local and international spellings of the same number are all accepted:08012345678,2348012345678and8012345678are the same customer. Each is stored in the local form (08012345678), which is what the customer endpoints return. countryIdis now optional. Omit it and the customer inherits the country on your merchant account. It also accepts an ISO code, soNGworks as well as anidfrom Get countries.- A country that cannot be resolved now says so, with
Invalid country selectedinstead of blaming the phone number. - Phone lookups match any spelling.
GET /v1/customers?phone=andGET /v1/payment/verify-phone/{phone}find the same customer whether you send08012345678or2348012345678, so you no longer have to remember the format you onboarded them with. This replaces the exact-match behaviour described below on August 15. Only the country prefix is interchangeable: a different subscriber number is still a different customer. - Customers created before this change keep the number as it was stored. Lookups find them either way, so nothing on your side needs to change.
Transaction webhooks
Your callback URL only ever received escrow updates, so a customer funding their wallet or taking a payout was invisible until you polled for it. Every transaction a merchant customer settles now reaches the same endpoint, on a newpayment.* stream.- Deposits, bank transfers into a reserved account, withdrawals, wallet-to-wallet
sends and escrow fundings all emit an event when they settle. The event name is
payment.<transactionType>.<outcome>, for examplepayment.deposit.successorpayment.withdrawal.failed. - The two payouts Payluk makes into your own merchant wallet are on the same
stream:
payment.commission.successfor your share of an escrow fee, andpayment.delivery.successfor anadditionalFeeyou charged the buyer. - The payload does not name the bank or processor behind a transaction. Which rail Payluk settles through is not part of this contract, so nothing in your handler should branch on it.
- Failures are reported too, so a declined payout no longer looks like a payout still in flight.
- Nothing is sent while a transaction is pending, and each transaction announces
itself once. De-duplicate on
data.reference. - The envelope, the
x-payluk-signatureHMAC-SHA512 header, the callback URL and the retry behaviour are unchanged and shared withescrow.*events. Switch on theeventfield and return2xxfor names you do not handle. - Funding an escrow now produces two webhooks:
payment.escrow.successfor the ledger entry andescrow.ongoingfor the deal. Act on one stream, not both. - Existing escrow integrations need no changes.
Test a bank transfer end to end on staging
Virtual accounts on staging were issued by a sandbox bank with no way to send money into them, so a deposit could be generated but never paid. The Payluk Test Bank closes that gap: it issues the account and gives you a funded float to pay it from.- On staging, Generate virtual account
now returns an account you can actually pay. Simulated accounts read
Payluk Test Bankinbank, carry the bank code999999, and their account numbers begin with99. - Pay one at
https://staging.live.payluk.ng/v1/checkout/test-bank. Paste the account number, confirm the resolved name and the expected amount, and send. - Paying runs the same settlement your live traffic runs: the same credit
notification, the same verification, the same wallet credit. Read the balance
back with Get customer wallet,
or find the deposit in
Get payment history as a
transfer. - The float carries three source accounts (₦1,000,000, ₦250,000, and ₦100), so an insufficient-funds rejection is one click away. Reset balances restores them.
- Nothing changes on production, where accounts are issued by the real bank and
every test bank route returns
404.
A dedicated virtual account now depends on the active gateway
Generate virtual account: a dedicated account is a Paystack product, so one is now only issued while Paystack is the active gateway. Previously any customer with a BVN on file was routed to Paystack regardless of which gateway was collecting.- Under Safe Haven, every merchant customer now receives the temporary account, whether or not they have a BVN. Safe Haven opens a reserved account only against an identity it verified itself, which requires an OTP sent to the phone behind the BVN. A server-side integration has no way to collect that, so the BVN could never be verified through this API and the old routing produced an account the gateway could not settle.
- Under Paystack nothing changes: a customer with a BVN still gets a dedicated account, one without still gets a temporary account.
- Check
dedicatedin the response rather than inferring the account type from whether you sent a BVN. Dedicated accounts have noexpiresIn; temporary ones expire after 24 hours and must be re-read.
Find a customer without their ID
List merchant customers:GET /v1/customers accepts email and phone. Both are unique among
your customers, so either one identifies a single record — useful when a support
ticket or a webhook gives you an address or a number but not the customerId
the rest of the API wants.- The response keeps the same shape as the plain listing: the customer is the
only row in
data, under a one-pagepagination. Nothing needs to special-case the lookup. emailis case-insensitive.phoneis matched exactly as it was stored when the customer was created, so send it in the format you onboarded them with —08012345678will not find a customer saved as+2348012345678. URL-encode a leading+as%2B.Superseded on August 23, 2026: phone lookups now match any spelling of the same number.- Sending both narrows to the customer matching both, not to two results.
- If nothing matches, the request fails with
400andNo customer found with the provided email(orphone, oremail and phone) rather than returning an empty list. Treat that as “no such customer”, not as an outage. pageandlimitare ignored on a lookup, and the 50-per-page ceiling does not apply to it.- Fully additive. Omit both and the endpoint pages through your customers exactly as before.
Split a disputed escrow between the seller and the buyer
Resolve dispute:POST /v1/escrow/dispute/resolve/{escrowId} accepts a third outcome,
status: SPLIT, for the cases where neither side is wholly right. Send
sellerAmount and buyerAmount and the held funds are divided between the two
in one pass, instead of all of it going to one party.- The two amounts must add up to exactly what the escrow still holds, which
is not always its
amount. A standard escrow holdsamountless the seller’s share of the escrow fee, so a buyer-paid escrow holds the full amount and awhoPays: bothone holds slightly less. A milestone escrow holds only the milestones the buyer never confirmed. Amounts that do not add up are rejected and no money moves. Up to two decimal places are accepted, because the held pool can end in a half unit. - The escrow closes with the new status
SPLITand carries asplitobject (sellerAmount,buyerAmount,pool,resolvedAt) on every later read. A newescrow.splitwebhook fires, with the same object in its payload. On a milestone escrow, each milestone that was still held is markedSPLITtoo. - The escrow fee is retained in full, exactly as on a refund, and commission is
charged once, exactly as on a completion.
additionalFeeRefundableworks on a split as it does on a refund, so you still choose who keeps the delivery fee. - Vault escrows cannot be split: they are pooled stakes with no buyer and seller to divide between, and are settled by declaring a winner.
- Fully additive.
COMPLETEDandREFUNDEDare unchanged,sellerAmountandbuyerAmountare rejected on those, and existing integrations are unaffected. Handlers that switch on escrow status should add aSPLITcase.
Charge a delivery fee on a standard escrow
Update additional fee:PUT /v1/escrow/additional-fee/{paymentToken} sets your own charge on top of one
of your standard escrows: delivery, handling, anything the escrow amount itself
shouldn’t include. Every escrow starts at additionalFee: 0.- The buyer is charged
amount + their fee share + additionalFee. Send that full total asamounton Pay escrow, or the request is rejected withAmount mismatch. The hosted checkout picks it up automatically and shows it as its own line. - You receive it in full when the escrow completes (Payluk takes no cut),
credited to your merchant wallet as a new
deliverytransaction type, kept separate from yourcommission. The escrow amount, the escrowfeeand the seller’s payout are all unchanged by it. - Settable only while the escrow is
PENDING, and only by you: super-admin key, nocustomer-idheader. Adjust it as often as you need before the buyer pays, or send0to remove it. Once paid, it is frozen. - If a dispute is later resolved in the buyer’s favour, you decide who keeps the
charge when you resolve it:
Resolve dispute now accepts
additionalFeeRefundable.truereturns it to the buyer with the principal;falsecredits it to you because the delivery cost was already incurred. Your choice is saved on the escrow. Omit it and the escrow’s existing setting (trueby default) applies, so nothing changes for existing integrations. - Standard escrows only; milestone and vault escrows are rejected.
Edit a customer’s profile
Update customer:PUT /v1/customer/update/{customerId} lets you correct a customer’s
firstname, lastname or email after onboarding. Send any one of them, and
the fields you omit are left as they are.phoneandbvnstay read-only here; both carry verification state and change through their own flows.- Email remains unique per merchant, so reusing an address another of your customers already holds is rejected with a validation error.
- The customer is resolved scoped to your merchant account, so an ID belonging to another merchant is denied. Super-admin key required.
Vault escrow: merchant-only pooled stakes
A new escrow type for competition-style products: two or more of your customers each stake an amount into one pot, and you declare the winner, who receives the whole pot minus the platform fee.- New settlement type
VAULT, alongsideSTANDARDandMILESTONE. - Four merchant-only endpoints under
/v1/escrow/vault/*: create, declare winner, cancel, and get. - Vaults are created already funded: every stake is verified against the participant’s main balance and locked into their own escrow balance in one all-or-nothing call.
- Tournament mode: any number of participants from 2 up.
- The platform fee is collected whether the vault completes or is cancelled: cancellation refunds each stake minus its pro-rata fee share.
- Escrow responses now carry
participants[]andwinnerIdfields (populated for vault escrows only).
Milestone due dates must be in the future
dueDate on milestone creation, editing, and conversion is now rejected unless
it parses to a future date.Milestone escrow endpoints for merchants
The milestone escrow surface is now available on the merchant (/v1) API:- Create milestone escrow:
POST /v1/escrow/milestone/create - Edit milestone escrow:
PUT /v1/escrow/milestone/edit/{paymentToken} - Convert to milestone:
PUT /v1/escrow/milestone/convert/{paymentToken} - Get milestones:
GET /v1/escrow/milestone/{paymentToken} - Confirm milestone:
POST /v1/escrow/milestone/confirm/{escrowId}/{milestoneId}
Milestones now release in order
A buyer can only confirm the earliest still-pending milestone; milestone 3 cannot be released before 1 and 2. Dispute-driven settlement (release-remaining or refund) still settles every remaining milestone at once.Dedicated virtual accounts for Nigerian customers
Generate virtual account now issues a permanent, dedicated virtual account for merchant customers who supplied a BVN. Customers without a BVN keep receiving 24-hour temporary accounts. Virtual accounts remain Nigerian-only.Milestone escrows
Escrows can now hold the full amount upfront and release it in parts as the buyer confirms each milestone; see Milestone escrows.- New settlement type
MILESTONE; escrow responses carry amilestones[]array. - At least 2 milestones, positive integer amounts summing to the escrow
amount, andwhoPays: buyeronly. - Each milestone may name an optional
customerIdbeneficiary who receives that milestone’s funds instead of the seller. - The escrow completes automatically when the final milestone is released.
Batch payment verification and dispute lookup
- Verify payment accepts multiple
referencesin one call, and escrow payment intents support multiple escrow IDs, so a buyer can pay several escrows at once. - New endpoint: Get dispute by escrow ID.
- Escrow listings accept a
statusfilter.