Skip to main content
RemovedChanged
All funds inflow is an escrow payment through Checkout; direct deposits, card payments, top-ups and crypto deposits removed

Money comes in only as an escrow payment

Every payment into Payluk is now an escrow payment made through the Checkout SDK. Checkout generates a temporary virtual account for the escrow and the buyer pays it by bank transfer. There are no direct deposits and no card payments.

Removed

  • Wallet top-ups: POST /v1/payment/topup (staging).
  • Saved cards: GET /v1/cards and DELETE /v1/card/{cardId}, and the card gateway on Pay escrow (gateway is now wallet only; cardId is gone).
  • Deposits through payment intents: Create payment intent no longer accepts transactionType: deposit or depositDetails. Withdrawals, crypto transfers and wallet transfers are unchanged.
  • Crypto deposits: GET /v1/payment/deposit/wallet and the crypto whitelist routes (/v1/whitelist-address), and the testnet cNGN deposit guide.
  • Webhook: payment.deposit.success is no longer sent. Use payment.escrow.success (or the escrow.* events) to see a payment land. See Webhooks.

Test bank

The Payluk Test Bank guide now covers paying the temporary account that a staging checkout generates.What to change: collect payments with pay() instead of topping up a wallet or charging a saved card, drop calls to the removed routes, and stop listening for payment.deposit.success.
Changed
Update customer now accepts phone and countryId

Update a customer’s phone and country

Update customer now accepts phone and countryId alongside firstname, lastname and email. Send at least one field; omitted fields are left untouched.A new phone is validated against the countryId you send (or your merchant account’s country) and must not already belong to another of your customers.
Changed
Claim funds waits one day after the delivery window; fee rate documented

Claim funds waits one extra day

Claim funds now succeeds only once one day has passed after the delivery window (maxDelivery in the escrow’s deliveryTimeline, counted from paidAt). Until then it answers Escrow cannot be claimed yet, as before.This matches the Payluk app, which already gave the buyer that day to confirm or open a dispute. The app now also counts the window in the escrow’s own unit, so an escrow with a 6-hour window becomes claimable 30 hours after payment on both.What to change: if you schedule claims, add one day to the time you compute from paidAt, maxDelivery and deliveryTimeline.

Fee rate documented

Fees & settlement now states the rate: Payluk’s 2% on merchant escrows plus any commission you set.
ReferenceFixed
Reference corrected against the API: rate limit, IP allowlist, super-admin scope, required delivery window, decimal amounts, milestone disputes

The reference now matches the API

A line-by-line check of this documentation against the merchant API source turned up behaviour that was live but undocumented, and a few statements that were wrong. All of it is corrected in the spec, the guides and the API skill.

Newly documented

  • Rate limit: each secret key may make 10 requests per minute; beyond that the API answers 429 Too many request and every response carries RateLimit headers.
  • IP allowlist: on production, a key with addresses on its dashboard allowlist is refused from anywhere else with 403 Unauthorized IP address.
  • Super-admin scope: only the escrow, milestone-escrow and category routes accept any approved business key. Everything else needs a merchant super-admin key.
  • customer-id is optional on escrow and category routes. Omit it and your own business account is the seller. Merchant-wide routes now list the customer-id is not allowed in request header refusal.
  • Extra filters: paymentId, fromDate and toDate on List all customers’ disputes, paymentToken on Get escrow feeds, category_position on category create and edit.
  • New error messages: Amount mismatch, Escrow is not in a valid state for checkout, Insufficient balance, Escrow cannot be claimed yet, and the 404, 410, 429 and 503 codes.

Corrected

  • maxDelivery and deliveryTimeline are required on standard and milestone escrow creation and on edit, and maxDelivery is 1 to 365. They were shown as optional.
  • Amounts accept two decimal places. Milestone amounts and vault stakes were described as positive integers; they are naira with kobo, like every other amount.
  • Milestone escrows can be disputed over milestones the buyer has not yet confirmed. One page said they could not; the milestone and dispute pages now agree.
  • Pay escrow takes wallet or card; card needs a saved cardId and is Nigerian-only. The route does not return an authorizationUrl, and cNGN collection is only available through the Checkout SDK. The amount formula is spelled out per whoPays.
  • Create payment intent: a deposit without a saved card opens a hosted collection (checkoutConfig on production, a testAccount on staging) rather than failing.
  • Top up on staging is 1,000 to 100,000 and once per customer per day.
  • Get countries is GET /v1/countries; the walkthrough had an old path.
  • Create merchant customer no longer promises a dedicated virtual account for a bvn.
ToolingNew
An open-source Agent Skill teaches AI coding agents the Payluk API

Payluk API skill for AI coding agents

The Payluk API skill packages this documentation in the Agent Skills format, so Claude Code, Cursor, Codex, Copilot, Gemini CLI and other agents integrate Payluk correctly without guessing header names, fee maths or state transitions.It ships the ground rules every request must follow, ordered steps for the standard, multi-quantity, milestone and vault flows, disputes, webhook signature verification, every error message and enum, an endpoint catalog generated from openapi.json, the spec itself, and a dependency-free CLI for calling the API.
Source and issues: github.com/jerozeek/payluk-api-skill. New API releases ship a matching version of the skill.
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

  1. Create an escrow with POST /v1/escrow/create.
  2. Collect payment for it with the Payluk Checkout SDK, or server-side with POST /v1/payment/create-intent.
  3. Confirm it with POST /v1/payment/verify.
