Endpoints and code in PHP, Python, Node.js and Java.
Send your secret key in the Authorization header of every request: Bearer sk_live_…. Test keys (sk_test_) never touch real money. Public keys (pk_) can only read.
Live requests must also: carry your business ID in the SeamlesPay-Business-Id header; come from the server addresses (IP) you registered on the API keys page; and send webhooks and customer return links only to your confirmed website. Anything else is refused with a 403 that says what to fix.
Set your key once
<?php
// Keep the key in an environment variable, never in your code.
$secretKey = getenv('SEAMLESPAY_SECRET_KEY'); // sk_live_… or sk_test_…
$businessId = getenv('SEAMLESPAY_BUSINESS_ID'); // SP_…
$headers = [
'Authorization: Bearer ' . $secretKey,
'SeamlesPay-Business-Id: ' . $businessId,
'Content-Type: application/json',
];
// Send $headers with every request (CURLOPT_HTTPHEADER).Test it: read your balance
<?php
$ch = curl_init('https://api.seamlespay.com/v1/balance');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);401: missing or wrong key
{ "type": "authentication_failed", "message": "Invalid API key." }403: live key used outside your business
{ "type": "ip_not_allowed", "message": "This live key can only be used from your registered server addresses. ..." }
// also: business_id_required, business_id_mismatch, server_ips_required, website_not_confirmed, url_not_on_websiteBase URL https://api.seamlespay.com. Amounts are kobo strings ("500000" = ₦5,000). Add an Idempotency-Key header to every POST that moves money.
GET/v1/balance
Your available and pending balance
POST/v1/virtual-accounts
Create a permanent account for a customer
GET/v1/virtual-accounts
List virtual accounts
GET/v1/virtual-accounts/{id}
One virtual account
POST/v1/virtual-accounts/{id}/deactivate
Close a virtual account
POST/v1/checkout-sessions
Create a hosted checkout page
GET/v1/checkout-sessions/{id}
One checkout session
POST/v1/payments
Start a payment: bank_transfer, card, ussd or opay
GET/v1/payments
List payments
GET/v1/payments/{id}
One payment
GET/v1/payments/verify/{reference}
Confirm a payment by your reference
POST/v1/payouts
Send money to a bank account
GET/v1/payouts
List payouts
GET/v1/payouts/{id}
One payout
GET/v1/payouts/fees
Payout fee bands
GET/v1/banks
Bank names and codes
POST/v1/pricing/quote
The fee on an amount
POST/v1/webhook-endpoints
Add a webhook URL
GET/v1/webhook-endpoints
List webhook URLs
A permanent account number for each customer, for wallets and top-ups. Accepts any amount, any time. Money is available instantly.
POST/v1/virtual-accounts
<?php
$ch = curl_init('https://api.seamlespay.com/v1/virtual-accounts');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: cust_42',
],
CURLOPT_POSTFIELDS => json_encode([
'type' => 'static',
'reference' => 'cust_42',
'customer' => [
'name' => 'Chidi Okeke',
'email' => 'chidi@example.com',
'phone' => '08012345678',
],
'bank' => 'wema-bank',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);Response
{
"id": "va_01M4…",
"reference": "cust_42",
"account_number": "9626283575",
"account_name": "SEAMLESPAY/OKEKE CHIDI",
"bank_name": "Wema Bank",
"bank_code": "wema-bank",
"status": "active"
}bank chooses the partner bank: titan-paystack (Paystack-Titan, the default) or wema-bank (Wema Bank). A customer can have one account at each, so create both if you want to offer two numbers.
reference is your ID for the customer; webhooks quote it back. Each deposit sends virtual_account.credited.
Send the customer to a SeamlesPay payment page where they choose card, transfer, USSD or OPay. The least code: no payment screen to build.
POST/v1/checkout-sessions
<?php
$ch = curl_init('https://api.seamlespay.com/v1/checkout-sessions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: order_1042',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '750000',
'reference' => 'order_1042',
'customer' => [
'name' => 'Chidi Okeke',
'email' => 'chidi@example.com',
],
'callback_url' => 'https://yourshop.ng/paid',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);Response: redirect the customer to url
{ "id": "cs_01M4…", "url": "https://app.seamlespay.com/pay/…", "status": "open" }Limit choices with "methods": ["card", "opay"]. Values: bank_transfer card ussd opay.
A one-time account number for one order, shown in your own app (no redirect). Expires in 30 minutes. Money is available instantly.
POST/v1/payments
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payments');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: order_1001',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '500000',
'reference' => 'order_1001',
'method' => 'bank_transfer',
'customer' => [
'name' => 'Chidi Okeke',
'email' => 'chidi@example.com',
],
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);payment_instructions
{ "type": "bank_transfer", "account_number": "9620925727", "bank_name": "Paystack-Titan", "account_name": "SEAMLESPAY/…" }Verve, Mastercard and Visa. Redirect the customer to the secure card page.
POST/v1/payments
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payments');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: order_1002',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '500000',
'reference' => 'order_1002',
'method' => 'card',
'customer' => [
'name' => 'Chidi Okeke',
'email' => 'chidi@example.com',
],
'callback_url' => 'https://yourshop.ng/paid',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);payment_instructions
{ "type": "redirect", "url": "https://checkout.paystack.com/…" }For customers without internet. Redirect them to the page that gives the code to dial.
POST/v1/payments
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payments');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: order_1003',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '500000',
'reference' => 'order_1003',
'method' => 'ussd',
'customer' => [
'name' => 'Chidi Okeke',
'email' => 'chidi@example.com',
],
'callback_url' => 'https://yourshop.ng/paid',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);The customer approves in their OPay app. Redirect them to the URL returned.
POST/v1/payments
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payments');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: order_1004',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '500000',
'reference' => 'order_1004',
'method' => 'opay',
'customer' => [
'name' => 'Chidi Okeke',
'email' => 'chidi@example.com',
],
'callback_url' => 'https://yourshop.ng/paid',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);Before you deliver, confirm on your server. Never trust a redirect alone.
GET/v1/payments/verify/{reference}
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payments/verify/order_1001');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);Deliver when status is succeeded and amount matches.
Add your URL on Webhooks. Check the signature, reply 200, and skip event IDs you have already handled.
<?php
// webhook.php — reply 200 fast, do slow work after.
$raw = file_get_contents('php://input'); // the raw body, not $_POST
$header = $_SERVER['HTTP_X_SEAMLESPAY_SIGNATURE'] ?? '';
$secret = getenv('SEAMLESPAY_WEBHOOK_SECRET'); // whsec_… from Webhooks
parse_str(str_replace(',', '&', $header), $sig); // ['t' => …, 'v1' => …]
$t = $sig['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!ctype_digit($t) || abs(time() - (int) $t) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true);
// Deliveries can repeat: store $event['id'] with a UNIQUE index and skip ones you have seen.
switch ($event['type']) {
case 'payment.succeeded': // checkout, card, USSD, OPay, transfer
markOrderPaid($event['data']['reference'], $event['data']['amount']);
break;
case 'virtual_account.credited': // money into a customer's own account
creditWallet($event['data']['reference'], $event['data']['amount']);
break;
}
http_response_code(200);
echo 'OK';payment.succeeded
{
"id": "evt_01M4…",
"type": "payment.succeeded",
"livemode": true,
"data": { "reference": "order_1001", "amount": "500000", "currency": "NGN", "method": "card" }
}Have your own app with user accounts? Let the dashboard search your users when you create a virtual account, so it is linked to the right user. Set your address on Webhooks → Customer lookup. It must be on the same site as a webhook endpoint: that endpoint's signing secret signs every search.
What SeamlesPay sends
GET https://yourapp.com/seamlespay/lookup?q=chidi&environment=live
X-SeamlesPay-Signature: t=1760520600,v1=<hex HMAC-SHA256 of "<t>.lookup:<q>" with your whsec_ secret>What to answer (at most 20, within 6 seconds)
{
"data": [
{
"reference": "user_15",
"name": "Chidi Okeke",
"email": "chidi@example.com",
"phone": "08012345678",
"label": "#15",
"account_name": "MYAPP CHIDI",
"references": { "wema-bank": "user_15_wema" }
}
]
}Answer the lookup
<?php
// lookup.php — SeamlesPay searches your customers here. Answer only signed requests.
$q = $_GET['q'] ?? '';
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_SEAMLESPAY_SIGNATURE'] ?? ''), $sig);
$t = $sig['t'] ?? '';
$secret = getenv('SEAMLESPAY_WEBHOOK_SECRET'); // the whsec_ of your webhook endpoint
$expected = hash_hmac('sha256', $t . '.lookup:' . $q, $secret);
if (!ctype_digit($t) || abs(time() - (int) $t) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(401); exit;
}
$stmt = $pdo->prepare('SELECT id, name, email, phone FROM users WHERE name LIKE ? OR email LIKE ? LIMIT 20');
$stmt->execute(["%$q%", "%$q%"]);
$data = [];
foreach ($stmt as $u) {
$data[] = [
'reference' => 'user_' . $u['id'], // comes back in every webhook for this account
'name' => $u['name'], 'email' => $u['email'], 'phone' => $u['phone'],
'label' => '#' . $u['id'],
];
}
header('Content-Type: application/json');
echo json_encode(['data' => $data]);reference becomes the account's reference: letters, digits and _ . : -, up to 100. Optional references gives a different one per bank, if you keep one account per bank per user. Optional account_name opens the account in that name, so it reads the same as accounts your app creates itself. Then link the account to the user when the virtual_account.created webhook arrives (its reference is yours), and credit them on virtual_account.credited.
Withdraw your available balance. For your safety, live payouts go only to the payout account approved in your application (change it under Settings → Payout account). Test mode accepts any account.
1. Send the payout
POST/v1/payouts
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payouts');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
'Content-Type: application/json',
'Idempotency-Key: withdrawal_77',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '5000000',
'reference' => 'withdrawal_77',
'narration' => 'Withdrawal',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);Request fields
amount required kobo as a string: "5000000" = ₦50,000 (minimum "5000" = ₦50)
reference required your unique ID for this payout (max 64)
narration optional shows on your bank statement (max 100)
metadata optional your own data, returned to you
bank_code, account_number, account_name
optional leave out to pay your approved payout account.
Live: if sent, account_number must be that account,
otherwise 403 destination_not_allowed.
Test: any account (codes from GET /v1/banks).Response
{
"id": "pout_01M4YD4E9T…",
"reference": "withdrawal_77",
"status": "processing",
"amount": "5000000",
"fee": "8500",
"total": "5008500",
"currency": "NGN",
"destination": {
"bank_code": "999992",
"account_number": "8060520009",
"account_name": "Your Business Ltd",
"resolved_account_name": "YOUR BUSINESS LTD",
"name_match": "exact"
},
"failure_reason": null,
"created_at": "2026-10-15T09:40:00.000Z"
}total = amount + fee, taken from your balance. status: processing → succeeded or failed (a failed payout and its fee are returned to your balance). pending_approval means it is waiting for review.
2. Check the result
GET/v1/payouts/{id}
<?php
$ch = curl_init('https://api.seamlespay.com/v1/payouts/pout_01M4YD4E9T');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SEAMLESPAY_SECRET_KEY'),
'SeamlesPay-Business-Id: ' . getenv('SEAMLESPAY_BUSINESS_ID'),
],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);Or wait for the webhook:
payout.succeeded
{
"id": "evt_01M4YD4F2R…",
"type": "payout.succeeded",
"created_at": "2026-10-15T09:40:30.000Z",
"livemode": true,
"data": {
"payoutId": "pout_01M4YD4E9T…",
"reference": "withdrawal_77",
"status": "succeeded",
"amount": "5000000",
"currency": "NGN",
"failureReason": null
}
}payout.failed
{
"id": "evt_01M4YD5H6W…",
"type": "payout.failed",
"created_at": "2026-10-15T09:41:00.000Z",
"livemode": true,
"data": {
"payoutId": "pout_01M4YD5G3X…",
"reference": "withdrawal_78",
"status": "failed",
"amount": "5000000",
"currency": "NGN",
"failureReason": "The bank name did not match"
}
}Virtual accounts 1.8% (max ₦900). Checkout, card, USSD, OPay and transfer 2.3% + ₦100 (max ₦3,000; ₦100 waived under ₦2,500). Payouts ₦20–₦50 (never more than ₦50).
POST/v1/pricing/quote
Act on the type, not the message.
Example
HTTP 422 { "type": "insufficient_balance", "message": "…" }validation_error 400 · authentication_failed 401 · forbidden 403 · not_found 404 · idempotency_key_reuse 409 · rate_limited 429