# 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).