SeamlesPay

Documentation

Endpoints and code in PHP, Python, Node.js and Java.

Authentication

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_website

Endpoints

Base 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

Virtual accounts

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.

Hosted checkout

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.

Bank transfer

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/…" }

Card

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/…" }

USSD

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);

Pay with OPay

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);

Verify a payment

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.

Webhooks

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" }
}

Customer lookup (optional)

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.

Payouts

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"
  }
}

Fees

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

Errors

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