# Kratos Pay — Merchant integration guide

Step-by-step journeys, routes, request bodies and possible responses (API v1, November 11, 2026 edition).

- Production : `https://api.kratospay.com/api/v1`
- Staging : `https://dev-aggregator-api.kratospay.com/api/v1`

## Contents

- **Introduction**
  - Overview
  - Common conventions
- **Individual merchant (developer)**
  - 1. Create an account
  - 2. Sign in (WhatsApp)
  - 3. Application and webhook
  - 4. Generate an API key
  - 5. Collect a payment
  - 6. Track and cancel
  - 7. Payment links
  - 8. Balance and withdrawals
  - 9. Refund
  - Rights and limits
- **Business merchant**
  - 1. Create the account
  - 2. Sign in (TOTP)
  - 3. Verify the account (KYC)
  - 4. Create an application
  - 5. Generate API keys
  - 6. Collect and send
  - 7. Payment links
  - 8. Balance and withdrawals
  - 9. Mass payments
  - 10. Team and other modules
- **GIMAC QR collections**
  - Merchant code and QR points
- **Appendices**
  - Webhooks
  - Transaction statuses
  - Common errors

## Introduction

### Overview

Kratos Pay API is the payment gateway of Kratos Financials Inc. It lets any application — online store, SaaS platform, mobile app — collect and send Mobile Money payments, accept bank cards and GIMAC QR payments, and track transactions through webhooks.

#### What the API lets you do

- Collect through Mobile Money (`collection`), bank card via a hosted checkout page, or GIMAC QR.
- Send funds to a Mobile Money wallet from your balance (`disbursement`), withdraw your money, schedule withdrawals or pay several beneficiaries at once.
- Collect without code thanks to payment links and QR codes.
- Be notified in real time of every status change through signed webhooks.
- Test the whole journey without real money using an `sk_test_…` key.

#### Key vocabulary

- **Application**: an integration (site, app, terminal) with its own keys, webhook and balance. Its id is sent in `X-Application-ID`.
- **API key**: `sk_test_…` (test, no real money) or `sk_live_…` (real payments). It stays on your server.
- **Transaction**: a money movement identified by a `uuid`, in one of the statuses `pending`, `success`, `failed`, `expired`, `refunded`…
- **Webhook**: an HTTP POST notification sent by Kratos Pay to your server every time a transaction's status changes.
- **Idempotency key**: a unique identifier you generate for each operation, so the same transaction is never created twice when a request is retried.

#### Two merchant profiles

|   | Individual merchant (developer) | Business merchant |
| --- | --- | --- |
| Who | A person collecting for themselves and integrating the API (site, app, bot). | A company collecting for its business. |
| Sign-in | Phone + WhatsApp code | Email + password + mandatory TOTP |
| KYC | Not required to collect live | Required before any real payment |
| Applications | One personal application, created automatically | Several applications (website, mobile app, terminal…) |
| Reserved features | — | Team, scheduled withdrawals, mass payments |

- **Individual:** create the account → sign in → get your application → set up the webhook → generate an API key → collect → track → withdraw your money.
- **Business:** create the account → enable TOTP → submit KYC → create an application → API key → collect → payment links → balance and withdrawals → team.

### Common conventions

This chapter applies to every route of the individual and business journeys. You only need to read it once.

#### API addresses

| Environment | Base URL |
| --- | --- |
| Production | https://api.kratospay.com/api/v1 |
| Staging | https://dev-aggregator-api.kratospay.com/api/v1 |

Every path in this documentation is relative to this base URL (for example, `POST /transactions` becomes `POST https://api.kratospay.com/api/v1/transactions`). Bodies are JSON (`Content-Type: application/json`, `Accept: application/json`).

#### Two kinds of authentication

| Type | Used for | How to send it |
| --- | --- | --- |
| Session token (dashboard) | Managing your account: profile, applications, API keys, KYC, payment links, balance, withdrawals. | `Authorization: Bearer {token}`, obtained when signing in. |
| API key (`sk_test_…` / `sk_live_…`) | Integrating payments on your server: create, track, cancel or refund a transaction, and manage GIMAC QR points. | `Authorization: Bearer sk_live_…` (or the `X-Api-Key` header), plus `X-Application-ID: {your application id}`. |

> The API key must **never** be placed in a mobile app or a browser: it can collect and refund. Keep it on your server.

- `sk_test_…`: test environment, simulated networks, **no real money**. `sk_live_…`: real payments.
- The key is shown only once, when it is created. If lost, revoke it and generate a new one.

#### Response format

Success (HTTP 2xx) :

```json
{
  "success": true,
  "data": {},
  "meta": {
    "request_id": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": "2026-11-02T10:30:00Z"
  }
}
```

Failure (HTTP 4xx/5xx) :

