Developersv1
Open dashboard

PayOak Developers

Let customers pay you with confidence, right on your own site. PayOak holds their payment securely until delivery is confirmed, then releases it to you.

Checkout with PayOak is a server-to-server API plus a PayOak-hosted checkout page. Your backend creates a checkout session and gets a checkoutUrl. You show that URL to your customer in an iframe or a redirect. PayOak then sends a signed webhook to your backend at each step of the escrow lifecycle.

Create session→Customer transfers to a virtual account→payment.confirmed→You deliver→delivery.confirmed→Funds released

How it works

  1. Your backend calls POST /v1/checkout/sessions with your secret key. The amount and the secret never reach the browser.
  2. You render the returned checkoutUrl in an iframe, or redirect to it.
  3. The customer pays by bank transfer into a PayOak-generated virtual account. PayOak holds the funds in escrow. In test mode no money moves: they select I have transferred the money and PayOak simulates the transfer.
  4. PayOak POSTs payment.confirmed to your webhook URL. Fulfil the order only on this signal.
  5. When delivery is confirmed (by either party, or automatically when the delivery window ends), PayOak POSTs delivery.confirmed and releases the funds to you.

Conventions

ItemDetail
Base URLhttps://api.payoak.net
FormatJSON request and response bodies; Content-Type: application/json
CurrencyOnly NGN is supported. amount is in Naira and may include decimals (for example 25000.50). Anything beyond 2 decimal places is rounded to the nearest kobo.
RequirementsA verified Business account at Tier 2 or above. Check your tier at the top of your Account page.

Quickstart

Set up Checkout with PayOak in your dashboard, then take a test payment from start to finish. No real money moves.

Set up in your dashboard

  1. Open Developer & API.

    In the PayOak app, go to Account and select Developer & API in the Business section. You need a Business account at Tier 2 or above. Your current tier is shown at the top of the same page, with options to view all tiers and upgrade.

    Account settings page with the Developer and API card highlighted
    Developer & API is in the Business section of your Account page.
  2. Generate your credentials.

    The first time you open Developer & API, you'll see three setup steps. Select Generate credentials to get your pk_… public key and sk_… secret key.

    The secret key is shown once. Copy it straight into a server-side environment variable, such as PAYOAK_SECRET_KEY, before you leave the page.

    Developer and API setup with three steps: API credentials, callback URL and webhook URL
    Three settings to complete: API credentials, callback URL and webhook URL.
  3. Add your webhook URL.

    Enter an HTTPS endpoint on your backend and select Save webhook URL. PayOak sends payment updates to this URL. Saving it generates your webhookSecret, which you use to verify each webhook.

  4. Add your callback URL.

    Enter the page on your site where customers should land after paying, then select Save callback URL. See Checkout URL for how the redirect works.

  5. Turn on test mode.

    Once your settings are saved, Developer & API shows a Test mode switch. Turn it on to take payments without creating real transactions. Your test payments appear under Open test transactions.

    Configured Developer and API page with the test mode switch and API credentials
    After setup: the test mode switch, your credentials, and the option to rotate them.

Make your first test payment

  1. Create a checkout session from your backend:
    curl -X POST https://api.payoak.net/v1/checkout/sessions \
      -H "Authorization: Bearer $PAYOAK_SECRET_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: order-123-85000" \
      -d '{
        "amount": 85000,
        "customerRef": "adaeze@example.com",  //email or phone number
        "customerName": "Adaeze Okonkwo",
        "merchantOrderRef": "ORDER-123"
      }'
  2. Show the checkout.

    Put the checkoutUrl in an iframe or redirect the customer to it. See Checkout URL.

  3. Select “I have transferred the money”.

    In test mode, the checkout shows a Test mode banner and labels the bank details as test details. Don’t send any money. Select I have transferred the money and PayOak simulates the payment, moving the transaction to paid just as a real transfer would.

    PayOak checkout in test mode with the I have transferred the money button highlighted
    In test mode, selecting I have transferred the money simulates the payment. No transfer is needed.

    The checkout then shows Test payment received, and returns the customer to your callback URL just as a live payment would.

    Test payment received screen with a Test mode banner
    The test success screen. The banner confirms no real money moved.
  4. Receive the webhook.

    Your endpoint gets payment.confirmed with "livemode": false. Verify the signature, then respond 200.

  5. Go live.

    Turn off Test mode in Developer & API, or call PATCH /v1/merchant/keys/mode with {"isLive": true}. Every session you create follows your account's current mode, so check it before you send real customers to checkout.

