Guides
Create a checkout session from your server, present it inline or by redirect, and confirm with a webhook.
Accept payments on your site
Create a checkout from your server, send your customer to the page, and Payfet tells you when the money lands.
How it works
- Your server asks Payfet for a checkout, passing the amount and your own order reference.
- Payfet returns a
checkout_url. You send your customer there. - The page shows a bank account and the exact amount. Your customer transfers from their banking app.
- Payfet confirms the transfer, credits your Payfet wallet, and calls your webhook.
Payment is by bank transfer There is no card form. Your customer is shown an account number and an exact amount, and pays from their own bank. The account is single-use, tied to that exact amount, and expires after about 15 minutes — so confirmation is not instant the way a card authorisation is. Treat the webhook as the moment you have been paid.
Before you start
Get your API credentials from Payfet for Business → Settings → Token. Every request below sends all three:
X-Tenant-Token: your access token
X-Tenant-Secret: your secret key
X-Tenant-BID: your business idThese belong on your server only. Anyone holding them can create checkouts in your name, so never ship them to a browser or commit them to a repository.
Restrict them to your servers
Under Settings → IP allowlist you can list the addresses allowed to use these credentials. Both IPv4 and IPv6 are accepted, as a single address or a CIDR range:
| Entry | Matches |
|---|---|
203.0.113.7 | that one address |
203.0.113.0/24 | the whole /24 |
2001:db8::1 | that one address |
2001:db8::/32 | the whole /32 |
An empty list means no restriction. Once there is one active entry, calls from
anywhere else are rejected with 401, so add every address your servers call
from — including any NAT gateway or egress proxy they sit behind — before you
rely on it. Your customers' checkout pages are unaffected either way; this
applies to the API only.
Check the address we see, not the one you think you have The allowlist is matched against the address Payfet resolves, which is not always what an external "what is my IP" service reports once a load balancer or egress proxy is in the path. The settings page shows you the address we currently see, and offers to add it.
1. Create a checkout
Call this from your backend when a customer is ready to pay.
curl -X POST https://api.payfet.org/v1/checkout/sessions/ \
-H "Content-Type: application/json" \
-H "X-Tenant-Token: $PAYFET_TOKEN" \
-H "X-Tenant-Secret: $PAYFET_SECRET" \
-H "X-Tenant-BID: $PAYFET_BID" \
-d '{
"reference": "order_12345",
"amount": "2500.00",
"currency": "NGN",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"customer_phone": "08012345678",
"redirect_url": "https://yoursite.com/thank-you",
"metadata": { "order_id": 12345 }
}'{
"reference": "order_12345",
"slug": "order-12345",
"checkout_url": "https://checkout.payfet.org/order-12345",
"amount": "2500.00",
"currency": "NGN",
"status": "pending"
}Set the amount on your server Never let the browser decide what to charge. If the amount comes from a form field, a query string, or JavaScript, a customer can change it before it reaches you and pay whatever they like. Read the price from your own database when you create the checkout.
Amounts are in major units, not kobo. "2500.00" means ₦2,500 — not ₦25.
Retries are safe. Creating a checkout twice with the same reference
returns the original one rather than a second charge, so a timeout you retry
cannot bill your customer twice.
Send customer_phone if you have it. The bank requires a phone number to
issue the account. If you omit it, the checkout page asks your customer for one
before it can show them the transfer details — one extra step you can remove.
2. Show the checkout
Redirecting is the simplest option and works everywhere:
// after creating the checkout on your server
window.location.href = checkout.checkout_urlInline: keep them on your page
Drop in our script and the checkout opens in a modal over your site.
<script src="https://cdn.payfet.org/pay.js"></script>With a button, no JavaScript of your own:
<!-- checkout_url comes from your server -->
<button
data-payfet-checkout="https://checkout.payfet.org/order-12345"
data-payfet-success="/thank-you"
>
Pay ₦2,500
</button>Or call it yourself:
const res = await fetch('/api/create-checkout', { method: 'POST' })
const { checkout_url } = await res.json()
Payfet.checkout({
url: checkout_url,
onSuccess: () => {
// The modal closes itself. Confirm with your own server before
// showing a receipt — your server knows because Payfet called your webhook.
location.assign('/thank-you')
},
onClose: () => console.log('customer dismissed the modal'),
})The script takes a URL, never an amount
It opens a checkout your server already created; it cannot create one. That is
on purpose — a price that reaches the page through the browser is a price your
customer can edit before paying. It also refuses any URL that is not on
checkout.payfet.org.
onSuccess is a UI signal, not a receipt. Fulfil the order from the webhook,
which arrives whether or not the customer kept your tab open.
3. Confirm the payment
Payfet calls the webhook URL configured in Settings → Webhook when a transfer clears. This is the event to act on — fulfil the order here, not when the customer is redirected back.
The redirect is not proof of payment A customer can land on your thank-you page without having transferred anything, or close the tab after paying and never reach it at all. Bank transfers settle asynchronously. Fulfil on the webhook.
{
"event": "checkout.paid",
"id": "6f1c9d2e-...",
"created_at": "2026-08-10T10:04:11+00:00",
"data": {
"reference": "order_12345",
"status": "paid",
"amount": "2500.00",
"settled_amount": "2480.00",
"currency": "NGN",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"paid_at": "2026-08-10T10:04:09+00:00",
"metadata": { "order_id": 12345 }
}
}Verify the signature
Every call carries X-Payfet-Signature: an HMAC-SHA256 of the raw request
body, keyed with your webhook secret. Verify it before trusting anything in
the payload — your endpoint is public, and anyone who finds it can post to it.
import crypto from 'node:crypto'
// Use the raw body, not a re-serialised object. JSON.stringify(req.body) can
// reorder keys or change spacing, and the digest will never match.
app.post('/payfet', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.PAYFET_WEBHOOK_SECRET)
.update(req.body)
.digest('hex')
const signature = req.get('X-Payfet-Signature') ?? ''
const valid =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
if (!valid) return res.sendStatus(401)
const { event, id, data } = JSON.parse(req.body)
if (event === 'checkout.paid') fulfil(data.reference, id)
res.sendStatus(200) // acknowledge fast; do the work after
})| Header | What it carries |
|---|---|
X-Payfet-Signature | HMAC-SHA256 of the raw body, hex |
X-Payfet-Event | Event name, so you can route without parsing |
X-Payfet-Delivery | Unique id for this delivery — retries reuse it |
X-Payfet-Timestamp | When we sent it, ISO 8601 |
Retries, and why your handler must be idempotent
Return 2xx and we consider it delivered. On a timeout, a connection failure
or a 5xx we retry on the schedule set in Settings → Webhook. A 4xx is
treated as a deliberate rejection and is not retried.
That means the same payment can reach you more than once — a retry after your
server accepted the call but timed out on the way back looks identical to a
first delivery. Key on id (or on reference, which is your own) and ignore
what you have already processed. Shipping goods twice is the failure mode here.
If you would rather poll — for example on your own order page:
curl https://api.payfet.org/v1/checkout/sessions/order_12345/ \
-H "X-Tenant-Token: $PAYFET_TOKEN" \
-H "X-Tenant-Secret: $PAYFET_SECRET" \
-H "X-Tenant-BID: $PAYFET_BID"| Status | Meaning |
|---|---|
pending | Created; the customer has not opened it yet |
processing | Account details issued; waiting for the transfer |
paid | Money received and credited to your Payfet wallet |
expired | The transfer window closed before money arrived |
failed | The payment could not be completed |
You are credited the settled amount, which is net of the transfer fee —
reconcile against settled_amount rather than the amount you asked for.
Request fields
| Field | Description | Required |
|---|---|---|
reference | Your own order reference. Reusing one returns the existing checkout. | Yes |
amount | Amount in major units, e.g. "2500.00" for ₦2,500 | Yes |
customer_email | Your customer's email address | Yes |
currency | Defaults to NGN | No |
customer_name | Shown on the page, on your receipts, and as the account name your customer sees when transferring. Omitted, the account is named Payfet Checkout. | No |
customer_phone | Required by the bank; the page asks for it if you omit it | No |
redirect_url | Where to send the customer once they have paid | No |
theme | hosted (default) or branded to use your own logo and colours | No |
slug | A readable URL, e.g. summer-sale. Suffixed if already taken. | No |
metadata | Anything you want returned to you on the webhook | No |
Making it look like you
Set "theme": "branded" and the checkout uses your logo, colour, corner
radius, button wording and support address, configured once in Settings →
Checkout branding. Everything else about the page stays the same, so payers
still recognise a Payfet checkout.
Branding is configuration, not code — there is no custom HTML or CSS to upload. That keeps a compromised or careless template on one merchant's account from affecting anyone else's payers.
No code at all?
Create a payment link in Payfet for Business under Payments → Links. You
get a shareable checkout.payfet.org URL for WhatsApp, email or a bio — the
same checkout page, no server required.
Need help?
Email support@payfet.org with your business ID and
the reference you used, and we can trace a specific checkout.