```json
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Solde insuffisant.",
    "details": {
      "amount": [
        "Le montant dépasse le solde disponible."
      ]
    }
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

`error.details` is only present for validation errors (422): one entry per invalid field, each with its list of messages. Give the `meta.request_id` to support if something goes wrong.

#### HTTP codes

| HTTP | Meaning | What to do |
| --- | --- | --- |
| 200 / 201 / 202 | Success (201 = resource created, 202 = processing accepted, in progress). | — |
| 401 | Token or API key missing, invalid, expired or revoked. | Sign in again / check the key and its environment. |
| 403 | Restricted action (e.g. business-only feature, missing team permission). | Check the account type and permissions. |
| 404 | Resource not found or owned by another account (never 403 in that case). | Check the identifier. |
| 422 | Invalid data or business rule refused (balance, KYC, network unavailable…). | Read `error.code` and `error.details`. |
| 429 | Too many requests. | Retry after a short wait. |
| 5xx | Incident on Kratos Pay's side or the payment network. | Retry; **never** create the same operation twice without an idempotency key. |

#### Payment networks and numbers

| `provider` value | Network | Collection | Send / withdrawal | Refund |
| --- | --- | --- | --- | --- |
| `orange_money_cm` | Orange Money Cameroon | Yes | Yes | Yes |
| `mtn_momo_cm` | MTN Mobile Money Cameroon | Yes | Yes | No |
| `mastercard` | Mastercard and Visa cards (secure checkout page) | Yes | No | No |
| `gimac` | GIMAC QR (scan-to-pay) | Yes | No | No |

- The up-to-date list of accepted values is returned by `GET /payment-providers` (session token). Copy the `selector` value as is. For both Mastercard and Visa, keep the `provider` field set to `mastercard`.
- For Mobile Money, send the number without a country code (`696246686`) and matching the provider's country; bank card payments do not require a phone number.
- Amounts: send a number (`5000`). Responses return amounts as strings with two decimals (`"5000.00"`). Default currency: `XAF`.
- Mobile Money and card payments are **asynchronous** (`pending`); wait for the webhook or fetch the transaction. A received GIMAC QR payment creates a `success` transaction immediately.

#### Idempotency

On every money-creating call, send an `idempotency_key` unique per business operation (e.g. order number). If your call times out and you resend it with the same key, you get the transaction already created instead of a second one. A key already used by another operation is refused with 422.

## Individual merchant (developer)

### 1. Create an account

The individual merchant collects for themselves and integrates the API (site, app, bot). The account comes with a personal application and a permanent payment link, with no extra step. Sign-in uses a phone number and a WhatsApp code (no password at sign-in, no TOTP).

#### Create your account

**POST** `/auth/register` — Public

Creates the individual account. No API key is generated at this step. The phone number is used to sign in: fill it in (email is optional).

Request body :

```json
{
  "name": "Nom Prénom",
  "merchant_type": "individual",
  "phone": "696246686",
  "email": "contact@exemple.cm",
  "password": "<mot-de-passe>",
  "password_confirmation": "<mot-de-passe>",
  "country_id": 1
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "id": 1,
    "name": "Nom Prénom",
    "email": "contact@exemple.cm",
    "merchant_type": "individual",
    "kyc_status": "none",
    "is_active": true,
    "personal_payment_link": {
      "...": "..."
    },
    "created_at": "2026-11-02T08:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

#### Possible error responses

| Code | When |
| --- | --- |
| 422 | Email or phone already used, password shorter than 8 characters, confirmation mismatch, missing required field. |

- Required fields: `name`, `merchant_type`, `password`, `password_confirmation`.
- Country ids (`country_id`) are listed by `GET /countries`.
- To withdraw by bank transfer, also provide `iban` and `bank_account_holder` during registration.

### 2. Sign in (WhatsApp)

Individual sign-in takes two calls: the phone number, then the 6-digit code received on WhatsApp.

#### Request the WhatsApp code

**POST** `/auth/login/phone` — Public

Sends a 6-digit code by WhatsApp to the account's number. Individual accounts only.

Request body :

```json
{
  "phone": "696246686"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "requires_whatsapp_otp": true,
    "challenge_token": "550e8400-e29b-41d4-a716-446655440000"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Unknown number, business account (use email sign-in), disabled account, or WhatsApp delivery failed. |

#### Validate the code and get the token

**POST** `/auth/whatsapp-otp/challenge` — Public

Validates the code received. The `challenge_token` is single-use and expires after 5 minutes.

Request body :

```json
{
  "challenge_token": "550e8400-e29b-41d4-a716-446655440000",
  "code": "123456"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "merchant": {
      "id": 1,
      "name": "Nom Prénom",
      "merchant_type": "individual",
      "kyc_status": "none"
    },
    "token": "<jeton-de-session>",
    "token_type": "Bearer"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Invalid code, expired or already used challenge: restart the previous step. |

- Keep `token`: it is the session token for the next steps (`Authorization: Bearer {token}`).
- Sign out: `POST /auth/logout`.

### 3. Application and webhook

The individual merchant has a personal application. Its id must be sent in the `X-Application-ID` header of every API-key call. Then configure the address that will receive webhooks.

#### Get your personal application

**GET** `/applications` — Session token

Response 200 :

```json
{
  "success": true,
  "data": {
    "items": [
      {
        "id": 1,
        "name": "Nom Prénom",
        "webhook_url": null,
        "has_webhook": false,
        "is_active": true,
        "created_at": "2026-11-02T08:00:00Z"
      }
    ],
    "pagination": {
      "...": "..."
    }
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 401 | Missing or expired token. |

#### Configure the webhook (recommended)

**PATCH** `/applications/{id}` — Session token

Sets the HTTPS address of your server that will receive status-change notifications (see the Webhooks appendix).

Request body :

```json
{
  "webhook_url": "https://mon-site.cm/webhooks/kratospay"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "id": 1,
    "webhook_url": "https://mon-site.cm/webhooks/kratospay",
    "has_webhook": true,
    "updated_at": "..."
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 404 | Application not found. |
| 422 | Invalid URL. |

#### Get the webhook signing secret

**POST** `/applications/{id}/webhook/rotate-secret` — Session token

Generates a signing secret, shown **only once**. It is used to check that each webhook really comes from Kratos Pay. Calling it again immediately invalidates the previous secret.

Response 200 :

```json
{
  "success": true,
  "data": {
    "application": {
      "id": 1,
      "webhook_url": "https://mon-site.cm/webhooks/kratospay",
      "has_webhook": true
    },
    "webhook_secret": "whsec_<secret>"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 404 | Application not found. |

### 4. Generate an API key

The API key is used to call payment routes from your server. Start with a `test` key, then create a `live` key once your integration is validated.

#### Generate an API key

**POST** `/auth/keys` — Session token

Request body :

```json
{
  "name": "Mon serveur",
  "environment": "test",
  "expires_at": null
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "key": "sk_test_<votre-cle>",
    "meta": {
      "id": 1,
      "name": "Mon serveur",
      "environment": "test",
      "is_active": true,
      "last_used_at": null,
      "expires_at": null,
      "created_at": "2026-11-02T08:00:00Z"
    }
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 401 | Missing or expired session token. |
| 422 | `environment` other than `test` or `live`, missing name. |

> **The `key` value is only shown here.** Copy it immediately.

- List your keys: `GET /auth/keys`. Revoke: `DELETE /auth/keys/{id}`.

### 5. Collect a payment

Requests a payment from a customer: they receive a confirmation request on their phone (Mobile Money PIN). Headers: `Authorization: Bearer sk_…` and `X-Application-ID: 1`.

#### Collect from a customer

**POST** `/transactions` — API key + X-Application-ID

Request body :

```json
{
  "type": "collection",
  "provider": "orange_money_cm",
  "amount": 5000,
  "currency": "XAF",
  "phone_number": "696246686",
  "idempotency_key": "commande-1234",
  "metadata": {
    "description": "Commande 1234",
    "customer_name": "Nom du client"
  }
}
```

Call examples (staging) :

cURL :

```bash
curl -X POST 'https://dev-aggregator-api.kratospay.com/api/v1/transactions' \
  -H 'Authorization: Bearer sk_test_VOTRE_CLE' \
  -H 'X-Application-ID: 1' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{"type":"collection","provider":"orange_money_cm","amount":5000,"currency":"XAF","phone_number":"696246686","idempotency_key":"commande-1234","metadata":{"description":"Commande 1234","customer_name":"Nom du client"}}'
```

JavaScript :

```javascript
// Côté serveur uniquement (Node.js) : la clé API ne doit jamais aller dans le navigateur.
const response = await fetch('https://dev-aggregator-api.kratospay.com/api/v1/transactions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.KRATOSPAY_API_KEY}`,
    'X-Application-ID': '1',
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({
    "type": "collection",
    "provider": "orange_money_cm",
    "amount": 5000,
    "currency": "XAF",
    "phone_number": "696246686",
    "idempotency_key": "commande-1234",
    "metadata": {
      "description": "Commande 1234",
      "customer_name": "Nom du client"
    }
  }),
});

const result = await response.json();
if (!result.success) console.error(result.error.code, result.error.message);
```

PHP :

```php
$ch = curl_init('https://dev-aggregator-api.kratospay.com/api/v1/transactions');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . getenv('KRATOSPAY_API_KEY'),
    'X-Application-ID: 1',
    'Content-Type: application/json',
    'Accept: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'type' => 'collection',
    'provider' => 'orange_money_cm',
    'amount' => 5000,
    'currency' => 'XAF',
    'phone_number' => '696246686',
    'idempotency_key' => 'commande-1234',
    'metadata' => ['description' => 'Commande 1234', 'customer_name' => 'Nom du client'],
  ]),
  CURLOPT_RETURNTRANSFER => true,
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
```

Python :

```python
import os
import requests

