# Payfet
> Payfet lets businesses take payments from their customers. A server creates
> a checkout, the customer pays by bank transfer to a single-use account, and
> a webhook confirms it. Payment is by bank transfer, not card.
## Facts an integration must get right
- Amounts are in major units: "2500.00" means NGN 2,500, not 25.
- Create the checkout server-side. A price that reaches the browser can be
edited by the customer before paying.
- Fulfil on the webhook, never on the redirect. Bank transfers settle
asynchronously and the customer may never return to your page.
- Credentials are three headers: X-Tenant-Token, X-Tenant-Secret,
X-Tenant-BID. Server-side only.
- The bank account issued to a payer expires after about 15 minutes and
matches one exact amount.
## Pages
- [Getting started](https://payfet.org/docs.md): Take payments from your customers with Payfet. Create a checkout from your server, show the page, get paid.
- [Accept payments on your site](https://payfet.org/docs/inline.md): Create a checkout session from your server, present it inline or by redirect, and confirm with a webhook.
- [Node.js SDK](https://payfet.org/docs/node.md): Install and use the Payfet Node.js SDK for bills, airtime and transaction lookups.
- [Python SDK](https://payfet.org/docs/python.md): Install and use the Payfet Python SDK for bills, airtime and transaction lookups.
- [API reference](https://payfet.org/docs/reference.md): Endpoint reference for the Payfet API.
---
# Getting started
Source: https://payfet.org/docs
# Getting started
Payfet lets you take payments from your customers on your own website. Your
server creates a checkout, your customer pays by bank transfer, and we tell you
when the money lands.
## The shape of an integration
1. Your server asks Payfet for a checkout, passing the amount and your own order
reference.
2. Payfet returns a `checkout_url`. You send your customer there, or open it in
a modal on your page.
3. The page shows a bank account and an exact amount. Your customer transfers
from their banking app.
4. Payfet confirms the transfer, credits your Payfet wallet, and calls your
webhook.
> **Payment is by bank transfer, not card.**
> 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. Confirmation is not instant
> the way a card authorisation is — treat the webhook as the moment you have
> been paid.
## Get your API credentials
From **Payfet for Business → Settings → Token**. Every server-side request sends
all three:
```http
X-Tenant-Token: your access token
X-Tenant-Secret: your secret key
X-Tenant-BID: your business id
```
These 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.
## Your first checkout
```bash
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"
}'
```
```json
{
"reference": "order_12345",
"slug": "order-12345",
"checkout_url": "https://checkout.payfet.org/order-12345",
"amount": "2500.00",
"currency": "NGN",
"status": "pending"
}
```
Open that `checkout_url` and you have a working payment page.
## Two things that cost people money
**Amounts are in major units.** `"2500.00"` means ₦2,500, not ₦25. Send the
amount the way you would write it on a receipt.
**Set the amount on your server.** If the price comes from a form field, a query
string, or JavaScript, your customer can change it before it reaches you. Read
it from your own database when you create the checkout.
## Where to go next
- [Accept payments on your site](/docs/inline) — the full integration, including
the inline modal
- [Node.js SDK](/docs/node)
- [Python SDK](/docs/python)
## 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.
---
# Accept payments on your site
Source: https://payfet.org/docs/inline
# 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
1. Your server asks Payfet for a checkout, passing the amount and your own order
reference.
2. Payfet returns a `checkout_url`. You send your customer there.
3. The page shows a bank account and the exact amount. Your customer transfers
from their banking app.
4. 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:
```http
X-Tenant-Token: your access token
X-Tenant-Secret: your secret key
X-Tenant-BID: your business id
```
These 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.
```bash
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 }
}'
```
```python
import os
import requests
response = requests.post(
"https://api.payfet.org/v1/checkout/sessions/",
headers={
"X-Tenant-Token": os.environ["PAYFET_TOKEN"],
"X-Tenant-Secret": os.environ["PAYFET_SECRET"],
"X-Tenant-BID": os.environ["PAYFET_BID"],
},
json={
# Your own order id. Sending it again returns the same checkout
# instead of creating a second one, so a retry is safe.
"reference": "order_12345",
# A string, not a float — 0.1 + 0.2 is not 0.3, and this is money.
"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},
},
timeout=30,
)
response.raise_for_status()
checkout_url = response.json()["checkout_url"]
```
```ts
const response = await fetch(
'https://api.payfet.org/v1/checkout/sessions/',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Token': process.env.PAYFET_TOKEN!,
'X-Tenant-Secret': process.env.PAYFET_SECRET!,
'X-Tenant-BID': process.env.PAYFET_BID!,
},
body: JSON.stringify({
// Your own order id. Sending it again returns the same checkout
// instead of creating a second one, so a retry is safe.
reference: 'order_12345',
// A string, not a number — 0.1 + 0.2 is not 0.3, and this is money.
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 },
}),
},
)
if (!response.ok) throw new Error(await response.text())
const { checkout_url } = await response.json()
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
body, _ := json.Marshal(map[string]any{
// Your own order id. Sending it again returns the same checkout
// instead of creating a second one, so a retry is safe.
"reference": "order_12345",
// A string, not a float64 — 0.1 + 0.2 is not 0.3, and this is money.
"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": map[string]any{"order_id": 12345},
})
req, _ := http.NewRequest(
"POST",
"https://api.payfet.org/v1/checkout/sessions/",
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tenant-Token", os.Getenv("PAYFET_TOKEN"))
req.Header.Set("X-Tenant-Secret", os.Getenv("PAYFET_SECRET"))
req.Header.Set("X-Tenant-BID", os.Getenv("PAYFET_BID"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
CheckoutURL string `json:"checkout_url"`
}
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(out.CheckoutURL)
}
```
```json
{
"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:
```js
// after creating the checkout on your server
window.location.href = checkout.checkout_url
```
### Inline: keep them on your page
Drop in our script and the checkout opens in a modal over your site.
```html
```
With a button, no JavaScript of your own:
```html
```
Or call it yourself:
```js
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.
```json
{
"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.
```js
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:
```bash
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](mailto:support@payfet.org) with your business ID and
the `reference` you used, and we can trace a specific checkout.
---
# Node.js SDK
Source: https://payfet.org/docs/node
# Node.js SDK
## Installation
```bash
npm install payfet-sdk
```
## Quick start
```js
const Payfet = require('payfet-sdk');
// Initialize the client with your API key
const client = new Payfet({
apiKey: 'your_api_key_here',
environment: 'production' // or 'sandbox'
});
// Process a bill payment
client.payBill({
phone: '08012345678',
amount: 500,
billType: 'electricity'
})
.then(response => {
console.log('Payment successful:', response);
})
.catch(error => {
console.error('Payment failed:', error);
});
```
Keep the API key on your server. A key shipped to a browser is a key anyone can
read and spend.
## TypeScript
```ts
import Payfet from 'payfet-sdk';
interface PaymentRequest {
phone: string;
amount: number;
billType: string;
}
const client = new Payfet({
apiKey: process.env.PAYFET_API_KEY!,
environment: 'production'
});
async function processPayment(request: PaymentRequest) {
try {
const response = await client.payBill(request);
console.log('Transaction ID:', response.transactionId);
return response;
} catch (error) {
console.error('Payment failed:', error);
throw error;
}
}
```
## Common operations
```js
// Top up airtime
await client.topupAirtime({
phone: '08012345678',
amount: 1000,
provider: 'MTN'
});
// Check transaction status
const status = await client.getTransactionStatus('txn_123456');
console.log('Status:', status.status);
// Retrieve account balance
const balance = await client.getBalance();
console.log('Available balance:', balance.amount);
```
## Taking payments from your customers
The SDK covers bills, airtime and account operations. To charge your own
customers on your website, use the checkout API — see
[Accept payments on your site](/docs/inline).
## Need help?
Email [support@payfet.org](mailto:support@payfet.org).
---
# Python SDK
Source: https://payfet.org/docs/python
# Python SDK
## Installation
```bash
pip install payfet
```
## Quick start
```python
from payfet import PayfetClient
# Initialize the client with your API key
client = PayfetClient(api_key="your_api_key_here")
# Process a bill payment
response = client.bill_payment(
phone="08012345678",
amount=500,
billtype="electricity"
)
print(response)
# {
# "status": "success",
# "transaction_id": "txn_123456",
# "message": "Payment successful"
# }
```
Read the key from the environment rather than hardcoding it, so it never
reaches version control.
## Common operations
```python
# Top up airtime for a phone number
response = client.airtime(
phone="08012345678",
amount=1000,
provider="MTN"
)
# Check status of a transaction
status = client.transaction_status(transaction_id="txn_123456")
# Retrieve your account balance
balance = client.get_balance()
```
## Error handling
```python
try:
response = client.bill_payment(phone="08012345678", amount=500)
except PayfetAuthError as e:
print(f"Authentication failed: {e}")
except PayfetPaymentError as e:
print(f"Payment processing error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
```
Catch `PayfetAuthError` separately — it means the credentials are wrong, and
retrying will not help.
## Taking payments from your customers
The SDK covers bills, airtime and account operations. To charge your own
customers on your website, use the checkout API — see
[Accept payments on your site](/docs/inline).
## Need help?
Email [support@payfet.org](mailto:support@payfet.org).
---
# API reference
Source: https://payfet.org/docs/reference
# API reference
The full generated reference is still being written. The checkout endpoints
below are live and documented in full on
[Accept payments on your site](/docs/inline).
## Checkout
Base URL `https://api.payfet.org`.
| Method | Endpoint | Auth | Purpose |
| --- | --- | --- | --- |
| `POST` | `/v1/checkout/sessions/` | Tenant token | Create a checkout, get a `checkout_url` |
| `GET` | `/v1/checkout/sessions/{reference}/` | Tenant token | Status of one checkout |
| `GET` | `/v1/checkout/{slug}/` | None | What the hosted page renders |
| `POST` | `/v1/checkout/{slug}/initiate/` | None | Issue the bank account for a payer |
| `GET` | `/v1/checkout/{slug}/status/` | None | Poll while the payer transfers |
The unauthenticated endpoints are what the hosted checkout page itself calls.
Your customers have no Payfet account, so those cannot require credentials.
## Authentication
```http
X-Tenant-Token: your access token
X-Tenant-Secret: your secret key
X-Tenant-BID: your business id
```
From **Payfet for Business → Settings → Token**. Server-side only.
## Need help?
Email [support@payfet.org](mailto:support@payfet.org).