P2P Transfers
P2P transfers move money between two wallets within PayKore's ledger. They settle instantly — no PSP or banking rails involved. Both wallets must belong to your partner account.
Fee: 0.5% of the transfer amount, capped at ₦200 (20000 kobo).
Making a transfer
curl -X POST https://api.paykore.dev/v1/transfers/p2p \
-H "X-API-Key: sk_test_YOUR_KEY_HERE" \
-H "Idempotency-Key: transfer_20260602_001" \
-H "Content-Type: application/json" \
-d '{
"source_wallet_id": "wlt_9f3kA2mXpQ",
"dest_wallet_id": "wlt_3pRsT7uVwX",
"amount_kobo": 500000,
"reference": "order_789",
"description": "Payment for order #789",
"metadata": {"orderId": "789"}
}'
Request fields:
| Field | Required | Description |
|---|---|---|
source_wallet_id | Yes | The wallet to debit. Must be active. |
dest_wallet_id | Yes | The wallet to credit. Must not be closed. |
amount_kobo | Yes | Amount in kobo. ₦5,000 = 500000 kobo. Minimum: 1 kobo. |
reference | Yes | Your own reference. Must be unique per partner. Use your order/invoice ID. |
description | No | Human-readable note shown in transaction history. |
metadata | No | Any JSON object. Returned on all transaction responses. |
Idempotency-Key (header) | Strongly recommended | See Idempotency below. |
amount_kobo is always in kobo. ₦1 = 100 kobo. Sending amount_kobo: 5000 transfers ₦50, not ₦5,000. Double-check your amounts before going live.
Understanding the response
{
"id": "tx_4qYzA1bCdE",
"type": "p2p_transfer",
"status": "completed",
"reference": "order_789",
"description": "Payment for order #789",
"amount_kobo": 500000,
"amount_naira": "5000.00",
"fee_breakdown": {
"customer_fee_kobo": 2500,
"customer_fee_naira": "25.00",
"platform_fee_kobo": 2500,
"mfb_cost_kobo": 0,
"net_amount_kobo": 500000
},
"source_wallet": {
"id": "wlt_9f3kA2mXpQ",
"balance_after_kobo": 497500,
"balance_after_naira": "4975.00"
},
"dest_wallet": {
"id": "wlt_3pRsT7uVwX",
"balance_after_kobo": 500000,
"balance_after_naira": "5000.00"
},
"created_at": "2025-06-01T10:03:00Z",
"meta": {
"request_id": "req_2kMnO5pQrS"
}
}
Key points:
status: "completed"— P2P is synchronous. A200response means the transfer happened. There is noprocessingstate for P2P.fee_breakdown.customer_fee_kobo— the fee charged to the source wallet on top of the transfer amount. The sender pays 502500 kobo (₦5,025); the recipient receives exactly 500000 kobo (₦5,000).source_wallet.balance_after_kobo— the source wallet's balance after the debit. Use this to update your UI without a separateGET /walletscall.
Idempotency
This is the most important concept for building a reliable transfer flow.
The problem: your server sends a transfer request. The network times out before you get a response. Did the transfer happen? You don't know. If you retry without precautions, you risk charging your user twice.
The solution: include an Idempotency-Key header with every transfer request. PayKore uses this key to deduplicate requests. If you send the same key twice within 24 hours, the second request returns the original response — no new transfer is created.
# First attempt — times out
curl -X POST https://api.paykore.dev/v1/transfers/p2p \
-H "Idempotency-Key: order_789_attempt_1" \
...
# Retry with the SAME key — safe
curl -X POST https://api.paykore.dev/v1/transfers/p2p \
-H "Idempotency-Key: order_789_attempt_1" \
...
# Returns the original response if the transfer was processed
# Processes it now if it wasn't
Good idempotency keys are tied to the specific action being attempted:
// Good — UUID generated once per user action
const key = crypto.randomUUID();
// "f47ac10b-58cc-4372-a567-0e02b2c3d479"
// Good — order ID + attempt context
const key = `order_${orderId}_transfer`;
// "order_789_transfer"
Bad idempotency keys defeat the purpose:
// Bad — timestamp changes on every retry
const key = `transfer_${Date.now()}`;
// Bad — static string means all transfers share a key
const key = 'my-transfer-key';
// Bad — counter that resets across deploys
const key = `transfer_${++requestCount}`;
Idempotency keys expire after 24 hours. After expiry, the same key can create a new transfer. If you're retrying a transfer from more than 24 hours ago, generate a new key and verify the original transfer's status first via GET /v1/wallets/{id}/transactions?reference={yourRef}.
Error handling
Catch errors by error.code, not by HTTP status alone. Multiple distinct errors share the same HTTP status.
| Error code | HTTP | Cause | Recommended action |
|---|---|---|---|
INSUFFICIENT_FUNDS | 422 | Source wallet balance too low | Check balance before initiating; show user their balance |
WALLET_NOT_FOUND | 404 | source_wallet_id or dest_wallet_id doesn't exist | Verify wallet IDs in your database |
WALLET_FROZEN | 422 | Source wallet is frozen | Check wallet status; contact support if unexpected |
WALLET_CLOSED | 422 | Source or dest wallet is closed | Create a new wallet for the user |
SAME_WALLET_TRANSFER | 400 | Source and dest wallet IDs are identical | Validate wallet IDs before submitting |
AMOUNT_TOO_LOW | 400 | amount_kobo is less than 1 | Enforce a minimum in your UI |
DUPLICATE_REFERENCE | 409 | reference already used for a different transfer | Use a unique reference per transfer |
Full error response for INSUFFICIENT_FUNDS:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "The source wallet does not have sufficient funds for this transfer including fees.",
"http_status": 422,
"detail": {
"available_kobo": 200000,
"required_kobo": 502500
}
},
"meta": {
"request_id": "req_6nOpQ7rStU"
}
}
TypeScript error handling pattern:
try {
const transfer = await paykore.transfers.p2p({ ... });
} catch (err) {
switch (err.code) {
case 'INSUFFICIENT_FUNDS':
return res.status(400).json({
message: `Insufficient balance. Available: ₦${(err.detail.available_kobo / 100).toFixed(2)}`,
});
case 'WALLET_FROZEN':
return res.status(400).json({ message: 'Your wallet is currently frozen. Contact support.' });
default:
logger.error('Unexpected PayKore error', { code: err.code, requestId: err.meta?.request_id });
return res.status(500).json({ message: 'Transfer failed. Please try again.' });
}
}
Webhooks for transfers
After a successful P2P transfer, PayKore fires a transaction.completed event to your registered webhook URL.
{
"event": "transaction.completed",
"data": {
"id": "tx_4qYzA1bCdE",
"type": "p2p_transfer",
"status": "completed",
"reference": "order_789",
"amount_kobo": 500000,
"source_wallet_id": "wlt_9f3kA2mXpQ",
"dest_wallet_id": "wlt_3pRsT7uVwX",
"created_at": "2025-06-01T10:03:00Z"
},
"timestamp": "2025-06-01T10:03:01Z",
"webhook_id": "wh_5rTuV2wXyZ"
}
Don't rely solely on the HTTP response to update your order state. Use the webhook as the authoritative signal. If your server crashes between receiving the 200 and writing to your database, the webhook gives you a second chance to record the transfer. See the Webhooks guide for how to verify signatures and handle retries.
SDK examples
TypeScript:
import { PayKore } from '@paykore/sdk';
import { randomUUID } from 'crypto';
const paykore = new PayKore({ apiKey: process.env.PAYKORE_API_KEY });
const transfer = await paykore.transfers.p2p({
sourceWalletId: 'wlt_9f3kA2mXpQ',
destWalletId: 'wlt_3pRsT7uVwX',
amountKobo: 500000,
reference: 'order_789',
description: 'Payment for order #789',
idempotencyKey: randomUUID(),
});
console.log(transfer.status); // "completed"
console.log(transfer.feeBreakdown.customerFeeKobo); // 2500
React Native:
import { PayKoreClient } from '@paykore/react-native-sdk';
const client = new PayKoreClient({ apiKey: PAYKORE_API_KEY });
const transfer = await client.transfers.p2p({
sourceWalletId: sourceWallet.id,
destWalletId: destWallet.id,
amountKobo: amountInKobo,
reference: `order_${orderId}`,
idempotencyKey: uuid.v4(),
});
Next steps
- Bank Transfers → — Send funds to external Nigerian bank accounts.
- Split Payments → — Route fees to multiple wallets in a single atomic transfer.
- Webhooks → — Handle
transaction.completedandtransaction.failedevents.