response = requests.post(
    "https://dev-aggregator-api.kratospay.com/api/v1/transactions",
    headers={
        "Authorization": f"Bearer {os.environ['KRATOSPAY_API_KEY']}",
        "X-Application-ID": "1",
        "Accept": "application/json",
    },
    json={
      "type": "collection",
      "provider": "orange_money_cm",
      "amount": 5000,
      "currency": "XAF",
      "phone_number": "696246686",
      "idempotency_key": "commande-1234",
      "metadata": {
        "description": "Commande 1234",
        "customer_name": "Nom du client"
      }
    },
)
result = response.json()
print(response.status_code, result)
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b3",
    "type": "collection",
    "provider": "orange_money",
    "status": "pending",
    "amount": "5000.00",
    "currency": "XAF",
    "reference": "MP240101.1234.A56789",
    "external_id": null,
    "phone_number": "696246686",
    "checkout_url": null,
    "metadata": {
      "description": "Commande 1234",
      "customer_name": "Nom du client"
    },
    "expires_at": null,
    "created_at": "2026-11-02T08:00:00Z",
    "updated_at": "2026-11-02T08:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

#### Collect by bank card

**POST** `/transactions` — API key + X-Application-ID

For both Mastercard and Visa, use `provider: "mastercard"`. Both networks use this same flow: do not send a phone number or card details; the response provides `checkout_url`, which you must open so the customer can pay on the secure hosted page. `return_url` is optional.

Request body :

