Skip to main content

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:

FieldRequiredDescription
source_wallet_idYesThe wallet to debit. Must be active.
dest_wallet_idYesThe wallet to credit. Must not be closed.
amount_koboYesAmount in kobo. ₦5,000 = 500000 kobo. Minimum: 1 kobo.
referenceYesYour own reference. Must be unique per partner. Use your order/invoice ID.
descriptionNoHuman-readable note shown in transaction history.
metadataNoAny JSON object. Returned on all transaction responses.
Idempotency-Key (header)Strongly recommendedSee Idempotency below.
warning

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. A 200 response means the transfer happened. There is no processing state 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 separate GET /wallets call.

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}`;
note

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 codeHTTPCauseRecommended action
INSUFFICIENT_FUNDS422Source wallet balance too lowCheck balance before initiating; show user their balance
WALLET_NOT_FOUND404source_wallet_id or dest_wallet_id doesn't existVerify wallet IDs in your database
WALLET_FROZEN422Source wallet is frozenCheck wallet status; contact support if unexpected
WALLET_CLOSED422Source or dest wallet is closedCreate a new wallet for the user
SAME_WALLET_TRANSFER400Source and dest wallet IDs are identicalValidate wallet IDs before submitting
AMOUNT_TOO_LOW400amount_kobo is less than 1Enforce a minimum in your UI
DUPLICATE_REFERENCE409reference already used for a different transferUse 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"
}
tip

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