Authentication

One key pair covers test and live. Your account's mode decides whether your calls are test or live.

Your credentials

CredentialFormatUse
publicKeypk_…Identifies your account. Appears in the checkoutUrl and is required to read session status. Safe in client-side code.
secretKeysk_…Authenticates your backend's calls. Shown once at creation. Never expose it in a browser, mobile app or repository.
webhookSecret64 hex charsVerifies that a webhook came from PayOak. Generated when you register a webhook URL.

Authenticating requests

Server-side calls (creating a session) send the secret key as a Bearer token:

Header
Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A missing or invalid secret returns 401. A deactivated (revoked) key also returns 401.

Rotate immediately if a secret leaks. Rotation invalidates the old secret at once, so deploy the new one right away.

Test mode and live mode

  • New keys start in test mode.
  • In test mode no real transfer is created. The hosted checkout shows a Test mode banner, selecting I have transferred the money simulates the payment, and webhooks carry "livemode": false.
  • Switch with the Test mode toggle in Account → Developer & API.

Managing keys

Everything below is available self-serve in Account → Developer & API: rotate your credentials, set the webhook and callback URLs, and turn test mode on or off. Creating a key needs a Business account at Tier 2 or above.

Create a checkout

POST/v1/checkout/sessions

Creates an escrow transaction and returns a hosted checkout URL for your customer. Call it from your backend only.

Headers

HeaderValue
AuthorizationBearer sk_… required
Content-Typeapplication/json
Idempotency-KeyA unique string for this checkout, 1 to 64 characters. required See Idempotent requests.

Body parameters

FieldTypeDescription
amountrequirednumberAmount in Naira, for example 25000 or 25000.50. Anything beyond 2 decimal places is rounded to the nearest kobo (19.990000000000002 becomes 19.99). Must be from ₦5,000 to ₦10,000,000 after rounding.
customerRefrequiredstringCustomer's email or E.164 phone (e.g. +2348012345678). Lower-cased by PayOak. Must differ from your own account email.
customerNamerequiredstringCustomer's full name.
currencyoptionalstringDefaults to NGN, the only supported value.
descriptionoptionalstringShown to the customer. Defaults to “Payment to <your business name>”.
merchantOrderRefoptionalstringYour own order ID. Stored as merchantRef and echoed in webhooks and the status endpoint.

Amounts are exact to the kobo. PayOak rounds amount before it validates, compares or charges it, so 25000.5, 25000.50 and "25000.5" all mean the same payment. Amounts in webhooks and the status endpoint come back in Naira.

Request

curl -X POST https://api.payoak.net/v1/checkout/sessions \
  -H "Authorization: Bearer $PAYOAK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-123-85000" \
  -d '{
    "amount": 85000,
    "currency": "NGN",
    "customerRef": "adaeze@example.com", //email or phone number
    "customerName": "Adaeze Okonkwo",
    "description": "Order #123",
    "merchantOrderRef": "ORDER-123"
  }'

Response 201 Created

JSON
{
  "checkoutSessionId": 48213,
  "checkoutUrl": "https://app.payoak.net/checkout/CHK-20261001-483920?key=pk_9f2c…"
}
FieldDescription
checkoutSessionIdThe transaction ID. This is the transactionId you receive in webhooks. Store it against your order.
checkoutUrlThe hosted checkout page for this session. See Checkout URL.