```json
{
  "type": "collection",
  "provider": "mastercard",
  "amount": 25000,
  "currency": "XAF",
  "idempotency_key": "commande-5678",
  "return_url": "https://monsite.com/merci",
  "metadata": {
    "description": "Commande 5678"
  }
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b4",
    "type": "collection",
    "provider": "mastercard",
    "status": "pending",
    "amount": "25000.00",
    "currency": "XAF",
    "reference": "K7H2M9QXP4R8T1W5Z3NB",
    "phone_number": null,
    "checkout_url": "https://master.kratos-travel.com/?sessionId=SESSION0002..."
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Invalid amount or currency, card payments not enabled for the account, or payment gateway temporarily unavailable. |

- After payment, the customer returns to `return_url`. Never fulfill an order based only on this redirect: wait for `transaction.success` or check `GET /transactions/{uuid}`. Possible statuses are `pending`, `success`, `failed` and `expired`.
- Cards do not support payouts or API refunds at this time.

#### Possible error responses

| Code | When |
| --- | --- |
| 401 | Invalid, inactive or expired API key, or key from another environment. |
| 422 | Invalid amount, network unavailable or disabled, number not matching the network's country, `idempotency_key` already used by another operation, or payment not initiated (retry in a moment). |

- Required fields: `type`, `provider`, `amount`, and `phone_number` for a collection. Other types: `disbursement` (send), `airtime`, `tv`, `internet`.
- The initial status is **pending**: it is not a failure. Wait for the webhook or fetch the transaction.
- Orange Money: fill in `metadata.customer_name` if you plan to issue refunds.
- In test (`sk_test_`), no money moves and the outcome is simulated.

### 6. Track and cancel

Fetch a transaction's status alongside the webhook (for example after a network outage on your side), or cancel it while it is still pending.

#### Track the payment

**GET** `/transactions/{uuid}` — API key + X-Application-ID

Returns the current status. Filterable list: `GET /transactions?status=success&type=collection&date_from=...&per_page=20`.

Response 200 :

```json
{
  "success": true,
  "data": {
    "uuid": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b3",
    "type": "collection",
    "provider": "orange_money",
    "status": "success",
    "amount": "5000.00",
    "currency": "XAF",
    "reference": "MP240101.1234.A56789",
    "external_id": null,
    "phone_number": "696246686",
    "checkout_url": null,
    "metadata": {
      "description": "Commande 1234",
      "customer_name": "Nom du client"
    },
    "expires_at": null,
    "created_at": "2026-11-02T08:00:00Z",
    "updated_at": "2026-11-02T08:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 401 | Invalid API key. |
| 404 | Unknown transaction or owned by another account. |

Possible statuses: see the **Transaction statuses** appendix.

#### Cancel a pending payment

**POST** `/transactions/{uuid}/cancel` — API key + X-Application-ID

Cancels a transaction that is still `pending`. Triggers the `transaction.cancelled` webhook. The response has the same structure as a transaction, with the updated status.

| Code | When |
| --- | --- |
| 404 | Transaction not found. |
| 422 | Transaction already completed: it can no longer be cancelled. |

### 7. Payment links

Collect without code: every individual merchant has a permanent open-amount link and a QR code. You can also create fixed-amount links. Customers pay on Kratos Pay's hosted page by Mobile Money or card when enabled.

#### Permanent link

**GET** `/payment-links/static` — Session token

Returns your permanent link: `public_url`, `qr_code_svg`, `qr_code_url`.

#### Create a link for a specific purpose

**POST** `/personal/payment-links` — Session token

For a kitty, an invoice… Allow the payment methods you want in `allowed_providers`.

Request body :

```json
{
  "title": "Cagnotte anniversaire",
  "description": "Participation au cadeau",
  "amount": 5000,
  "currency": "XAF",
  "is_amount_fixed": true,
  "allowed_providers": [
    "orange_money_cm",
    "mtn_momo_cm"
  ],
  "redirect_url": "https://monsite.com/merci",
  "expires_at": "2026-12-31T23:59:59Z",
  "max_uses": 100
}
```

Response 201 (excerpt) :

```json
{
  "success": true,
  "data": {
    "uuid": "b2c3d4e5-...",
    "slug": "cagnotte-anniversaire-xkf72a",
    "title": "Cagnotte anniversaire",
    "amount": "5000.00",
    "currency": "XAF",
    "is_amount_fixed": true,
    "status": "active",
    "use_count": 0,
    "max_uses": 100,
    "public_url": "https://payment.kratospay.com/cagnotte-anniversaire-xkf72a",
    "images": []
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

- Link states (`status`): **active**, **disabled**, **expired**, **max_uses_reached**. List: `GET /personal/payment-links`.
- Errors: 400/422 if the amount is missing for a fixed link, or if a network in `allowed_providers` does not exist.

#### What the payment page does

For information, or if you build your own payment page:

| Route | Role | Responses |
| --- | --- | --- |
| `GET /public/payment-links/{slug}` | Shows the link. Returns 200 even if the link is unavailable (`is_available: false` + `unavailable_reason`: disabled, expired or max_uses_reached). | 200, 404 if unknown slug |
| `POST /public/payment-links/{slug}/transactions` | Creates the customer's payment (`provider`, `phone_number`, `amount`, `idempotency_key`). `amount` is only required for open-amount links. | 201 (pending), 404, 422 (link unavailable, network not allowed, missing amount) |
| `GET /public/transactions/{uuid}/status` | Waits for the result: stays open up to 20 s while the status is `pending`; call it again immediately. | 200: `{ uuid, status, amount, currency, provider }` |

### 8. Balance and withdrawals

Check your balance, then withdraw your money to a Mobile Money account or, if a valid IBAN is saved in your profile, by bank transfer.

#### Check your balance

**GET** `/merchant/wallet` — Session token

Available balance per currency, across all applications. Never add up two currencies. Optional parameter: `environment=live|test`.

Response 200 :

```json
{
  "success": true,
  "data": {
    "balances": {
      "XAF": "980000.00"
    },
    "applications_without_activity": []
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 401 | Expired token. |
| 503 | Ledger temporarily unavailable (`LEDGER_MODULE_UNAVAILABLE`): retry. |

#### Check the number's holder

**POST** `/withdrawals/account-inquiry` — Session token

Before a withdrawal, check who holds the destination number. No amount is committed. `operator` is `orange` or `mtn`.

Request body :

```json
{
  "operator": "orange",
  "phone_number": "696246686"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "operator": "orange",
    "phone_number": "696246686",
    "first_name": "PRENOM",
    "last_name": "NOM",
    "full_name": "PRENOM NOM"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 404 | `ACCOUNT_NOT_FOUND`: number unknown to the operator. |
| 422 | Invalid `operator` or `phone_number`. |
| 502 | `GIMAC_UNAVAILABLE`: check temporarily unavailable, retry. |

#### Start the withdrawal

**POST** `/withdrawals` — Session token

The amount is held on your balance immediately, then sent to the network. For an individual, `application_id` is optional (your personal application is used).

Request body :

```json
{
  "amount": 15000,
  "destination_provider": "orange_money_cm",
  "destination_phone_number": "696246686",
  "currency": "XAF"
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "...",
    "application_id": 1,
    "destination_provider": "orange_money",
    "destination_phone_number": "696246686",
    "amount": 15000,
    "currency": "XAF",
    "status": "pending",
    "failure_reason": null,
    "reference": "OM-9f3a1",
    "created_at": "2026-11-02T12:30:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Insufficient balance (`INSUFFICIENT_BALANCE`), network unavailable, invalid data. |

- Initial status: **pending** (or success / failed depending on the network's immediate answer). Tracking: `GET /withdrawals/{uuid}`; history: `GET /withdrawals`.
- If the withdrawal fails, the held amount is returned to your balance.

#### Withdraw by bank transfer

**POST** `/withdrawals` — Session token

Set `destination_type: "bank"`. The transfer is sent to the IBAN saved in your profile through the GIMAC network; the bank is determined from the IBAN. Do not send a provider or phone number.

Request body :

```json
{
  "amount": 150000,
  "destination_type": "bank",
  "currency": "XAF"
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "018f1a2b-...",
    "application_id": 1,
    "destination_provider": "gimac",
    "destination_phone_number": null,
    "destination_bank": {
      "bank_name": "BICEC",
      "iban": "CM21 1000 5000 ...... 0117"
    },
    "amount": 150000,
    "currency": "XAF",
    "status": "pending",
    "failure_reason": null,
    "reference": "KP00000000000000001",
    "created_at": "2026-11-11T12:30:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Missing or invalid IBAN, bank not active on the GIMAC network, currency other than XAF, or insufficient balance. |

- Requirements: a valid CEMAC IBAN (27 characters and valid check digits), an active participating bank, and `XAF` currency. Funds are held when the transfer starts; a confirmed failure is returned, while a network incident may require investigation.
- A transfer may remain `pending` longer than a Mobile Money withdrawal. The full IBAN is masked in `destination_bank.iban`. Contact support to change your IBAN.

### 9. Refund

Fully refund a successful transaction. The operation is irreversible and can only be done once per transaction.

#### Refund a customer

**POST** `/transactions/{uuid}/refund` — API key + X-Application-ID

Request body :

```json
{
  "reason": "Commande annulée par le client."
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "transaction_uuid": "018f1a2b-...",
    "amount": "5000.00",
    "currency": "XAF",
    "reason": "Commande annulée par le client.",
    "status": "success",
    "processed_at": "2026-11-02T09:00:00Z",
    "created_at": "2026-11-02T09:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 404 | Transaction not found. |
| 422 | Transaction not refundable: status other than `success`, already refunded, or network without refund support (only Orange Money supports refunds). |

- Fetch a refund: `GET /transactions/{uuid}/refund`. Triggers the `transaction.refunded` webhook.

### Rights and limits

What the individual account can do, and what it cannot.

#### Individual rights and limits

- An `sk_live_` key of the personal application collects in production **without prior KYC**. If creating a payment returns 422 with an error on the `kyc` field, the account must first be verified (see Business merchant, step 3).
- Some features are reserved for businesses (team management, scheduled withdrawals, mass payments): they return 403 to an individual account.
- Sign-in by phone only (not by email and password).

## Business merchant

### 1. Create the account

The business merchant collects for its activity, with several applications, a team, scheduled withdrawals and mass payments. Main differences with the individual: **email + password + mandatory TOTP** sign-in, **KYC verification** before live mode, several applications.

#### Create the business account

**POST** `/auth/register` — Public

Creates the account and its company profile. An application is created automatically in the company's name. The account must pass KYC before any real payment.

Request body :

```json
{
  "name": "Nom Prénom",
  "merchant_type": "business",
  "email": "contact@mon-entreprise.cm",
  "password": "<mot-de-passe>",
  "password_confirmation": "<mot-de-passe>",
  "phone": "696246686",
  "business_name": "Mon Entreprise SARL",
  "legal_form": "SARL",
  "rccm_number": "RC/DLA/2021/B/12345",
  "niu": "M012345678901B",
  "manager_name": "Nom Prénom",
  "address": "Rue de la Paix, Douala",
  "iban": "CM2110003001000123456789012",
  "bank_account_holder": "Mon Entreprise SARL",
  "mobile_money_number": "696246686",
  "activity_type": "commerce",
  "country_id": 1
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "id": 2,
    "name": "Nom Prénom",
    "email": "contact@mon-entreprise.cm",
    "merchant_type": "business",
    "kyc_status": "none",
    "is_active": true,
    "created_at": "2026-11-02T08:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Email already used, password too short, confirmation mismatch, invalid field (NIU over 15 characters, `activity_type` not in the list…). |

- `activity_type` accepts: commerce, restauration, transport, immobilier, sante, education, technologie, agriculture, services_financiers, tourisme, btp, autre.

### 2. Sign in (TOTP)

Businesses sign in with email and password. TOTP (Google Authenticator, Authy…) is mandatory: a full token is never returned directly, the response tells you the next step.

#### Sign in

**POST** `/auth/login` — Public

Request body :

```json
{
  "email": "contact@mon-entreprise.cm",
  "password": "<mot-de-passe>"
}
```

Case 1: TOTP not configured yet (first sign-in) :

```json
{
  "success": true,
  "data": {
    "requires_totp_setup": true,
    "setup_token": "..."
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

Case 2: TOTP already configured :

```json
{
  "success": true,
  "data": {
    "requires_totp": true,
    "challenge_token": "550e8400-e29b-41d4-a716-446655440000"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Wrong credentials, disabled account, or individual account (use phone sign-in). |

- Forgotten password: `POST /auth/password/forgot` then `POST /auth/password/reset`.

#### First sign-in: enable TOTP

**POST** `/auth/totp/setup` — setup_token (Bearer)

With `Authorization: Bearer {setup_token}` (valid 15 min). Returns the secret to scan in Google Authenticator or Authy.

Response 200 :

```json
{
  "success": true,
  "data": {
    "secret": "JBSWY3DPEHPK3PXP",
    "qr_url": "otpauth://totp/KratosPay:...",
    "manual_entry_key": "JBSWY3DPEHPK3PXP"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 401 | Expired `setup_token`: sign in again. |

#### Confirm TOTP and get the token

**POST** `/auth/totp/confirm` — setup_token (Bearer)

Enter the first 6-digit code generated by the app. Setup expires after 10 minutes.

Request body :

```json
{
  "code": "123456"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "merchant": {
      "...": "..."
    },
    "token": "<jeton-de-session>",
    "token_type": "Bearer",
    "recovery_codes": [
      "XXXX-XXXX-XXXX",
      "... 10 codes au total"
    ]
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Invalid code or expired session. |

> **Keep the 10 recovery codes somewhere safe: they will never be shown again.** Each one replaces a TOTP code, single-use.

#### Next sign-ins: answer the TOTP challenge

**POST** `/auth/totp/challenge` — Public

Use after a sign-in that returned `requires_totp`. The `challenge_token` is single-use, valid 5 minutes.

Request body :

```json
{
  "challenge_token": "550e8400-e29b-41d4-a716-446655440000",
  "code": "123456"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "requires_totp": false,
    "merchant": {
      "...": "..."
    },
    "token": "<jeton-de-session>",
    "token_type": "Bearer"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Invalid code, expired or already used challenge (a `XXXX-XXXX-XXXX` recovery code is also accepted). |

### 3. Verify the account (KYC)

Without verified KYC, the business stays in **test**: `sk_live_` keys cannot collect (422 "account not KYC verified"). KYC takes three calls per document, with files going straight to secure storage.

#### File status

**GET** `/kyc` — Session token

File status and documents still missing.

Response 200 :

```json
{
  "success": true,
  "data": {
    "status": "none",
    "status_label": "Non vérifié",
    "has_live_access": false,
    "can_submit_documents": true,
    "requirements": {
      "identity": [
        "id_front",
        "passport"
      ],
      "...": "..."
    },
    "missing_groups": [
      "identity",
      "niu"
    ],
    "documents": []
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

Account statuses: **none** (nothing submitted), **pending** (under review), **verified** (live access), **suspended**. The exact list of required documents is in `requirements` / `missing_groups`.

#### Send a document (3 calls)

**POST** `/kyc/upload-url` — Session token

1. Requests a temporary upload address (valid 15 min) for a document.

Request body :

```json
{
  "document_type": "business_registration",
  "content_type": "application/pdf"
}
```

Response 200 :

```json
{
  "success": true,
  "data": {
    "upload_url": "https://...",
    "document_key": "kyc/2/business_registration/550e...pdf",
    "expires_in": 900
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

2. Upload the file: `PUT {upload_url}` with the same `Content-Type`, the body being the file.

**POST** `/kyc/documents` — Session token

3. Confirm the upload:

Request body :

```json
{
  "document_type": "business_registration",
  "document_key": "kyc/2/business_registration/550e...pdf",
  "expires_at": "2030-05-14"
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "document_type": "business_registration",
    "document_type_label": "Registre de commerce",
    "status": "pending",
    "status_label": "En attente de vérification",
    "rejection_reason": null,
    "requires_expiry": false,
    "created_at": "2026-11-02T14:30:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

#### Document rules

- Document types: `id_front`, `id_back`, `passport`, `location_plan`, `niu_certificate` (individual); `business_registration`, `tax_compliance`, `manager_id_front`, `manager_id_back`, `manager_passport` (business); `merchant_contract`, `bank_domiciliation_certificate` (both); `address_proof`, `residence_proof`, `additional_financial_documents` (optional).
- Formats: `image/jpeg`, `image/png`, `application/pdf`. `expires_at` is only required for identity documents (`requires_expiry: true`).
- Each document's state: **pending**, **approved**, **rejected** (with `rejection_reason`). An approved document cannot be overwritten; a pending one is replaced by a new upload. Delete a pending document: `DELETE /kyc/documents/{id}`.
- Errors: 422 if the type or format is invalid, if the file was not uploaded before confirming, or if the document is already approved.

### 4. Create an application

One application = one separate integration (website, mobile app, terminal). Each has its own keys, webhook, balance and payment links. Its id goes in `X-Application-ID`.

#### Create an application

**POST** `/applications` — Session token

Request body :

```json
{
  "name": "Boutique en ligne",
  "webhook_url": "https://mon-site.cm/webhooks/kratospay",
  "country_id": 1
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "application": {
      "id": 12,
      "name": "Boutique en ligne",
      "webhook_url": "https://mon-site.cm/webhooks/kratospay",
      "has_webhook": true,
      "is_active": true,
      "created_at": "2026-11-02T08:00:00Z"
    },
    "webhook_secret": "whsec_<secret>"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 401 | Expired token. |
| 422 | Missing name or invalid webhook URL. |

- **`webhook_secret` is shown only once**, even without `webhook_url`. Regenerate it: `POST /applications/{id}/webhook/rotate-secret`.
- Manage: `GET /applications`, `GET /applications/{id}`, `PATCH /applications/{id}` (`name`, `webhook_url`, `is_active`), `DELETE /applications/{id}`. Countries with transactions: `GET /applications/{id}/countries`.

### 5. Generate API keys

Same call as for individuals: a `test` key to develop, a `live` key once KYC is verified. The value is shown only once.

#### Generate API keys

**POST** `/auth/keys` — Session token

Request body :

```json
{
  "name": "Serveur boutique",
  "environment": "live"
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "key": "sk_live_<votre-cle>",
    "meta": {
      "id": 7,
      "environment": "live",
      "...": "..."
    }
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | Invalid `environment` or name. |

Full details: Individual merchant, step 4.

### 6. Collect and send

Calls are identical to the individual's (collect, track, cancel, refund): `POST /transactions`, `GET /transactions`, `GET /transactions/{uuid}`, `POST /transactions/{uuid}/cancel`, `POST /transactions/{uuid}/refund`, with `Authorization: Bearer sk_live_…` and `X-Application-ID: {id}`.

#### Business specifics

- **`X-Application-ID` is required**: choose the application the operation belongs to.
- Until KYC is verified, a payment with a live key is refused with 422 (`kyc` error).
- Sending money from the balance: `POST /transactions` with `"type": "disbursement"`. In production the amount is held on the balance before calling the network; insufficient balance = 422 `INSUFFICIENT_BALANCE`, nothing is created. In test, no balance is required.
- List filters: `status`, `type`, `provider`, `date_from`, `date_to`, `amount_min`, `amount_max`, `application_id`, `per_page`. Response: `data.items` + `data.pagination`.
- From the dashboard (without API key): `GET /merchant/transactions` and `GET /merchant/transactions/{uuid}` with the session token.
- Asynchronous CSV export: `POST /transactions/export`, then `GET /transactions/exports/{uuid}` until "ready", and download through the link received by email.
- Bank cards: use `provider: "mastercard"` for both Mastercard and Visa, without `phone_number`; creation returns a secure `checkout_url`. Business KYC must be verified before live payments.

Bodies, responses and errors: Individual merchant, steps 5, 6 and 9.

#### Send money (disbursement)

**POST** `/transactions` — API key + X-Application-ID

Sends an amount from the application balance to the recipient's Mobile Money wallet. `phone_number` is required, without a country code, and must match the `provider` country. In production, the amount is held before calling the network; insufficient balance returns 422 `INSUFFICIENT_BALANCE`. Use a unique `idempotency_key` and keep the response `uuid`.

Request body :

```json
{
  "type": "disbursement",
  "provider": "mtn_momo_cm",
  "amount": 2000,
  "currency": "XAF",
  "phone_number": "677895412",
  "idempotency_key": "paiement-fournisseur-42"
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b4",
    "type": "disbursement",
    "provider": "mtn_momo",
    "status": "pending",
    "amount": "2000.00",
    "currency": "XAF",
    "reference": "MP240101.1235.B12345",
    "phone_number": "677895412",
    "created_at": "2026-11-02T08:05:00Z",
    "updated_at": "2026-11-02T08:05:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 422 | `INSUFFICIENT_BALANCE`, invalid amount/currency, unavailable network or phone number not matching the provider's country. |
| 401 | Invalid, inactive or wrong-environment API key. |

- The initial status is usually `pending`. Wait for the `transaction.success` or `transaction.failed` webhook, or fetch `GET /transactions/{uuid}`; do not infer success from the creation response alone.
- In test (`sk_test_`), no real balance is required and no funds move.

### 7. Payment links

To collect without development. Same public page as for individuals, with Mobile Money or card according to the allowed methods; creation is tied to an application.

#### Create a payment link

**POST** `/payment-links` — Session token

Request body :

```json
{
  "application_id": 12,
  "title": "Abonnement mensuel",
  "description": "Accès illimité pendant 30 jours",
  "amount": 5000,
  "currency": "XAF",
  "is_amount_fixed": true,
  "allowed_providers": [
    "orange_money_cm",
    "mtn_momo_cm"
  ],
  "redirect_url": "https://monsite.com/merci",
  "expires_at": "2026-12-31T23:59:59Z",
  "max_uses": 100,
  "slug": "abonnement-mensuel"
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "b2c3d4e5-...",
    "slug": "abonnement-mensuel",
    "title": "Abonnement mensuel",
    "amount": "5000.00",
    "currency": "XAF",
    "is_amount_fixed": true,
    "status": "active",
    "use_count": 0,
    "public_url": "https://payment.kratospay.com/abonnement-mensuel",
    "images": []
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

- Errors: 400 (missing amount for a fixed link), 422 (unknown application or owned by another account, network not found in `allowed_providers`).
- Other routes: `GET /payment-links` (list), `GET` / `PATCH` / `DELETE /payment-links/{uuid}`, `GET /payment-links/static` (permanent link + QR), image gallery in two steps: `POST /payment-links/{uuid}/images/upload-url` then `POST /payment-links/{uuid}/images/confirm`.
- A payment through a link requires the business to be KYC verified: otherwise 422 when the payment is created.

### 8. Balance and withdrawals

Balance: `GET /merchant/wallet` (global per currency) and `GET /applications/{id}/balance` (per application). Details and responses: Individual merchant, step 8.

#### Instant withdrawal

**POST** `/withdrawals` — Session token

For a business, `application_id` is **required**. Same prior check (`POST /withdrawals/account-inquiry`), same bodies and responses as for individuals. History: `GET /withdrawals`.

- For a bank transfer, add `destination_type: "bank"` and `application_id`. The IBAN on the business profile is used; see the individual merchant's "Withdraw by bank transfer" section for requirements, examples and errors.

#### Scheduled withdrawal

**POST** `/scheduled-withdrawals` — Session token

Business only: the amount is held when each occurrence is triggered.

Request body :

```json
{
  "application_id": 12,
  "amount": 15000,
  "destination_provider": "orange_money_cm",
  "destination_phone_number": "696246686",
  "frequency": "monthly",
  "day_of_month": 1,
  "time_of_day": "12:30",
  "starts_from": "2026-12-01",
  "currency": "XAF"
}
```

Response 201 (excerpt) :

```json
{
  "success": true,
  "data": {
    "application_id": 12,
    "amount": 15000,
    "frequency": "monthly",
    "day_of_month": 1,
    "time_of_day": "12:30:00",
    "next_execution_at": "2026-12-01T12:30:00Z",
    "is_active": true,
    "status": "active",
    "currency": "XAF"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

- `frequency`: `once`, `daily`, `weekly` (requires `day_of_week`, 1 = Monday to 7 = Sunday), `monthly` (requires `day_of_month`, 1 to 31; moved to the last day of a shorter month).
- Statuses: active, paused, completed (finished `once` frequency), cancelled. Edit / pause: `PATCH /scheduled-withdrawals/{id}`. Cancel: `DELETE /scheduled-withdrawals/{id}`. Occurrence history: `GET /scheduled-withdrawals/executions`.
- Errors: 403 (not a business account), 422 (invalid data).

### 9. Mass payments

Salaries, suppliers, grouped refunds. Three steps: (1) create the batch (no money moves), (2) trigger it, (3) track its progress.

#### Create a batch

**POST** `/mass-payments` — Session token

Request body :

```json
{
  "application_id": 12,
  "reference": "Salaire_Juin_2026",
  "currency": "XAF",
  "beneficiaries": [
    {
      "nom": "Bénéficiaire 1",
      "telephone": "691028372",
      "montant": 10000,
      "provider": "orange_money_cm"
    },
    {
      "nom": "Bénéficiaire 2",
      "telephone": "677895412",
      "montant": 10000,
      "provider": "mtn_momo_cm"
    }
  ]
}
```

Response 201 (excerpt) :

```json
{
  "success": true,
  "data": {
    "uuid": "...",
    "reference": "Salaire_Juin_2026",
    "currency": "XAF",
    "total_amount": 20000,
    "beneficiary_count": 2,
    "statut": "en_attente",
    "beneficiaries": [
      "..."
    ]
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

#### Trigger and track

- **All or nothing**: a single invalid beneficiary and nothing is saved (422 with errors indexed by beneficiary).
- Import from a file: `POST /mass-payments/preview-csv` returns a preview before creation.
- `POST /mass-payments/{uuid}/trigger` (202) starts the run in the background, once per batch (422 if already triggered). Status: `en_attente` → `en_cours` → `valide` / `partiellement_reussi` / `echoue`.
- Tracking: `GET /mass-payments/{uuid}`; list: `GET /mass-payments`.

### 10. Team and other modules

Invite team members with limited rights, and discover the other modules available to businesses.

#### Invite a team member

**POST** `/merchant/team/members` — Session token

The team member receives an email to set their password (no password is sent in plain text).

Request body :

```json
{
  "name": "Nom Prénom",
  "email": "collaborateur@exemple.cm",
  "role_id": 3,
  "application_ids": [
    12
  ]
}
```

`role_id` missing: the member stays pending configuration. `application_ids` missing or `null`: access to all applications. Errors: 422 (member limit reached, member already active).

| Route | Role |
| --- | --- |
| `GET /merchant/team/members` | List members |
| `PATCH /merchant/team/members/{uuid}` | Change profile or accessible applications |
| `POST /merchant/team/members/{uuid}/resend-invitation` | Resend the invitation |
| `DELETE /merchant/team/members/{uuid}` | Remove a member |
| `GET /merchant/team/roles`, `GET /merchant/team/permissions` | Profiles and permissions catalogue |
| `POST /merchant/team/roles`, `PATCH` / `DELETE /merchant/team/roles/{id}`, `POST /merchant/team/roles/{id}/fork` | Create, edit, archive or fork a custom profile |

#### Other available modules

Detailed documentation on request.

| Module | Main routes |
| --- | --- |
| Dashboard insights | `GET /merchant/insights`, `/merchant/insights/timeseries`, `/merchant/insights/payment-links`, `/merchant/insights/flows` ; `GET /merchant/activity-logs` |
| Suppliers | `GET` / `POST /fournisseurs`, `GET` / `PUT` / `DELETE /fournisseurs/{id}`, activate / deactivate, `GET` / `POST /fournisseurs/{id}/payments` |
| Currency conversion | `GET /exchange-rates`, `POST /conversions/quote`, `POST /conversions/{uuid}/execute`, `GET /conversions` |
| Referral | `GET /merchant/referral-code`, `GET /merchant/referral/commissions`, `POST /merchant/commissions/transfer`, `POST /merchant/commissions/withdraw` |
| Announcements | `GET /announcements`, `POST /announcements/{id}/read`, `POST /announcements/read-all` |
| Reference data | `GET /countries`, `/currencies`, `/activity-types`, `/payment-providers` |

## GIMAC QR collections

### Merchant code and QR points

GIMAC QR lets customers in the CEMAC region pay by scanning a merchant code from their bank or operator app. QR point routes let you create a distinct code for each cashier, station, branch or attendant.

#### Payment flow

- The customer scans the QR, enters an amount in XAF, then confirms in their payment app. GIMAC contacts Kratos Pay in real time.
- If the merchant code, account and point are active and required KYC is verified, the payment is accepted immediately: a `success` transaction is created, the balance is credited and a webhook is sent.
- If the payment is rejected, the customer is not debited. The payer may be in another CEMAC country; the currency remains `XAF`.
- QR payments are static: the customer enters the amount. An account already has an automatically assigned merchant code; contact support for its printable "Scan & Pay" card.

#### QR point routes

| Route | Purpose |
| --- | --- |
| `POST /gimac/qr-points` | Create a point with its merchant code, `qr_payload` and `qr_svg`. |
| `GET /gimac/qr-points` | List points; optional `external_ref` and `is_active` filters. QR image is not included. |
| `GET /gimac/qr-points/{uuid}` | Read a point and its `qr_svg` image. |
| `PATCH /gimac/qr-points/{uuid}` | Change the name, city or application, or activate/deactivate the point. The code does not change. |
| `GET /gimac/qr-points/{uuid}/card` | Open the printable A5 card with the name, code and QR. |

Use an API key and `X-Application-ID` for these routes. The same operations are available from the dashboard with a session token under `/merchant/gimac/qr-points`.

#### Create a QR point

**POST** `/gimac/qr-points` — API key + X-Application-ID

Creates a code for a station, attendant, branch or cashier. Payments are credited to the balance of the specified application, or the `X-Application-ID` application when `application_id` is omitted.

Request body :

```json
{
  "name": "STATION TOTAL BONAMOUSSADI",
  "city": "Douala",
  "external_ref": "station-42",
  "application_id": 12
}
```

Response 201 :

```json
{
  "success": true,
  "data": {
    "uuid": "0190b6f0-7a52-7e0a-9b5c-2f6d1c3a8e44",
    "code": "612345",
    "name": "STATION TOTAL BONAMOUSSADI",
    "city": "Douala",
    "external_ref": "station-42",
    "application_id": 12,
    "is_active": true,
    "qr_payload": "00020101021135370014A000000656101001051402302066123455204...",
    "qr_svg": "<svg>…</svg>",
    "created_at": "2026-11-11T09:00:00Z",
    "updated_at": "2026-11-11T09:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}
```

| Code | When |
| --- | --- |
| 200 | `external_ref` already exists: the existing point is returned (idempotent creation). |
| 401 | Invalid API key. |
| 422 | Missing or over-25-character `name`, over-15-character `city`, or `application_id` owned by another account. |

- `external_ref` is your stable business identifier; resend the same value after a timeout to avoid duplicates. The 6-digit merchant code never changes.
- `name` (up to 25 characters) and `city` (up to 15) are printed in the QR. Accents are removed; `city` defaults to the profile address.
- `qr_svg` is the complete image to display or save; `qr_payload` is the string to encode if you draw the QR yourself. Neither is included in list responses.

#### Manage a point and process a payment

**PATCH** `/gimac/qr-points/{uuid}` — API key + X-Application-ID

Request body :

```json
{
  "is_active": false
}
```

Deactivation rejects payments scanned from this QR; send `is_active: true` to reactivate it. The response has the creation shape with updated values. A 404 means the point is missing or belongs to another account. Changing its name or city does not update already printed QRs.

`transaction.success` webhook for a point payment :

```json
{
  "event": "transaction.success",
  "data": {
    "uuid": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b3",
    "type": "collection",
    "provider": "gimac",
    "status": "success",
    "amount": 5000,
    "currency": "XAF",
    "external_id": "C1452HG78511",
    "metadata": {
      "gimac_intent": "merchant_purchase",
      "gimac_frommember": "32001",
      "qr_point_uuid": "0190b6f0-7a52-7e0a-9b5c-2f6d1c3a8e44",
      "qr_point_ref": "station-42",
      "qr_point_name": "STATION TOTAL BONAMOUSSADI"
    }
  }
}
```

- Use `metadata.qr_point_ref` (your `external_ref`) to assign the payment to a station or attendant. Account-level QR payments omit `qr_point_*` fields.
- `external_id` identifies the GIMAC transfer; the same transfer is never credited twice. QR payments cannot be refunded through the API.

#### Requirements and limits

- Business merchants must pass KYC to receive QR payments; individuals are exempt.
- Available in XAF in the CEMAC region. An inactive account or point is rejected and the customer is not charged.
- The customer enters the amount. Fixed-amount QR points are not available.

## Appendices

### Webhooks

Kratos Pay sends a JSON `POST` to your application's `webhook_url` every time a transaction's status changes. It is the recommended way to learn a payment's outcome.

#### Events

| event | When |
| --- | --- |
| `transaction.success` | The payment succeeded (Mobile Money, card or GIMAC QR). |
| `transaction.failed` | The payment failed (refusal, customer's insufficient balance, timeout…). |
| `transaction.cancelled` | The payment was cancelled (by you, the customer or the subscriber). |
| `transaction.awaiting_confirmation` | A payout is still waiting for network confirmation; the amount remains held. |
| `transaction.refunded` | The refund was confirmed. |

#### Request received

Headers :

```http
POST https://mon-site.cm/webhooks/kratospay
Content-Type: application/json
X-Timestamp: 1751731200
X-Signature: a3f8c9...          (HMAC-SHA256 hexadécimal)
X-Request-ID: 7d2a1c3e-....      (unique par tentative)
User-Agent: KratosPay-Webhooks/1.0
```

Body :

```json
{
  "event": "transaction.success",
  "api_version": "2026-06-17",
  "created_at": "2026-11-02T14:30:00Z",
  "data": {
    "uuid": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b3",
    "type": "collection",
    "provider": "orange_money",
    "status": "success",
    "amount": 5000,
    "currency": "XAF",
    "reference": "MP240101.1234.A56789",
    "external_id": "TXN-987654",
    "phone_number": "696246686",
    "metadata": {
      "description": "Commande 1234"
    }
  }
}
```

#### Verify the signature

Compute `HMAC-SHA256(key = webhook_secret, message = X-Timestamp + "." + raw body)` and compare it to `X-Signature` with a constant-time comparison. Reject any request whose `X-Timestamp` is more than 5 minutes away from the current time (replay protection). Use the **raw** body, before any JSON decoding.

PHP :

```php
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
if (!hash_equals($expected, $signature) || abs(time() - (int)$timestamp) > 300) {
  http_response_code(400);
  exit;
}
```

Node.js :

```javascript
const expected = crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
```

#### Delivery and retries

- Reply with a **2xx** status to acknowledge, within 10 seconds. Any other response (or no response) is a delivery failure.
- On failure, up to **5 attempts** in total, spaced 1, 5, 15 then 30 minutes apart.
- The same event may be delivered more than once: make your processing **idempotent** (for example by storing `data.uuid` + `event`).
- Never consider a payment collected without checking the signature; when in doubt, confirm with `GET /transactions/{uuid}`.
- For GIMAC QR payments, `transaction.success` is sent as soon as the transaction is created. Payments made at a QR point include `metadata.qr_point_ref` (see Merchant code and QR points).

### Transaction statuses

The possible statuses of a transaction, and which ones are final.

#### Statuses

| status | Meaning | Final? |
| --- | --- | --- |
| `pending` | Request sent, waiting for the customer to confirm on their phone or the card checkout page, or for the network. | No |
| `awaiting_confirmation` | Send whose outcome the network has not confirmed yet; the amount stays reserved until the answer. | No |
| `success` | Payment succeeded. | Yes (depending on provider) |
| `failed` | Payment refused or failed. | Yes |
| `cancelled_by_customer` | Cancelled by the merchant or the customer. | Yes |
| `cancelled_by_subscriber` | Refused or cancelled by the subscriber on their phone. | Yes |
| `expired` | Confirmation time limit exceeded (for example, the customer did not return from card checkout). | Yes |
| `refunded` | Refunded. | Yes |

#### Transaction types

`collection` (Mobile Money, card or GIMAC QR collection), `disbursement` (send), `cashout` (withdrawal, including bank transfer), `airtime`, `tv`, `internet`.

### Common errors

The most common errors and what to do.

#### Common errors and what to do

| Situation | Response | What to do |
| --- | --- | --- |
| API key missing or malformed | 401 | The prefix must be `sk_test_` or `sk_live_`; the key must be active and not expired. |
| Test key used in production (or the reverse) | 401 | Use the key of the target environment. |
| Unknown `X-Application-ID` or from another account | 422 `APPLICATION_NOT_FOUND` | Check the id (`GET /applications`). |
| Business account not KYC verified, live key | 422 (`kyc` field) | Complete KYC (Business merchant, step 3). |
| Withdrawal or send above the balance | 422 `INSUFFICIENT_BALANCE` | Check `GET /merchant/wallet`; nothing was debited. |
| Bank transfer without an IBAN or with an invalid IBAN | 422 (`destination_type`) | Save a valid CEMAC IBAN in the profile; contact support to change it. |
| Unsupported bank or wrong currency for a bank transfer | 422 | The bank must be an active GIMAC participant and the currency must be XAF. |
| Card payments not enabled for the account | 422 (`provider` field) | Contact support to enable card payments. |
| Customer returns from card checkout without paying | `pending` then `expired` | Do not fulfill the order; offer a new payment. |
| Number from another country than the network | 422 (`phone_number` field) | Use a number from the network's country. |
| Network disabled or unavailable | 422 (`provider` field) | Retry later or use another network. |
| Inactive QR point or account without verified KYC | Rejected by GIMAC | Reactivate the point or complete required KYC; the customer is not charged. |
| Timeout creating a payment or QR point | — | Retry the payment with the same `idempotency_key` or the point with the same `external_ref`. |
| MTN, card or GIMAC QR refund | 422 | Not supported by API: only Orange Money refunds. |
| Too many requests | 429 | Slow down and retry. |

> Support: always give the `meta.request_id` of the response concerned.
