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

<!-- tabs -->

```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)
}
```

<!-- /tabs -->

```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
})
```

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