Errors

StatusWhen
400Missing customerRef or customerName; invalid email/phone; amount missing, not a number, zero or negative, below the minimum or above the maximum; unsupported currency; the customer is your own account; a missing or invalid Idempotency-Key.
401Missing, invalid or revoked secret key (Missing API secret key / Invalid API secret key).
409The Idempotency-Key belongs to a checkout that is no longer payable (cancelled or declined). Retry with a new key.
422The Idempotency-Key was already used with a different request body. Use a new key for a different checkout.
429Rate limit exceeded: 20 requests per minute per business account ("Too many requests. Please try again later."). The Retry-After header gives the seconds to wait before you retry.
503PayOak could not verify your secret key right now. This says nothing about the key. Retry with the same Idempotency-Key.
502Upstream failure. Safe to retry with backoff.

Errors from this endpoint look like { "status": 400, "error": "customerRef and customerName are required" }. Request-validation failures from the global validator use the standard { "statusCode", "message", "error" } shape, so read whichever of error or message is present.

Idempotent requests

Every request to this endpoint must include an Idempotency-Key header. It guarantees a retry never creates a second checkout. If your request times out or your server restarts, repeat the same call with the same key and PayOak returns the original session.

KeyRequest bodyResult
NewAnyCreates the checkout and remembers the key.
Already usedSame as the first requestReturns the original checkoutSessionId and checkoutUrl, with the header Idempotent-Replayed: true.
Already usedDifferent from the first request422. Nothing is created.
Already usedSame, but the original is cancelled or declined409. Retry with a new key.

Choosing a key

  • Use 1 to 64 characters from A-Z, a-z, 0-9, _, ., : and -. Anything else, or a missing header, returns 400.
  • Build it from your own order, for example order-123-85000, or generate a UUID when the order is created and store it. Do not generate a new random value on every call, or a retry will not match.
  • Make the key change when the checkout changes. A different amount, customer or order should have a different key.

What counts as the same request

PayOak compares amount, currency, customerRef, merchantOrderRef and description. Differences in letter case, surrounding spaces or decimal formatting (25000.5 and 25000.50) are ignored. customerName is not compared, so correcting a spelling does not change the request.

Node.js
// Retrying is safe: the same key always returns the same checkout.
async function createCheckout(order, attempt = 1) {
  try {
    const res = await fetch('https://api.payoak.net/v1/checkout/sessions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': `order-${order.id}-${order.total}`,
        Authorization: `Bearer ${process.env.PAYOAK_SECRET_KEY}`,
      },
      body: JSON.stringify({
        amount: order.total,
        customerRef: order.email,
        customerName: order.name,
        merchantOrderRef: String(order.id),
      }),
    });
    if (res.status >= 500 && attempt < 3) return createCheckout(order, attempt + 1);
    return await res.json();
  } catch (err) {
    if (attempt < 3) return createCheckout(order, attempt + 1); // timeout or network error
    throw err;
  }
}

Retry with the same key, never a new one. A new key is a new checkout. If a request times out, repeat it with the key you already used. Generating a fresh random key on every attempt defeats the protection and can create two sessions for one order.

Checkout URL

The checkoutUrl is a PayOak-hosted page showing the customer how to pay. Display it in an iframe overlay or redirect to it.

Shape
https://app.payoak.net/checkout/{reference}?key={publicKey}

{reference} looks like CHK-20261001-483920. It identifies the session for the status endpoint.

Option A: embed in an iframe

HTML
<iframe
  id="payoak-checkout"
  src="{{checkoutUrl}}"
  style="position:fixed;inset:0;width:100%;height:100%;border:0;z-index:99999"
  allow="clipboard-write"></iframe>

