# Integrating `app_id`

**Audience:** developers of any app that calls the FinFlo payment API.
**Base URL:** `https://mm.finflo.net/public/api/mobile-money/`

Every app that talks to this API now identifies itself with an `app_id`. This
guide covers what to change, what breaks if you get it wrong, and how to test.

---

## 1. What changes

One field. This is the whole integration:

```diff
  POST /public/api/mobile-money/pay

  {
      "msisdn": "+256709713160",
-     "amount": 500.00
+     "amount": 500.00,
+     "app_id": "app_7f3c1e9a2b8d4056af1c3e5d7b9a2c40"
  }
```

Nothing else about the request or the response changes. The API still forwards
to the payment gateway exactly as before.

### Where to get your `app_id`

The administrator registers your app in the admin panel
(**Governance → Apps**) and gives you two values:

| Value | What it is | Where it goes |
|---|---|---|
| `app_id` | Public identifier | In the request. Safe to ship in your app. |
| `secret` | Signing secret | **Never ship this.** Server-side only. See §6. |

The `app_id` is not a password. It identifies your app; it does not prove
anything. Treat it like a username.

---

## 2. Why it exists

Several separate businesses share this API. The `app_id` tells the platform
which business a transaction belongs to, which drives:

- **Per-app reporting** in the admin panel — volume, transaction counts, last
  seen.
- **Shareholder reporting.** Some businesses have outside shareholders who see
  a portal. Their portal must show *their* business's money and nobody else's.
  Without an `app_id` we cannot tell your transactions apart from another
  company's.
- **Instant revocation.** If an app is compromised it can be switched off on
  its own, without touching anyone else.

---

## 3. Which endpoints need it

Every endpoint under `/api/mobile-money/` and `/api/pesapal/`. In particular:

| Endpoint | Method | Notes |
|---|---|---|
| `pay` | POST | Collections |
| `send` | POST | Disbursement |
| `direct` | POST | Disbursement |
| `purchase-product` | POST | Disbursement |
| `payment-session` | POST | Card |
| `validate`, `validate-product`, `validate-bank` | POST | |
| `balance`, `status/{ref}`, `check-status/{ref}`, `health` | GET | Use the header — see below |

**Not** required on the gateway callbacks (`webhook/relworx`, `callback`) —
those come from Relworx, not from you.

### GET requests

GET has no body, so send the header instead:

```
X-App-Id: app_7f3c1e9a2b8d4056af1c3e5d7b9a2c40
```

The header also works on POST if that suits your HTTP client better. If both
are present, the body wins.

---

## 4. Rollout — you will not be broken overnight

There is a server switch, `payments.require_app_id`, currently **off**.

| Situation | While the switch is OFF | Once it is ON |
|---|---|---|
| Valid `app_id` sent | Works | Works |
| **No** `app_id` sent | Works — but the transaction is attributed to nobody | **Rejected, HTTP 400** |
| Unknown `app_id` sent | **Rejected, HTTP 403** | **Rejected, HTTP 403** |
| Revoked `app_id` sent | **Rejected, HTTP 403** | **Rejected, HTTP 403** |

Two things to take from that table:

1. **You can migrate at your own pace.** Apps that have not been updated keep
   working until the switch is flipped.
2. **A wrong `app_id` is always rejected**, switch or no switch. Sending a
   typo'd or stale identifier is worse than sending none at all. Do not
   hard-code a guess.

You will be told before the switch is turned on.

---

## 5. Errors

All errors return JSON in the same shape:

```json
{ "success": false, "message": "..." }
```

| Status | `message` | Cause | Fix |
|---|---|---|---|
| 400 | `An app_id is required...` | No `app_id`, and the switch is on | Send your `app_id` |
| 403 | `Unrecognised app_id.` | Not registered, or a typo | Check the value with the administrator |
| 403 | `This app has been revoked.` | Registration switched off | Contact the administrator |
| 401 | `Request signature is missing or invalid.` | Signing enforced, signature wrong | See §6 |
| 409 | `A transaction with this reference already exists...` | Duplicate `reference` | Use a fresh reference — **do not retry blindly**, the first one may have succeeded |
| 422 | `Amount exceeds the maximum permitted...` | Over the per-transaction cap | Large payouts go through the withdrawal mandate, not the API |
| 429 | `Too many requests.` | Rate limited | Back off; honour the `Retry-After` header |

**On 409:** this means a transaction with that reference already exists. Do not
treat it as a failure and retry with the same reference — check the status of
the existing one with `GET status/{reference}` instead.

---

## 6. Request signing (not yet required)

Separately from `app_id`, the API supports HMAC request signing. It is running
in **log mode** — unsigned requests are accepted and recorded. You do not need
this today, but building it in now saves a second migration.

Send four headers:

| Header | Value |
|---|---|
| `X-Client-Id` | Your `app_id` (same value) |
| `X-Timestamp` | Unix seconds |
| `X-Nonce` | Random, unique per request, 8+ characters |
| `X-Signature` | Hex HMAC-SHA256, see below |

The signed string is five lines joined with `\n`:

```
POST
/api/mobile-money/pay
1755259200
a3f9c1e07b2d4856
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

- Line 1: HTTP method, uppercase
- Line 2: path, **without** the `/public` prefix, leading slash included
- Line 3: the same value as `X-Timestamp`
- Line 4: the same value as `X-Nonce`
- Line 5: SHA-256 hex of the **exact raw request body** (empty string for GET)

Then `X-Signature = hex(hmac_sha256(secret, signedString))`.

Notes that will save you an afternoon:

- Hash the **exact bytes you send**. Re-serialising the JSON to compute the
  hash and then sending a differently-formatted body will not verify.
- The timestamp must be within **5 minutes** of server time. Check the device
  clock if signatures fail intermittently.
- A nonce may be used **once**. Generate a fresh one per request, never reuse.
- The signature is bound to the path, so a signature captured from one endpoint
  cannot be replayed against another.

---

## 7. Examples

### curl

```bash
curl -X POST https://mm.finflo.net/public/api/mobile-money/pay \
  -H "Content-Type: application/json" \
  -d '{"msisdn":"+256709713160","amount":500.00,"app_id":"app_7f3c1e9a2b8d4056af1c3e5d7b9a2c40"}'
```

### Dart / Flutter

```dart
import 'dart:convert';
import 'package:http/http.dart' as http;

const String appId   = 'app_7f3c1e9a2b8d4056af1c3e5d7b9a2c40';
const String baseUrl = 'https://mm.finflo.net/public/api/mobile-money';

Future<Map<String, dynamic>> pay({
  required String msisdn,
  required double amount,
  String? reference,
}) async {
  final body = <String, dynamic>{
    'msisdn': msisdn,
    'amount': amount,
    'app_id': appId,
    if (reference != null) 'reference': reference,
  };

  final res = await http.post(
    Uri.parse('$baseUrl/pay'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode(body),
  );

  final decoded = jsonDecode(res.body) as Map<String, dynamic>;

  // 403 means the app_id is wrong or revoked - that is a configuration
  // problem, not a transient one. Do not retry it.
  if (res.statusCode == 403) {
    throw StateError('App rejected: ${decoded['message']}');
  }

  return decoded;
}
```

For GET calls, send the header:

```dart
final res = await http.get(
  Uri.parse('$baseUrl/status/$reference'),
  headers: {'X-App-Id': appId},
);
```

### PHP

```php
$payload = [
    'msisdn' => '+256709713160',
    'amount' => 500.00,
    'app_id' => 'app_7f3c1e9a2b8d4056af1c3e5d7b9a2c40',
];

$ch = curl_init('https://mm.finflo.net/public/api/mobile-money/pay');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($payload),
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
]);

$response = json_decode(curl_exec($ch), true);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
```

### Signed request (PHP, for when signing is enforced)

```php
$appId  = 'app_7f3c1e9a2b8d4056af1c3e5d7b9a2c40';
$secret = getenv('FINFLO_APP_SECRET');   // never hard-code this

$path      = '/api/mobile-money/pay';
$body      = json_encode(['msisdn' => '+256709713160', 'amount' => 500.00, 'app_id' => $appId]);
$timestamp = (string) time();
$nonce     = bin2hex(random_bytes(8));

$canonical = implode("\n", ['POST', $path, $timestamp, $nonce, hash('sha256', $body)]);
$signature = hash_hmac('sha256', $canonical, $secret);

$headers = [
    'Content-Type: application/json',
    "X-Client-Id: {$appId}",
    "X-Timestamp: {$timestamp}",
    "X-Nonce: {$nonce}",
    "X-Signature: {$signature}",
];
// send $body verbatim - do not re-encode it
```

---

## 8. Checklist

- [ ] Received `app_id` (and `secret`, if you are signing) from the administrator
- [ ] `app_id` added to every POST body under `/api/mobile-money/` and `/api/pesapal/`
- [ ] `X-App-Id` header added to every GET
- [ ] Secret stored outside source control — not in the app bundle, not in git
- [ ] 403 handled as a configuration error, not retried
- [ ] 409 handled by checking status, not by retrying the same reference
- [ ] Tested against a real call and confirmed the app appears under
      **Governance → Apps** with a recent "last seen"

---

## 9. Questions the administrator can answer

- *What is my `app_id`?* — Admin panel, **Governance → Apps**.
- *Is my app reporting to shareholders?* — Shown on the same screen. It changes
  nothing about how you integrate.
- *When is `require_app_id` being turned on?* — After every app has migrated.
  The Apps screen shows how many transactions are still arriving without one.
- *I lost the secret.* — It cannot be shown again. Ask for a rotation; the old
  secret stops working immediately.
