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.
POST /v1/checkout/sessions
WebhooksThe only reliable signal that a customer paid.
How it works
- Your backend calls
POST /v1/checkout/sessionswith your secret key. The amount and the secret never reach the browser. - You render the returned
checkoutUrlin an iframe, or redirect to it. - 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.
- PayOak POSTs
payment.confirmedto your webhook URL. Fulfil the order only on this signal. - When delivery is confirmed (by either party, or automatically when the delivery window ends), PayOak POSTs
delivery.confirmedand releases the funds to you.
Conventions
| Item | Detail |
|---|---|
| Base URL | https://api.payoak.net |
| Format | JSON request and response bodies; Content-Type: application/json |
| Currency | Only 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. |
| Requirements | A 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
- 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.
Developer & API is in the Business section of your Account page. - 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 andsk_…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.Three settings to complete: API credentials, callback URL and webhook URL. - 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. - 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.
- 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.
After setup: the test mode switch, your credentials, and the option to rotate them.
Make your first test payment
- 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" }'const res = await fetch('https://api.payoak.net/v1/checkout/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'order-123-85000', Authorization: `Bearer ${process.env.PAYOAK_SECRET_KEY}`, }, body: JSON.stringify({ amount: 85000, // Naira customerRef: 'adaeze@example.com', //email or phone number customerName: 'Adaeze Okonkwo', merchantOrderRef: 'ORDER-123', }), }); if (!res.ok) throw new Error(await res.text()); const { checkoutSessionId, checkoutUrl } = await res.json();import os, requests r = requests.post( "https://api.payoak.net/v1/checkout/sessions", headers={ "Authorization": f"Bearer {os.environ['PAYOAK_SECRET_KEY']}", "Idempotency-Key": "order-123-85000", }, json={ "amount": 85000, "customerRef": "adaeze@example.com", //email or phone number "customerName": "Adaeze Okonkwo", "merchantOrderRef": "ORDER-123", }, timeout=15, ) r.raise_for_status() session = r.json() # {"checkoutSessionId": ..., "checkoutUrl": ...}$ch = curl_init('https://api.payoak.net/v1/checkout/sessions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotency-Key: order-123-85000', 'Authorization: Bearer ' . getenv('PAYOAK_SECRET_KEY'), ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 85000, 'customerRef' => 'adaeze@example.com', 'customerName' => 'Adaeze Okonkwo', 'merchantOrderRef' => 'ORDER-123', ]), ]); $session = json_decode(curl_exec($ch), true); - Show the checkout.
Put the
checkoutUrlin an iframe or redirect the customer to it. See Checkout URL. - 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.
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.
The test success screen. The banner confirms no real money moved. - Receive the webhook.
Your endpoint gets
payment.confirmedwith"livemode": false. Verify the signature, then respond200. - Go live.
Turn off Test mode in Developer & API, or call
PATCH /v1/merchant/keys/modewith{"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
| Credential | Format | Use |
|---|---|---|
publicKey | pk_… | Identifies your account. Appears in the checkoutUrl and is required to read session status. Safe in client-side code. |
secretKey | sk_… | Authenticates your backend's calls. Shown once at creation. Never expose it in a browser, mobile app or repository. |
webhookSecret | 64 hex chars | Verifies 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:
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
Creates an escrow transaction and returns a hosted checkout URL for your customer. Call it from your backend only.
Headers
| Header | Value |
|---|---|
Authorization | Bearer sk_… required |
Content-Type | application/json |
Idempotency-Key | A unique string for this checkout, 1 to 64 characters. required See Idempotent requests. |
Body parameters
| Field | Type | Description |
|---|---|---|
amountrequired | number | Amount 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. |
customerRefrequired | string | Customer's email or E.164 phone (e.g. +2348012345678). Lower-cased by PayOak. Must differ from your own account email. |
customerNamerequired | string | Customer's full name. |
currencyoptional | string | Defaults to NGN, the only supported value. |
descriptionoptional | string | Shown to the customer. Defaults to “Payment to <your business name>”. |
merchantOrderRefoptional | string | Your 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"
}'
const res = await fetch('https://api.payoak.net/v1/checkout/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': 'order-123-85000',
Authorization: `Bearer ${process.env.PAYOAK_SECRET_KEY}`,
},
body: JSON.stringify({
amount: 85000,
currency: 'NGN',
customerRef: 'adaeze@example.com', //email or phone number
customerName: 'Adaeze Okonkwo',
description: 'Order #123',
merchantOrderRef: 'ORDER-123',
}),
});
const session = await res.json();
r = requests.post(
"https://api.payoak.net/v1/checkout/sessions",
headers={
"Authorization": "Bearer {SECRET}",
"Idempotency-Key": "order-123-85000",
},
json={
"amount": 85000,
"currency": "NGN",
"customerRef": "adaeze@example.com", //email or phone number
"customerName": "Adaeze Okonkwo",
"description": "Order #123",
"merchantOrderRef": "ORDER-123"
},
)
session = r.json()
$payload = json_encode([
'amount' => 85000, 'currency' => 'NGN',
'customerRef' => 'adaeze@example.com', 'customerName' => 'Adaeze Okonkwo',
'description' => 'Order #123', 'merchantOrderRef' => 'ORDER-123',
]);
$ch = curl_init('https://api.payoak.net/v1/checkout/sessions');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json',
'Idempotency-Key: order-123-85000',
'Authorization: Bearer ' . getenv('PAYOAK_SECRET_KEY')],
CURLOPT_POSTFIELDS => $payload]);
$session = json_decode(curl_exec($ch), true);
Response 201 Created
{
"checkoutSessionId": 48213,
"checkoutUrl": "https://app.payoak.net/checkout/CHK-20261001-483920?key=pk_9f2c…"
}| Field | Description |
|---|---|
checkoutSessionId | The transaction ID. This is the transactionId you receive in webhooks. Store it against your order. |
checkoutUrl | The hosted checkout page for this session. See Checkout URL. |
Errors
| Status | When |
|---|---|
400 | Missing 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. |
401 | Missing, invalid or revoked secret key (Missing API secret key / Invalid API secret key). |
409 | The Idempotency-Key belongs to a checkout that is no longer payable (cancelled or declined). Retry with a new key. |
422 | The Idempotency-Key was already used with a different request body. Use a new key for a different checkout. |
429 | Rate 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. |
503 | PayOak could not verify your secret key right now. This says nothing about the key. Retry with the same Idempotency-Key. |
502 | Upstream 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.
| Key | Request body | Result |
|---|---|---|
| New | Any | Creates the checkout and remembers the key. |
| Already used | Same as the first request | Returns the original checkoutSessionId and checkoutUrl, with the header Idempotent-Replayed: true. |
| Already used | Different from the first request | 422. Nothing is created. |
| Already used | Same, but the original is cancelled or declined | 409. 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, returns400. - 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.
// 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.
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
<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.
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
| Mode | Experience |
|---|---|
| Live | Bank-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. |
| Test | The 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. |
Checkout status
Read the current state of a session. Useful for UI polling, but webhooks remain the source of truth for fulfilment.
Parameters
| Name | In | Description |
|---|---|---|
reference | path | The CHK-… reference from the checkoutUrl. |
key | query | Your 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 "https://api.payoak.net/v1/checkout/sessions/CHK-20261001-483920?key=pk_9f2c…"
Response 200 OK
{
"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"
}| Field | Description |
|---|---|
paid | true once the customer's payment is confirmed and held in escrow. |
callbackUrl | Your configured callback URL once paid is true; otherwise null. |
testMode | true for a simulated session. |
merchantRef | The merchantOrderRef you supplied, or null. |
amount / transactionFee / totalAmount | Naira. 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, accountCreatedAt | The 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
- Expose an HTTPS endpoint on your backend.
localhostand private IP ranges are rejected. For local development, use a tunnel such as ngrok. - Add it in Account → Developer & API,
Your
webhookSecretis displayed on your PayOak dashboarrd. - Verify every request’s signature.
How webhooks are sent
| Property | Detail |
|---|---|
| Method | POST, Content-Type: application/json |
| Success | Respond with any 2xx within 5 seconds. |
| Retries | Timeout, 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). |
| Ordering | Not guaranteed. A retry of an earlier event can arrive after a later one. |
| Scope | Only 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.
| Event | Fires when | What to do |
|---|---|---|
payment.confirmed | The customer's transfer is confirmed and the funds are held in escrow. | Mark the order paid and start fulfilment. |
delivery.confirmed | Delivery is confirmed, by either party or automatically once the delivery window ends. | Mark the order complete. PayOak releases the funds to you. |
payment.refundRequested | A refund has been requested on the transaction. | Hold shipment if you haven't shipped. |
payment.refunded | The transaction was refunded. | Cancel or reverse the order. |
Payload
{
"event": "payment.confirmed",
"data": {
"transactionId": 48213,
"merchantOrderRef": "ORDER-123",
"amount": 85000,
"livemode": true
},
"ts": 1790845200000
}| Field | Description |
|---|---|
event | One of the event names above. |
data.transactionId | Equals the checkoutSessionId returned when you created the session. |
data.merchantOrderRef | Your merchantOrderRef, or null. |
data.amount | Transaction amount in Naira (excludes the PayOak fee). |
data.livemode | false for simulated test payments. |
ts | Unix time in milliseconds when PayOak sent this webhook. |
Headers
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);
});
@app.post("/webhooks/payoak")
def payoak_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_payoak_webhook(raw, request.headers.get("X-PayOak-Signature", ""), WEBHOOK_SECRET):
return "", 401
evt = json.loads(raw)
if evt["data"]["livemode"] and not already_processed(evt):
handle(evt)
return "", 200
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_PAYOAK_SIGNATURE'] ?? '';
if (!hash_equals(hash_hmac('sha256', $raw, getenv('PAYOAK_WEBHOOK_SECRET')), $sig)) {
http_response_code(401); exit;
}
$evt = json_decode($raw, true);
http_response_code(200);
// …handle $evt['event'] idempotently
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
- Read the raw request body.
- Compute
hex(HMAC_SHA256(webhookSecret, rawBody)). - Compare with
X-PayOak-Signatureusing a constant-time comparison. - 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);
}
import hmac, hashlib
def verify_payoak_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
function verifyPayOakWebhook(string $raw, string $sig, string $secret): bool {
return hash_equals(hash_hmac('sha256', $raw, $secret), $sig);
}
Capturing the raw body
- Express: use
express.raw({ type: 'application/json' })on the route, or averifycallback onexpress.json()that storesreq.rawBody. - Flask:
request.get_data(). - PHP:
file_get_contents('php://input').
Keep it safe
- Store
webhookSecretin a secret manager, never in source control. - The signed body includes
ts. For extra protection, also reject events whosetsis far from your clock (e.g. more than 10 minutes) and de-duplicate ontransactionId+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
<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).
Events and attributes
| Name | Type | Description |
|---|---|---|
payoak-select | event | Fired (bubbling, composed) when the customer clicks the button. The component does not create a session. Your handler does. |
loading | attribute | Set 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.
<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
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/checkout/sessions | Secret key | Create a checkout session. Requires an Idempotency-Key header. 20/min per business account. |
| GET | /v1/checkout/sessions/{ref}?key=pk_ | Public key | Get session status |
Webhook events (outbound)
| Event | Docs |
|---|---|
payment.confirmed | Events 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.
Status codes
| Code | Meaning |
|---|---|
200 / 201 | Success. |
400 | Validation failed. The body explains which field. |
401 | Missing, invalid or revoked credentials. |
403 | The account is not eligible: not a Business account, or below Tier 2. |
404 | Session or key not found. |
409 | A key already exists for this account (rotate it instead), or an Idempotency-Key belongs to a checkout that is no longer payable. |
422 | An Idempotency-Key was reused with a different request body. |
429 | Rate limit exceeded. Wait for the number of seconds in the Retry-After header, then retry. |
502 | Upstream error. Retry with exponential backoff. |
503 | Temporarily 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.confirmedwithlivemode: true, not by redirects. - Amount is computed on your server from your own order record.
- Every checkout request sends an
Idempotency-Keybuilt from your order, and retries reuse the same key. - Test mode turned off in Developer & API (or
PATCH /v1/merchant/keys/mode).