If your site sets its own Content-Security-Policy, add the PayOak checkout host (https://app.payoak.net) to frame-src.

Option B: redirect

Send the customer's browser to checkoutUrl. Use this if you would rather not embed an iframe.

After payment: the callback URL

If you set a callback URL (in Account → Developer & API ), the hosted page sends the customer there once payment is confirmed (e.g https://yourstore.com/order/confirmed/{merchantOrderRef}). The “Payment received” screen shows a button back to your business and redirects on click. Without a callback URL, the customer stays on that screen until you direct the customer back to your page in response to our webhook notification. Either way, PayOak emails the customer a confirmation with a link to track the transaction.

Payment received screen with the Return to Lumi Studios button highlighted
After payment, the customer returns to your site, by button or automatically after 10 seconds.

The hosted page navigates its own window. When the checkout is in an iframe, your callback page therefore loads inside the frame. Have that page break out (if (window.top !== window) window.top.location.href = location.href) or use the redirect integration.

The redirect is not proof of payment. It happens in the customer's browser. They can close the tab before it fires, and anyone can type your callback URL into an address bar. Use it for UX only, and release goods, credit accounts or ship orders on the signed webhook.

What the customer sees

ModeExperience
LiveBank-transfer instructions: a PayOak virtual account (bank, account number, name) and the exact total (amount plus fee), with a 30-minute countdown after which the account details expire. The customer pays from their banking app, then selects I have transferred the money. Confirmation can take a few minutes.
TestThe same screen with a Test mode banner and the bank details labelled as test details. No money needs to move: selecting I have transferred the money simulates the payment, and the success screen reads Test payment received.
PayOak checkout in live mode showing the virtual account, total and countdown
What your customer sees in live mode.

Checkout status

GET/v1/checkout/sessions/{reference}?key={publicKey}

Read the current state of a session. Useful for UI polling, but webhooks remain the source of truth for fulfilment.

Parameters

NameInDescription
referencepathThe CHK-… reference from the checkoutUrl.
keyqueryYour public key (pk_…). No secret is needed.

If the reference doesn't exist or the key doesn't own it, the response is 404, so a guessed reference can't be confirmed.

cURL
curl "https://api.payoak.net/v1/checkout/sessions/CHK-20261001-483920?key=pk_9f2c…"

Response 200 OK

JSON
{
  "paid": true,
  "callbackUrl": "https://yourstore.com/order/confirmed/ORDER-123",
  "testMode": false,
  "paymentId": "CHK-20261001-483920",
  "merchantName": "Lumi Studios",
  "merchantRef": "ORDER-123",
  "customerName": "Adaeze Okonkwo",
  "customerRef": "adaeze@example.com",
  "currency": "NGN",
  "amount": 85000,
  "transactionFee": 3125,
  "totalAmount": 88125,
  "accountNumber": "1234567890",
  "settlementBank": "PALMPAY",
  "accountCreatedAt": "2026-10-01T09:14:22.000Z"
}
FieldDescription
paidtrue once the customer's payment is confirmed and held in escrow.
callbackUrlYour configured callback URL once paid is true; otherwise null.
testModetrue for a simulated session.
merchantRefThe merchantOrderRef you supplied, or null.
amount / transactionFee / totalAmountNaira. totalAmount is what the customer transfers. PayOak fees are tiered, so always use the transactionFee and totalAmount the API returns rather than calculating them yourself.
accountNumber, settlementBank, accountCreatedAtThe virtual account the customer pays into. May be empty briefly right after creation, or in test mode.

Overview

PayOak POSTs a signed JSON event to your server whenever one of your checkout transactions changes state. This is the only signal you should act on.

Browser signals are not proof of payment. The callback redirect and any client-side event come from the customer's browser and can be skipped or forged. Only a webhook, signed with your webhookSecret and sent from PayOak's servers, is trustworthy.

Setup

  1. Expose an HTTPS endpoint on your backend. localhost and private IP ranges are rejected. For local development, use a tunnel such as ngrok.
  2. Add it in Account → Developer & API, Your webhookSecret is displayed on your PayOak dashboarrd.
  3. Verify every request’s signature.

How webhooks are sent

PropertyDetail
MethodPOST, Content-Type: application/json
SuccessRespond with any 2xx within 5 seconds.
RetriesTimeout, non-2xx or a connection error is retried up to 5 attempts with exponential backoff starting at 5 seconds (about 5s, 10s, 20s, 40s between attempts).
OrderingNot guaranteed. A retry of an earlier event can arrive after a later one.
ScopeOnly transactions created through /v1/checkout/sessions trigger webhooks to your business.

Handle events idempotently

The same event can arrive more than once. Key your processing on data.transactionId + event, and acknowledge quickly. Do slow work (emails, inventory) after returning 200.

Test events

In test mode, events carry "livemode": false. Route these away from your real fulfilment pipeline.

Events

Four events cover the escrow lifecycle.

EventFires whenWhat to do
payment.confirmedThe customer's transfer is confirmed and the funds are held in escrow.Mark the order paid and start fulfilment.
delivery.confirmedDelivery is confirmed, by either party or automatically once the delivery window ends.Mark the order complete. PayOak releases the funds to you.
payment.refundRequestedA refund has been requested on the transaction.Hold shipment if you haven't shipped.
payment.refundedThe transaction was refunded.Cancel or reverse the order.

Payload

JSON
{
  "event": "payment.confirmed",
  "data": {
    "transactionId": 48213,
    "merchantOrderRef": "ORDER-123",
    "amount": 85000,
    "livemode": true
  },
  "ts": 1790845200000
}
FieldDescription
eventOne of the event names above.
data.transactionIdEquals the checkoutSessionId returned when you created the session.
data.merchantOrderRefYour merchantOrderRef, or null.
data.amountTransaction amount in Naira (excludes the PayOak fee).
data.livemodefalse for simulated test payments.
tsUnix time in milliseconds when PayOak sent this webhook.

Headers

HTTP
Content-Type: application/json
X-PayOak-Signature: 9b1c…e07a   # hex HMAC-SHA256 of the raw body

Example handler

app.post('/webhooks/payoak', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyPayOakWebhook(req.body, req.get('X-PayOak-Signature'), process.env.PAYOAK_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const { event, data } = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);                      // acknowledge first

  if (!data.livemode) return;               // ignore test events in production
  const key = `${data.transactionId}:${event}`;
  if (await alreadyProcessed(key)) return;  // idempotency
  switch (event) {
    case 'payment.confirmed':  await markPaid(data.merchantRef); break;
    case 'delivery.confirmed': await markComplete(data.merchantRef); break;
    case 'payment.refunded':   await cancelOrder(data.merchantRef); break;
  }
  await rememberProcessed(key);
});

Signature verification

Every webhook carries X-PayOak-Signature, a hex-encoded HMAC-SHA256 of the exact request body, keyed with your webhookSecret.

Sign the raw bytes. Compute the HMAC over the body exactly as received, before JSON.parse. A re-serialised object can differ in key order or whitespace and will not match.

Algorithm

  1. Read the raw request body.
  2. Compute hex(HMAC_SHA256(webhookSecret, rawBody)).
  3. Compare with X-PayOak-Signature using a constant-time comparison.
  4. If they differ, respond non-2xx and discard the request.
const crypto = require('crypto');

function verifyPayOakWebhook(rawBody, signatureHeader, webhookSecret) {
  if (!signatureHeader) return false;
  const expected = crypto
    .createHmac('sha256', webhookSecret)
    .update(rawBody)                     // Buffer or string, before JSON.parse
    .digest('hex');

  const a = Buffer.from(signatureHeader, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Capturing the raw body

  • Express: use express.raw({ type: 'application/json' }) on the route, or a verify callback on express.json() that stores req.rawBody.
  • Flask: request.get_data().
  • PHP: file_get_contents('php://input').

Keep it safe

  • Store webhookSecret in a secret manager, never in source control.
  • The signed body includes ts. For extra protection, also reject events whose ts is far from your clock (e.g. more than 10 minutes) and de-duplicate on transactionId + event.

Payment button

<payoak-payment-option> is a drop-in web component that renders “Pay with PayOak Escrow” as a payment method on your checkout page.

Install

HTML
<script src="https://developer.payoak.net/v1/payoak-payment-option.min.js" defer></script>

<payoak-payment-option></payoak-payment-option>

It uses Shadow DOM, so your page styles can't break it and it needs no framework. It sizes itself to its container (up to ~480px wide).

The PayOak payment button, labelled Pay with PayOak Escrow, with a note that funds are released only after delivery is confirmed
The payment button as your customers see it.

Events and attributes

NameTypeDescription
payoak-selecteventFired (bubbling, composed) when the customer clicks the button. The component does not create a session. Your handler does.
loadingattributeSet while your backend creates the session; removing it re-enables the button.

Full example

The button does not hold any keys. On click, call your own backend, which calls PayOak with the secret key, and show the returned URL.

Browser
<payoak-payment-option></payoak-payment-option>
<iframe id="checkout" style="display:none;width:100%;height:720px;border:0"></iframe>

<script>
const btn = document.querySelector('payoak-payment-option');

btn.addEventListener('payoak-select', async () => {
  btn.setAttribute('loading', '');
  try {
    // YOUR endpoint - it calls POST /v1/checkout/sessions with the secret key
    const res = await fetch('/api/payoak/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId: 'ORDER-123' }),
    });
    const { checkoutUrl } = await res.json();
    const frame = document.getElementById('checkout');
    frame.src = checkoutUrl;
    frame.style.display = 'block';
  } catch (err) {
    alert('Unable to start checkout. Please try again.');
  } finally {
    btn.removeAttribute('loading');
  }
});
</script>

Your backend decides the amount. Look up the price from your own order record on the server. Never accept an amount from the browser, or a customer can pay less than the order total.

Treat the iframe or redirect finishing as UX only. Fulfil on the webhook.

API reference

Every public endpoint at a glance. Base URL https://api.payoak.net.

Checkout

MethodPathAuthDescription
POST/v1/checkout/sessionsSecret keyCreate a checkout session. Requires an Idempotency-Key header. 20/min per business account.
GET/v1/checkout/sessions/{ref}?key=pk_Public keyGet session status

Webhook events (outbound)

EventDocs
payment.confirmedEvents and payload, signature verification
delivery.confirmed
payment.refundRequested
payment.refunded

Transaction states

A checkout transaction moves through the escrow states below. Webhooks report the transitions in bold.

created→payment confirmed→delivered→delivery confirmed→funds released
payment confirmed→refund requested→refunded

Status codes

CodeMeaning
200 / 201Success.
400Validation failed. The body explains which field.
401Missing, invalid or revoked credentials.
403The account is not eligible: not a Business account, or below Tier 2.
404Session or key not found.
409A key already exists for this account (rotate it instead), or an Idempotency-Key belongs to a checkout that is no longer payable.
422An Idempotency-Key was reused with a different request body.
429Rate limit exceeded. Wait for the number of seconds in the Retry-After header, then retry.
502Upstream error. Retry with exponential backoff.
503Temporarily unable to process the request. Retry shortly.

Going-live checklist

  • Secret key stored server-side only; not in client code or Git.
  • Webhook endpoint is HTTPS, verifies signatures on raw bytes, and is idempotent.
  • Fulfilment is triggered by payment.confirmed with livemode: true, not by redirects.
  • Amount is computed on your server from your own order record.
  • Every checkout request sends an Idempotency-Key built from your order, and retries reuse the same key.
  • Test mode turned off in Developer & API (or PATCH /v1/merchant/keys/mode).