<!-- Patronise Dev documentation, 2026-11-02. Base URL https://api.patronise.me/v1 -->

# Quickstart

Patronise Dev lets your website or app take **Mobile Money payments** (MTN, Orange, Moov, Airtel, Wave, M-Pesa…) in Central and West Africa through one REST API. This page takes you from zero to a working test payment in about five minutes.

> **Working with an AI agent?** Give it [`llms-full.txt`](https://patronise.me/developers/llms-full.txt): the whole documentation in one file. See [Integrate with an AI agent](https://patronise.me/developers/docs/ai-agents).

## 1. Create an account and a test key

1. [Sign up](https://patronise.me/developers/login?signup=1) with your email. No password, no paperwork.
2. Open **Dashboard → API keys** and create a **secret test key**. It starts with `sk_test_`.
3. Keep it on your server, in an environment variable such as `PATRONISE_SECRET_KEY`. Never put a secret key in a browser or a mobile app.

Test keys never move real money. Live keys (`sk_live_`) arrive once your account is [verified](https://patronise.me/developers/docs/going-live).

## 2. Create a payment

```bash
curl https://api.patronise.me/v1/payments \
  -H "Authorization: Bearer $PATRONISE_SECRET_KEY" \
  -H "Idempotency-Key: order-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "currency": "XAF",
    "operator": "mtn_cm",
    "phone": "670000000",
    "reference": "order-1042"
  }'
```

- `amount` is an integer in the currency's smallest unit. FCFA has no decimals, so `5000` is 5 000 FCFA. FCFA amounts must be multiples of 5.
- `operator` is one of the [operator codes](https://patronise.me/developers/docs/operators). For Cameroonian numbers you can leave it out: we detect MTN or Orange from the prefix.
- `Idempotency-Key` makes the call safe to retry: the same key never creates two payments.

The answer comes at once, with `status: "pending"`:

```json
{
  "id": "pay_3hX9kQ2mVb8sLr4tN7wYc1Ze",
  "object": "payment",
  "status": "pending",
  "amount": 5000,
  "charge": 5000,
  "fee": 138,
  "net": 4862,
  "currency": "XAF",
  "operator": "mtn_cm",
  "next_action": { "type": "ussd_prompt", "ussd": "*126#" },
  "reference": "order-1042"
}
```

`next_action` tells you what the customer does next:

| `next_action.type` | What happens | What you show |
|---|---|---|
| `ussd_prompt` | A prompt pops up on the customer's phone; they type their PIN. | "Confirm the payment on your phone." |
| `redirect` | The operator needs its own page (Wave, Orange Côte d'Ivoire, cards…). | Send the customer to `next_action.url`. |

## 3. Get the result

A payment ends as `succeeded` or `failed`, usually within a minute. Learn about it in one of two ways:

- **Webhook (recommended).** Add an endpoint in **Dashboard → Webhooks**. We `POST` a signed `payment.succeeded` or `payment.failed` event to it. See [Webhooks](https://patronise.me/developers/docs/webhooks).
- **Polling.** `GET /v1/payments/{id}` every few seconds until the status is no longer `pending`.

> Only deliver the order when you see `succeeded` from a verified webhook or from `GET /v1/payments/{id}`. Never trust a redirect or a query string alone.

In test mode, the number `670000000` succeeds about five seconds later. Other [test numbers](https://patronise.me/developers/docs/testing) fail or time out on purpose.

## 4. No code? Use a hosted page or a link

- **Hosted checkout**: `POST /v1/checkout-sessions` returns a `url`. Send your customer there; they pick their operator and pay. See [Hosted checkout](https://patronise.me/developers/docs/checkout).
- **Payment links**: create one in the dashboard and share it on WhatsApp. See [Payment links](https://patronise.me/developers/docs/payment-links).

## Next steps

- [Test mode and test numbers](https://patronise.me/developers/docs/testing)
- [Verify webhooks](https://patronise.me/developers/docs/webhooks)
- [Go live](https://patronise.me/developers/docs/going-live)
- [API reference](https://patronise.me/developers/docs/api-reference)


---

# Test mode

Every account starts in test mode. Test keys (`sk_test_…`, `pk_test_…`) go through the same API, the same validation and the same webhooks as live keys, but **no provider is called and no money moves**. Test payments, balances and payouts are kept apart from live ones.

## Test phone numbers

The last four digits of the phone number decide what happens. Use any valid number for the country, for example `670000000` in Cameroon.

| Number ends with | Result | Failure code |
|---|---|---|
| anything else (e.g. `670000000`) | `succeeded` after about 5 seconds | — |
| `1111` (e.g. `670001111`) | `failed`: not enough money | `INSUFFICIENT_BALANCE` |
| `2222` (e.g. `670002222`) | `failed`: the customer declined | `PAYMENT_NOT_APPROVED` |
| `3333` (e.g. `670003333`) | stays `pending`, then `failed` after 2 minutes | `EXPIRED` |

The same rules apply to payout destinations.

## Redirect operators

Operators that use a payment page (`wave_ci`, `wave_sn`, `orange_ci`, `moov_ci`, `orange_bf`, `moov_bf`, `card`, `wallet`) return `next_action.type = "redirect"`. In test mode the `url` opens a **test operator page** with *Approve* and *Decline* buttons, then sends the customer back to your `return_url`.

## Fees in test mode

Test payments show the real fees you would pay in live mode, so you can check your numbers. Test money is available for payouts straight away (live money waits 24 hours).

## Going from test to live

Change the key from `sk_test_` to `sk_live_`. Nothing else changes. See [Going live](https://patronise.me/developers/docs/going-live).


---

# Integrate with an AI agent

Patronise Dev is written to be integrated by coding agents (Claude Code, Cursor, Copilot, Windsurf…) as well as people.

## Give your agent the docs

- [`llms.txt`](https://patronise.me/developers/llms.txt): a short index with links to every page.
- [`llms-full.txt`](https://patronise.me/developers/llms-full.txt): the whole documentation in one Markdown file. Best for agents.
- [`openapi.json`](https://patronise.me/developers/openapi.json): the OpenAPI 3.1 spec, for code generators and API tools.

## A prompt that works

Paste this into your agent, then describe your stack ("Next.js app with Prisma", "Laravel shop", "Django + HTMX"…). You can also copy it from **Dashboard → API keys**.

```text
Integrate Patronise Dev (Mobile Money payments for Central and West Africa) into this project.
Read the full documentation first: https://patronise.me/developers/llms-full.txt
- Base URL: https://api.patronise.me/v1 ; auth: Authorization: Bearer <secret key>, read from the env var
  PATRONISE_SECRET_KEY (never hard-code it, never send it to the browser).
- Create payments server-side with POST /v1/payments (or a hosted page with
  POST /v1/checkout-sessions), always with an Idempotency-Key.
- Never mark an order paid from the redirect: only from a verified webhook
  (Patronise-Signature header, HMAC-SHA256) or GET /v1/payments/:id returning "succeeded".
- Store the webhook secret in PATRONISE_WEBHOOK_SECRET.
- Test with sk_test_ keys and the test numbers from the docs before switching to live.
```

## What a good integration looks like

Check that your agent's code does these things:

1. Calls the API **from the server only**, with the secret key in an environment variable.
2. Sends an `Idempotency-Key` on every `POST`, derived from your order id.
3. Stores the payment `id` on your order.
4. Has a webhook route that **checks the signature over the raw body** before it trusts the event, and answers `2xx` fast.
5. Handles `payment.succeeded` **idempotently**: the same event can arrive twice.
6. Shows the customer "Confirm on your phone" for `ussd_prompt`, or redirects for `redirect`.


---

# Payments API

A **payment** charges one customer once. You create it from your server; the customer approves it on their phone (or on the operator's page); you learn the result by webhook or by fetching it.

## Lifecycle

```text
pending ──► succeeded
   │
   ├──────► failed
   │
   └──────► requires_review ──► succeeded | failed
```

- `pending`: waiting for the customer or the operator.
- `succeeded`: money received. The merchant's share is added to your balance.
- `failed`: nothing was charged. `failure.code` says why.
- `requires_review`: rare. The provider's answer did not add up (amount mismatch, late confirmation, a provider with no status API). Our team checks with the provider and closes it as `succeeded` or `failed`; you get the matching webhook.

## Create a payment

`POST /v1/payments`

| Field | Type | Required | Description |
|---|---|---|---|
| `amount` | integer | yes | Smallest currency unit. FCFA amounts are whole francs, multiples of 5. |
| `currency` | string | yes | `XAF`, `XOF` or `CDF`. XAF and XOF are both FCFA and accepted by either zone's operators. |
| `operator` | string | yes* | e.g. `mtn_cm`. *Optional for Cameroonian numbers. See [operators](https://patronise.me/developers/docs/operators). |
| `phone` | string | for Mobile Money | Any format: `670000000`, `+237 6 70 00 00 00`, `237670000000`. |
| `reference` | string | no | Your order id. Searchable, and sent back in webhooks. |
| `description` | string | no | Shown on some operators' prompts. |
| `fee_bearer` | string | no | `merchant` (default: the fee is taken from the amount) or `customer` (the fee is added to what the customer pays). |
| `customer_email`, `customer_name` | string | no | For your records. |
| `return_url` | string | no | Where redirect operators send the customer afterwards. |
| `metadata` | object | no | Up to 20 string keys, sent back as is. |

### Amounts and fees

- `amount`: what you asked for.
- `charge`: what the customer pays.
- `fee`: what Patronise Dev keeps (operator fee + 1%).
- `net`: what lands in your balance.

With `fee_bearer: "merchant"`, `charge = amount` and `net = amount − fee`. With `fee_bearer: "customer"`, the charge is raised so that `net = amount`. Use `GET /v1/fees/quote` to show the total before you create the payment.

## Retrieve a payment

`GET /v1/payments/{id}` returns the payment. If it is still `pending`, we check with the provider before answering, so polling every 3–5 seconds is fine.

## List payments

`GET /v1/payments?status=succeeded&reference=order-1042&limit=20&starting_after=pay_…`

Results are newest first. When `has_more` is `true`, pass the last `id` as `starting_after` to get the next page.

## Idempotency

Send `Idempotency-Key: <unique string>` on every `POST`. If the network fails, send the exact same request with the same key: you get the first answer back (with the header `Idempotent-Replayed: true`) and no second payment is created. Reusing a key with a different body returns `409 idempotency_key_reused`. Keys are kept 24 hours.

## Failure codes

| Code | Meaning |
|---|---|
| `INSUFFICIENT_BALANCE` | Not enough money in the customer's wallet. |
| `PAYMENT_NOT_APPROVED` | The customer declined or did not enter their PIN. |
| `PAYER_NOT_FOUND` | The number has no Mobile Money account with that operator. |
| `PAYER_LIMIT_REACHED` | The customer's wallet limit is reached. |
| `EXPIRED` / `expired` | The customer never confirmed. |
| `provider_unavailable` | No provider could take the payment. Retry later. |
| `provider_rejected` | The operator refused the request. |


---

# Hosted checkout

A **checkout session** is a payment page we host for you. The customer chooses their country and operator, types their number, confirms, and comes back to your site. You write no front end.

## 1. Create a session (server side)

```bash
curl https://api.patronise.me/v1/checkout-sessions \
  -H "Authorization: Bearer $PATRONISE_SECRET_KEY" \
  -H "Idempotency-Key: order-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 15000,
    "currency": "XAF",
    "description": "Order #1042",
    "reference": "order-1042",
    "success_url": "https://shop.example.com/orders/1042/thanks",
    "cancel_url": "https://shop.example.com/cart",
    "metadata": { "order_id": "1042" }
  }'
```

| Field | Description |
|---|---|
| `amount`, `currency` | Required. |
| `description` | Shown at the top of the page. |
| `reference`, `metadata` | Copied onto the payment. |
| `success_url` | Where the customer goes after paying. We add `?payment=pay_…`. |
| `cancel_url` | The "Cancel" link. |
| `fee_bearer` | `merchant` or `customer`. |
| `countries` | Limit the page to some countries, e.g. `["CM", "GA"]`. |
| `customer_email` | Pre-filled. |
| `expires_in_minutes` | 10 to 1440. Default 60. |

The response contains a `url`. Redirect the customer to it.

A **publishable key** (`pk_…`) may also create checkout sessions, so a static site can call this endpoint from the browser. It cannot read payments or move money.

## 2. Confirm the payment

When the customer pays, you receive `payment.succeeded` and `checkout_session.completed`. The `checkout_session` field of the payment holds the session id.

> The redirect to `success_url` is not proof of payment: anyone can open that URL. Fulfil the order from the webhook, or check `GET /v1/payments/{id}` before you do.

## Session states

`open` → `complete` once a payment succeeds, or `expired` when `expires_at` passes. A session takes only one successful payment.


---

# Payment links

A **payment link** is a reusable page for selling without a website: share it on WhatsApp, Instagram, Facebook or by SMS. Many customers can pay the same link.

## From the dashboard

**Dashboard → Payment links → New link.** Give it a title and an amount, or let the customer choose the amount (donations, tips). Copy the link or share it straight to WhatsApp. Each link shows how many payments it received and the total.

## From the API

```bash
curl https://api.patronise.me/v1/payment-links \
  -H "Authorization: Bearer $PATRONISE_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Wax dress, size M", "amount": 12500, "currency": "XAF" }'
```

Leave out `amount` for a link where the customer types the amount. Turn a link off with `PATCH /v1/payment-links/{id}` and `{ "active": false }`.

Payments made through a link carry its id in `payment_link`, and send the usual `payment.succeeded` webhook.


---

# Countries and operators

Pass one of these codes as `operator`. The live list, with each operator's current fee, is at `GET /v1/operators` (and on the [pricing page](https://patronise.me/developers/pricing)).

| Country | Operator | Code | Currency | Flow | Payouts |
|---|---|---|---|---|---|
| Cameroon | MTN MoMo | `mtn_cm` | XAF | prompt | yes |
| Cameroon | Orange Money | `orange_cm` | XAF | prompt | yes |
| Benin | MTN MoMo | `mtn_bj` | XOF | prompt | yes |
| Benin | Moov Money | `moov_bj` | XOF | prompt | yes |
| Congo | MTN MoMo | `mtn_cg` | XAF | prompt | yes |
| Congo | Airtel Money | `airtel_cg` | XAF | prompt | yes |
| Gabon | Airtel Money | `airtel_ga` | XAF | prompt | yes |
| Senegal | Orange Money | `orange_sn` | XOF | prompt | yes |
| Senegal | Yas (Free Money) | `free_sn` | XOF | prompt | yes |
| Senegal | Wave | `wave_sn` | XOF | redirect | — |
| Côte d'Ivoire | Orange Money | `orange_ci` | XOF | redirect | — |
| Côte d'Ivoire | MTN MoMo | `mtn_ci` | XOF | prompt | — |
| Côte d'Ivoire | Wave | `wave_ci` | XOF | redirect | — |
| Côte d'Ivoire | Moov Money | `moov_ci` | XOF | redirect | — |
| Burkina Faso | Orange Money | `orange_bf` | XOF | redirect | — |
| Burkina Faso | Moov Money | `moov_bf` | XOF | redirect | — |
| Togo | Yas (Togocom) | `togocom_tg` | XOF | prompt | yes |
| Togo | Moov Money | `moov_tg` | XOF | prompt | yes |
| DR Congo | M-Pesa | `mpesa_cd` | CDF | prompt | yes |
| International | Bank card | `card` | XAF | redirect | — |
| International | PayPal, Google Pay, Revolut… | `wallet` | XAF | redirect | — |

- **prompt**: the customer confirms on their phone (`next_action.type = "ussd_prompt"`).
- **redirect**: the customer pays on the operator's page (`next_action.type = "redirect"`).
- XAF and XOF are the same value (FCFA). An XAF amount can be charged on a XOF operator and vice versa.

## Finding the operator from a number

```bash
curl https://api.patronise.me/v1/operators/predict \
  -H "Authorization: Bearer $PATRONISE_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+229 97 00 00 00" }'
# → { "operator": "mtn_bj", "country": "BJ", "phone": "22997000000" }
```

For Cameroon, `POST /v1/payments` works out MTN or Orange from the prefix if you leave `operator` out.


---

# Webhooks

A webhook is an HTTPS `POST` we send to your server when something happens: a payment succeeds, a payout is paid… Add endpoints in **Dashboard → Webhooks** (test and live have separate endpoints).

## Events

| Type | When |
|---|---|
| `payment.succeeded` | The money was received. Fulfil the order. |
| `payment.failed` | The payment did not go through. |
| `payment.requires_review` | We are checking the payment with the provider. Wait. |
| `checkout_session.completed` | A hosted checkout page was paid. |
| `payout.paid` | A payout reached the phone. |
| `payout.failed` | A payout failed; the amount is back in your balance. |
| `ping` | Sent by the "Send test" button. |

## Payload

```json
{
  "id": "evt_8Qm2pXc4Lw0sVb7nTr5yKa9d",
  "object": "event",
  "type": "payment.succeeded",
  "livemode": false,
  "created_at": "2026-11-02T10:15:04.112Z",
  "data": {
    "object": { "id": "pay_3hX9…", "object": "payment", "status": "succeeded", "amount": 5000, "net": 4862, "reference": "order-1042", "…": "…" }
  }
}
```

`data.object` is the same object as `GET /v1/payments/{id}` returns.

## Verifying the signature

Every request carries:

```text
Patronise-Signature: t=1767312000,v1=5f2c9a…e91a
Patronise-Event-Id: evt_8Qm2pXc4Lw0sVb7nTr5yKa9d
```

`v1` is the hex HMAC-SHA256 of `"<t>.<raw request body>"`, keyed with your endpoint's signing secret (`whsec_…`). To verify:

1. Read the **raw body** before any JSON parsing.
2. Split the header; take `t` and `v1`.
3. Reject if `t` is more than 5 minutes away from now.
4. Compute `HMAC_SHA256(secret, t + "." + rawBody)` and compare it to `v1` in constant time.

### Node.js (Express)

```js
import crypto from 'node:crypto'
import express from 'express'

const app = express()

app.post('/webhooks/patronise', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Patronise-Signature') || ''
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto
    .createHmac('sha256', process.env.PATRONISE_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  const valid = fresh && parts.v1 && expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  if (!valid) return res.status(400).send('bad signature')

  const event = JSON.parse(req.body)
  if (event.type === 'payment.succeeded') {
    // markOrderPaid(event.data.object.reference) — make it idempotent
  }
  res.sendStatus(200)
})
```

### PHP

```php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_PATRONISE_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('PATRONISE_WEBHOOK_SECRET'));
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
    http_response_code(400);
    exit('bad signature');
}
$event = json_decode($raw, true);
// if ($event['type'] === 'payment.succeeded') { … }
http_response_code(200);
```

### Python (Flask)

```python
import hmac, hashlib, os, time
from flask import request, abort

@app.post("/webhooks/patronise")
def patronise_webhook():
    raw = request.get_data()
    sig = dict(p.split("=", 1) for p in request.headers.get("Patronise-Signature", "").split(","))
    expected = hmac.new(os.environ["PATRONISE_WEBHOOK_SECRET"].encode(),
                        f"{sig.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
    if abs(time.time() - int(sig.get("t", 0))) > 300 or not hmac.compare_digest(expected, sig.get("v1", "")):
        abort(400)
    event = request.get_json()
    return "", 200
```

## Delivery rules

- Answer with any `2xx` within 10 seconds. Do slow work after answering.
- If you answer anything else, or time out, we retry with growing delays (30 s, 1 min, 2 min… up to 6 h between tries) for about 24 hours.
- The same event can arrive more than once, and events can arrive out of order. Use `id` to skip duplicates, and trust the `status` in `data.object`.
- Every delivery is listed in **Dashboard → Webhooks → History**, with the HTTP code your server returned and a **Resend** button.
- To test locally, expose your machine with `cloudflared tunnel --url http://localhost:3000` or `ngrok http 3000`.


---

# Payouts

A **payout** sends money from your **available balance** to a Mobile Money number: yours, a supplier's, a seller's on your marketplace.

## Balance

`GET /v1/balance` returns, per currency:

- `available`: can be paid out now.
- `pending`: successful payments that are still settling. Live payments become available **24 hours** after they succeed. Test payments are available at once.

## Create a payout

```bash
curl https://api.patronise.me/v1/payouts \
  -H "Authorization: Bearer $PATRONISE_SECRET_KEY" \
  -H "Idempotency-Key: payout-2026-11-02" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50000,
    "currency": "XAF",
    "destination": { "operator": "mtn_cm", "phone": "670000000", "account_name": "Awa Ngono" },
    "reference": "weekly-settlement"
  }'
```

- `amount` leaves your balance. The payout fee comes out of it; `sent` is what reaches the phone.
- Payouts start at 500.
- Live payouts need a [verified account](https://patronise.me/developers/docs/going-live).
- Large payouts (above 250 000 FCFA) wait for a quick manual approval (`awaiting_approval`), usually within the hour.

## Statuses

`awaiting_approval` → `pending` → `paid`, or `failed` / `cancelled`. When a payout fails or is cancelled, the amount goes back to your available balance and you get `payout.failed`.

Payouts are currently available to: MTN and Orange Cameroon, MTN and Moov Benin, MTN and Airtel Congo, Airtel Gabon, Orange and Yas Senegal, Yas and Moov Togo, M-Pesa DR Congo.


---

# Going live

Test mode is open to everyone. Live mode (real money) opens once we know who you are and what you sell. It usually takes less than a day.

## 1. Verify your identity (3 minutes)

**Dashboard → Verification → Step 1.** You photograph your national ID card (CNI), passport or residence permit, then take a short video selfie. The check is done by [Didit](https://didit.me), our identity provider; Patronise Dev keeps only the result (approved or not, and the name on the document), never the images.

## 2. Verify your business

**Dashboard → Verification → Step 2.** Fill in your business name, RCCM and NIU (or your country's equivalents), what you sell, and upload the documents (PDF, JPG or PNG). A sole trader can upload a business licence (patente) or proof of activity. Our team reviews it within one business day.

## 3. Switch to live

When both are approved:

1. Create a **live secret key** in **Dashboard → API keys** (`sk_live_…`).
2. Add your **live webhook endpoint** in **Dashboard → Webhooks** (switch the dashboard to *Live*). It has its own signing secret.
3. Replace the test key and webhook secret in your server's environment variables.

Your code stays the same.

## Checklist before launch

- [ ] Secret key only on the server, from an environment variable.
- [ ] `Idempotency-Key` on every `POST`.
- [ ] Webhook signature checked over the raw body.
- [ ] Orders fulfilled from `payment.succeeded` (or `GET /v1/payments/{id}`), never from the redirect.
- [ ] Duplicate webhooks handled (same event `id` twice).
- [ ] A clear "Confirm on your phone" screen for `ussd_prompt` payments.


---

# API reference

**Base URL:** `https://api.patronise.me/v1` · **Auth:** `Authorization: Bearer <key>` · **Format:** JSON in, JSON out · **Spec:** [`openapi.json`](https://patronise.me/developers/openapi.json)

The key decides the mode: `sk_test_…` sees only test objects, `sk_live_…` only live ones. Secret keys (`sk_`) can call everything; publishable keys (`pk_`) can only create checkout sessions and read fees, operators and their own checkout payments.

## Endpoints

| Method | Path | Description |
|---|---|---|
| `POST` | `/v1/payments` | [Create a payment](https://patronise.me/developers/docs/payments#create-a-payment) |
| `GET` | `/v1/payments` | List payments (`status`, `reference`, `limit`, `starting_after`) |
| `GET` | `/v1/payments/{id}` | Retrieve a payment |
| `POST` | `/v1/checkout-sessions` | [Create a hosted checkout page](https://patronise.me/developers/docs/checkout) |
| `GET` | `/v1/checkout-sessions/{id}` | Retrieve a checkout session |
| `POST` | `/v1/payment-links` | [Create a payment link](https://patronise.me/developers/docs/payment-links) |
| `GET` | `/v1/payment-links` | List payment links |
| `GET` | `/v1/payment-links/{id}` | Retrieve a payment link |
| `PATCH` | `/v1/payment-links/{id}` | Turn a link on or off (`active`) |
| `POST` | `/v1/payouts` | [Send a payout](https://patronise.me/developers/docs/payouts) |
| `GET` | `/v1/payouts` | List payouts |
| `GET` | `/v1/payouts/{id}` | Retrieve a payout |
| `GET` | `/v1/balance` | Available and pending balance per currency |
| `GET` | `/v1/fees/quote` | Fee for an amount and operator (`amount`, `currency`, `operator`, `fee_bearer`, `direction`) |
| `GET` | `/v1/operators` | Payment methods and fees (`country`, `currency`) |
| `POST` | `/v1/operators/predict` | Find the operator of a phone number |
| `GET` | `/v1/events` | Recent events (`type`) |
| `GET` | `/v1/events/{id}` | Retrieve an event |

## Conventions

- **Amounts** are integers in the currency's smallest unit. FCFA (`XAF`, `XOF`) and `CDF` have no decimals.
- **Dates** are ISO 8601 strings in UTC.
- **IDs** have a prefix: `pay_` payment, `cs_` checkout session, `plink_` payment link, `po_` payout, `evt_` event.
- **Lists** return `{ "object": "list", "data": [...], "has_more": bool }`, newest first. Paginate with `limit` (1–100, default 20) and `starting_after=<last id>`.
- **Idempotency**: send `Idempotency-Key` on every `POST`. Replays return the first response with `Idempotent-Replayed: true`. Keys last 24 hours.
- **Rate limit**: 120 requests per minute per key (`429` above).
- **Errors**: see [Errors](https://patronise.me/developers/docs/errors).

## The payment object

```json
{
  "id": "pay_3hX9kQ2mVb8sLr4tN7wYc1Ze",
  "object": "payment",
  "livemode": false,
  "status": "succeeded",
  "amount": 5000,
  "charge": 5000,
  "fee": 138,
  "net": 4862,
  "fee_bearer": "merchant",
  "currency": "XAF",
  "country": "CM",
  "operator": "mtn_cm",
  "phone": "237670000000",
  "reference": "order-1042",
  "description": "Sneakers",
  "customer": { "email": null, "name": null },
  "metadata": { "cart": "42" },
  "next_action": null,
  "failure": null,
  "checkout_session": null,
  "payment_link": null,
  "created_at": "2026-11-02T10:15:00.000Z",
  "completed_at": "2026-11-02T10:15:05.000Z"
}
```

## The payout object

```json
{
  "id": "po_8sK2mX…",
  "object": "payout",
  "livemode": true,
  "status": "paid",
  "amount": 50000,
  "fee": 1150,
  "sent": 48850,
  "currency": "XAF",
  "destination": { "country": "CM", "operator": "mtn_cm", "phone": "237670000000", "account_name": "Awa Ngono" },
  "reference": "weekly-settlement",
  "failure": null,
  "created_at": "2026-11-02T10:15:00.000Z",
  "completed_at": "2026-11-02T10:16:12.000Z"
}
```

## The checkout session object

```json
{
  "id": "cs_gJandwH0lBjIBhrxdtzZ2p7L",
  "object": "checkout_session",
  "livemode": false,
  "status": "open",
  "url": "https://patronise.me/developers/pay/cs_gJandwH0lBjIBhrxdtzZ2p7L",
  "amount": 7500,
  "currency": "XAF",
  "description": "Order #77",
  "reference": null,
  "fee_bearer": "merchant",
  "customer_email": null,
  "metadata": {},
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": null,
  "payment": null,
  "expires_at": "2026-11-02T11:30:20.819Z",
  "created_at": "2026-11-02T10:30:20.819Z"
}
```


---

# Errors

Every error has the same shape and an HTTP status that tells you what kind it is:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "phone_invalid",
    "message": "`phone` is not a valid CM mobile number.",
    "param": "phone"
  }
}
```

| HTTP | `type` | What to do |
|---|---|---|
| 400 | `invalid_request_error` | Fix the request. `param` names the field. |
| 401 | `authentication_error` | Missing, wrong or revoked key. |
| 403 | `permission_error` | Wrong kind of key (`pk_` where `sk_` is needed), live mode not enabled, or account suspended. |
| 404 | `not_found_error` | No such object, or it belongs to the other mode (test/live). |
| 409 | `idempotency_error` | Idempotency key reused with another body, or still in use. |
| 415 | `invalid_request_error` | Send `Content-Type: application/json`. |
| 429 | `rate_limit_error` | More than 120 requests a minute on one key. Back off and retry. |
| 500 | `api_error` | Our fault. Retry with the same `Idempotency-Key`. |

## Common codes

| `code` | Meaning |
|---|---|
| `parameter_missing` / `parameter_invalid` | See `param`. |
| `amount_invalid` | FCFA amounts must be multiples of 5. |
| `amount_out_of_range` | Below the operator's minimum or above its maximum. |
| `amount_too_small` | The amount does not cover the fees. |
| `currency_not_supported` | The operator does not take that currency. |
| `operator_required` | Pass `operator` (only Cameroonian numbers are detected automatically). |
| `operator_not_supported` / `operator_unavailable` | Unknown operator, or temporarily switched off. |
| `phone_invalid` | Not a valid number for the operator's country. |
| `balance_insufficient` | Your available balance does not cover the payout. |
| `live_not_enabled` | Finish [verification](https://patronise.me/developers/docs/going-live) first. |
| `idempotency_key_reused` | Same key, different body. Use a new key. |

Payment failures (the customer declined, no money…) are not API errors: the payment is created and ends as `failed` with a `failure.code`. See [Payments](https://patronise.me/developers/docs/payments#failure-codes).