The Checkout SDK opens an escrow first and settles the payment against it, so the money is attached to a transaction from the moment it arrives.
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.
CryptoSandboxFixed
Test-mode crypto payments now run on BSC testnet instead of mainnet

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, networkId and explorerUrl fields on GET /v1/payment/get-master-wallet-address now describe the chain the address is actually on, so a sandbox address links to testnet.bscscan.com rather than to bscscan.com. The explorerUrl recorded on blockchain payments follows the same rule.
Sandbox crypto is refused until your Payluk deployment has a testnet contract and wallets configured; there is deliberately no fall back to the mainnet ones. If a test-mode crypto call returns a configuration error, that is why.

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.
  • fromAddress on 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/balance returns mainBalance and escrowBalance on your own merchant wallet. This is where Payluk settles your earnings.
  • GET /v1/merchant/transactions returns your own ledger: commission for your share of an escrow fee, delivery for a delivery fee you charged a buyer, and any withdrawals you made. It takes the same page, limit, type, reference, fromDate and toDate filters as the customer history.
  • Both require a merchant super-admin key, and both refuse a customer-id header. Sending one returns 400 rather 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.
Both figures also arrive as webhooks the moment they settle, on the 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.
  • limit sets 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.
  • limit applies to the paginated response only. Supplying reference, fromDate or toDate still returns a flat array, which is not paged.
See Merchant account and Pagination.
Merchant CustomersFixed
Phone numbers accept any spelling, and countryId is now optional

Phone numbers and countryId

Creating a customer rejected every phone number with Unable 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 phone as digits only, with no leading +. Local and international spellings of the same number are all accepted: 08012345678, 2348012345678 and 8012345678 are the same customer. Each is stored in the local form (08012345678), which is what the customer endpoints return.
  • countryId is now optional. Omit it and the customer inherits the country on your merchant account. It also accepts an ISO code, so NG works as well as an id from Get countries.
  • A country that cannot be resolved now says so, with Invalid country selected instead of blaming the phone number.
  • Phone lookups match any spelling. GET /v1/customers?phone= and GET /v1/payment/verify-phone/{phone} find the same customer whether you send 08012345678 or 2348012345678, 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.
See Merchant customers.
WebhooksPaymentsNew
Webhooks now cover every transaction your customers settle

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 new payment.* 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 example payment.deposit.success or payment.withdrawal.failed.
  • The two payouts Payluk makes into your own merchant wallet are on the same stream: payment.commission.success for your share of an escrow fee, and payment.delivery.success for an additionalFee you 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-signature HMAC-SHA512 header, the callback URL and the retry behaviour are unchanged and shared with escrow.* events. Switch on the event field and return 2xx for names you do not handle.
  • Funding an escrow now produces two webhooks: payment.escrow.success for the ledger entry and escrow.ongoing for the deal. Act on one stream, not both.
  • Existing escrow integrations need no changes.
See Webhooks.
PaymentsTestingNew
Pay a staging virtual account from the Payluk Test Bank

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 Bank in bank, carry the bank code 999999, and their account numbers begin with 99.
  • 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.
See Simulate bank transfers with the Payluk Test Bank.
PaymentsChanged
Dedicated accounts are issued only under Paystack

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 dedicated in the response rather than inferring the account type from whether you sent a BVN. Dedicated accounts have no expiresIn; temporary ones expire after 24 hours and must be re-read.
CustomersNew
Look customers up by email or phone

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-page pagination. Nothing needs to special-case the lookup.
  • email is case-insensitive. phone is matched exactly as it was stored when the customer was created, so send it in the format you onboarded them with — 08012345678 will 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 400 and No customer found with the provided email (or phone, or email and phone) rather than returning an empty list. Treat that as “no such customer”, not as an outage.
  • page and limit are 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.
DisputesNew
Split dispute resolution

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 holds amount less the seller’s share of the escrow fee, so a buyer-paid escrow holds the full amount and a whoPays: both one 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 SPLIT and carries a split object (sellerAmount, buyerAmount, pool, resolvedAt) on every later read. A new escrow.split webhook fires, with the same object in its payload. On a milestone escrow, each milestone that was still held is marked SPLIT too.
  • The escrow fee is retained in full, exactly as on a refund, and commission is charged once, exactly as on a completion. additionalFeeRefundable works 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. COMPLETED and REFUNDED are unchanged, sellerAmount and buyerAmount are rejected on those, and existing integrations are unaffected. Handlers that switch on escrow status should add a SPLIT case.
EscrowNew
Additional (delivery) fee

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 as amount on Pay escrow, or the request is rejected with Amount 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 delivery transaction type, kept separate from your commission. The escrow amount, the escrow fee and the seller’s payout are all unchanged by it.
  • Settable only while the escrow is PENDING, and only by you: super-admin key, no customer-id header. Adjust it as often as you need before the buyer pays, or send 0 to 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. true returns it to the buyer with the principal; false credits 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 (true by default) applies, so nothing changes for existing integrations.
  • Standard escrows only; milestone and vault escrows are rejected.
CustomersNew
Update customer

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.
  • phone and bvn stay 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.
EscrowNew
Vault escrow

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, alongside STANDARD and MILESTONE.
  • 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[] and winnerId fields (populated for vault escrows only).
See Vault escrows for the full concept.

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.
Escrow
Milestone endpoints on the merchant API

Milestone escrow endpoints for merchants

The milestone escrow surface is now available on the merchant (/v1) API:
Escrow
Sequential milestone release

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.
Payments
Dedicated virtual accounts

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.
EscrowNew
Milestone escrows introduced

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 a milestones[] array.
  • At least 2 milestones, positive integer amounts summing to the escrow amount, and whoPays: buyer only.
  • Each milestone may name an optional customerId beneficiary who receives that milestone’s funds instead of the seller.
  • The escrow completes automatically when the final milestone is released.
PaymentsDisputes
Payments & disputes improvements

Batch payment verification and dispute lookup

  • Verify payment accepts multiple references in 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 status filter.