Transfers
Programmable transfers let you send money from a ZevPay wallet to bank accounts or other ZevPay users via API. This is useful for payouts, vendor payments, refunds, and any scenario where you need to move funds programmatically.
How It Works
Unlike checkout (which collects money in), transfers move money out of your wallet. Because of this, transfers have additional security layers:
- Wallet-level consent — You must explicitly enable "Programmable Debit" on a wallet in your business dashboard
- API key permissions — The API key must have the
transferspermission - Fixed source — Each API key is linked to exactly one wallet. The transfer always debits from that wallet — you cannot override the source per request
- Secret key only — All transfer endpoints require a secret key (
sk_*)
Setup
Step 1: Enable Programmable Debit on a Wallet
In the ZevPay Business Dashboard:
- Navigate to Wallets and select the wallet you want to use for transfers
- Open Settings on the wallet detail page
- Under the Developer API section, toggle Programmable Debit to enabled
- Read and confirm the consent dialog
WARNING
Enabling programmable debit allows API keys to debit this wallet without PIN confirmation. Only enable this on wallets designated for API payouts. Disabling it later will immediately unlink all connected API keys.
Step 2: Add an API Key as a Wallet Member
Once programmable debit is enabled, you can add API keys to the wallet from the Members tab:
- Go to the wallet detail page and open the Members tab
- Click the Add dropdown and select Add API Key
- Search for and select the API key you want to link
- Confirm — the API key is added as a member by default
INFO
A wallet can have multiple API key members. Each API key can only be linked to one transfer wallet at a time. The transfers permission is automatically granted when linked.
Step 3: Configure the API Key's Role and Limits
After adding an API key, tap on it in the members list to configure its settings:
Role
| Role | Capabilities |
|---|---|
| Admin | Initiate transfers and manage the wallet via REST API (add/remove members, update settings). Only the wallet owner can promote an API key to admin. |
| Member | Initiate transfers only. Cannot perform wallet management operations. |
Spending Limits
Set per-key spending limits to control how much an API key can transfer:
| Limit | Description |
|---|---|
| Single transaction limit | Maximum amount per individual transfer |
| Daily limit | Maximum total amount transferred in a rolling 24-hour window |
| Monthly limit | Maximum total amount transferred in a rolling 30-day window |
Amounts are in naira (not kobo). For example, setting a daily limit of 50000 means the API key cannot transfer more than ₦50,000 per day.
TIP
Use spending limits to minimize risk. For example, a payout key that only needs to send small amounts should have a low single transaction limit. A batch payout key might have a higher daily limit but still benefit from a monthly cap.
Balance Visibility
You can hide the wallet balance from an API key. When enabled, the API key cannot view the wallet's available balance via the Get Wallet endpoint. Admin API keys always have balance visibility — this setting only applies to member-role keys.
Step 4: Start Making Transfers
Your API key is now ready to initiate transfers. Use the Transfer API endpoints.
If the API key has an admin role, it can also manage the wallet programmatically via the Wallet Management API.
Bank Transfer Flow
The recommended flow for sending a bank transfer:
1. Get bank list
Fetch the list of supported banks to populate your UI or validate bank codes.
const { data } = await fetch('/v1/checkout/transfer/banks', {
headers: { 'Authorization': `Bearer ${secretKey}` },
}).then(r => r.json());
// data.banks = [{ bankCode: "044", bankName: "Access Bank" }, ...]2. Resolve bank account
Verify the account number and get the account holder's name. Always show this to the user for confirmation before sending.
const { data } = await fetch('/v1/checkout/transfer/banks/resolve', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
account_number: '0123456789',
bank_code: '058',
}),
}).then(r => r.json());
// data.account_name = "JOHN DOE"3. Check charges (optional)
Calculate fees before initiating so you know the total debit amount.
const { data } = await fetch('/v1/checkout/transfer/charges', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 500000, // ₦5,000 in kobo
bank_code: '058',
}),
}).then(r => r.json());
// data = { amount: 500000, fee: 2625, vat: 197, stamp_duty: 0, total: 502822 }4. Initiate transfer
const { data } = await fetch('/v1/checkout/transfer', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'bank_transfer',
account_number: '0123456789',
bank_code: '058',
account_name: 'JOHN DOE',
amount: 500000,
narration: 'Vendor payment - March',
reference: 'payout_vendor_001',
}),
}).then(r => r.json());
// data.status = "completed" or "pending"
// data.reference = "ZVP-TRF-..."5. Handle webhook
Listen for transfer.success or transfer.failed webhooks to confirm the outcome. See Webhook Events.
PayID Transfer Flow
PayID transfers are simpler — no bank resolution or fee calculation needed. PayID transfers are instant and free.
const { data } = await fetch('/v1/checkout/transfer', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'payid',
pay_id: 'john',
amount: 250000, // ₦2,500 in kobo
narration: 'Freelance payment',
}),
}).then(r => r.json());
// PayID transfers complete instantly
// data.status = "completed"Handling Webhooks
Set up a webhook endpoint to receive transfer status updates:
app.post('/webhooks/zevpay', (req, res) => {
const { event, data } = req.body;
switch (event) {
case 'transfer.success':
console.log(`Transfer ${data.reference} completed`);
// Update your records
markPayoutComplete(data.reference, data.amount);
break;
case 'transfer.failed':
console.log(`Transfer ${data.reference} failed`);
// Funds are returned to your wallet automatically
markPayoutFailed(data.reference);
// Consider retrying or notifying your team
break;
}
res.status(200).send('OK');
});INFO
When a bank transfer fails, the debited amount is automatically reversed back to your wallet. You do not need to handle the reversal manually.
Checking Balance
Monitor your wallet balance to ensure you have sufficient funds:
const { data } = await fetch('/v1/checkout/transfer/balance', {
headers: { 'Authorization': `Bearer ${secretKey}` },
}).then(r => r.json());
const balanceNaira = data.available_balance / 100;
console.log(`Available: ₦${balanceNaira.toLocaleString()}`);Revoking Access
Disable programmable debit
Disabling programmable debit on a wallet (from Settings → Developer API) automatically:
- Removes all API key members from that wallet
- Revokes the
transferspermission from those keys - Takes effect immediately
Remove a single API key
To revoke access for one API key without affecting others:
- Go to the wallet's Members tab
- Find the API key in the list and open its settings
- Remove it from the wallet
Other API keys linked to the same wallet continue to work normally.
Via the API
Admin API keys can also remove members programmatically using DELETE /v1/checkout/wallet/members/:payId. See Wallet Management API.
Best Practices
Use idempotency references
Always provide a reference when initiating transfers. If your system retries a request (due to network issues), the API will return the existing transfer instead of creating a duplicate.
// Good — idempotent
{ reference: "payout_vendor_001_march", amount: 500000, ... }
// Risky — no reference means retries could create duplicates
{ amount: 500000, ... }Store references
Save both your reference and the ZevPay reference returned in the response. You'll need them for:
- Looking up transfers via the API
- Matching webhook events to your records
- Debugging with ZevPay support
Verify before sending
Always call the Resolve Bank Account endpoint and show the account name to your user before initiating a bank transfer. This prevents sending money to the wrong account.
Monitor balance
Check your wallet balance before initiating transfers to avoid insufficient balance errors. Consider setting up alerts when your balance drops below a threshold.
Secure your keys
- Never expose secret keys in client-side code
- Configure IP whitelisting in your API key settings
- Use separate API keys for transfers and checkout
- Rotate keys periodically
Wallet Management via API
API keys linked to a wallet with an admin role can manage the wallet programmatically — adding or removing members, updating member settings, and changing wallet settings.
Permission Hierarchy
| Role | Can manage |
|---|---|
| Owner | Everyone (admins + members) |
| Admin | Members only (not other admins or the owner) |
| Member | No management access |
API keys are linked as either admin or member. Only admin API keys can use the wallet management endpoints.
Available Operations
| Operation | Description |
|---|---|
| Get wallet details | View wallet info, balance, members, and settings |
| Update wallet settings | Change name, description, notifications, and privacy |
| List members | View all people and API key members |
| Add a member | Add a ZevPay user by their PayID |
| Remove a member | Remove a member (members only, not admins or owner) |
| Update member settings | Change spending limits, notifications, and balance visibility |
Example: Add a Member
const { data } = await fetch('/v1/checkout/wallet/members', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
pay_id: '@jane.personal',
}),
}).then(r => r.json());Example: Set Spending Limits on a Member
await fetch('/v1/checkout/wallet/members/@jane.personal', {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
has_daily_limit: true,
daily_limit: 50000, // ₦50,000
has_single_limit: true,
single_limit: 10000, // ₦10,000
}),
});For full endpoint details, request/response schemas, and error codes, see the Wallet Management API Reference.