Payfet/Docs
PricingDashboard

Introduction

  • Getting started

Guides

  • Accept payments on your site

SDKs

  • Node.js SDK
  • Python SDK

Reference

  • API reference

For AI agents

/llms.txt

Guides

Create a checkout session from your server, present it inline or by redirect, and confirm with a webhook.

View as Markdown

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:

EntryMatches
203.0.113.7that one address
203.0.113.0/24the whole /24
2001:db8::1that one address
2001:db8::/32the 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 }
  }'
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
<script src="https://cdn.payfet.org/pay.js"></script>

With a button, no JavaScript of your own:

html
<!-- 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:

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
})
HeaderWhat it carries
X-Payfet-SignatureHMAC-SHA256 of the raw body, hex
X-Payfet-EventEvent name, so you can route without parsing
X-Payfet-DeliveryUnique id for this delivery — retries reuse it
X-Payfet-TimestampWhen 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"
StatusMeaning
pendingCreated; the customer has not opened it yet
processingAccount details issued; waiting for the transfer
paidMoney received and credited to your Payfet wallet
expiredThe transfer window closed before money arrived
failedThe 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

FieldDescriptionRequired
referenceYour own order reference. Reusing one returns the existing checkout.Yes
amountAmount in major units, e.g. "2500.00" for ₦2,500Yes
customer_emailYour customer's email addressYes
currencyDefaults to NGNNo
customer_nameShown 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_phoneRequired by the bank; the page asks for it if you omit itNo
redirect_urlWhere to send the customer once they have paidNo
themehosted (default) or branded to use your own logo and coloursNo
slugA readable URL, e.g. summer-sale. Suffixed if already taken.No
metadataAnything you want returned to you on the webhookNo

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.

Building with an AI assistant? Use /llms.txt to give it every page at once.