# Gafiapay Integration Guide for AI Coding Agents

> **To the developer:** give this file to your coding agent (Claude Code, Cursor, Copilot, etc.) and tell it which features you want. It contains everything the agent needs to integrate Gafiapay into your codebase.
>
> **To the agent:** this file is the complete, authoritative reference for the Gafiapay merchant API. Read all of it before writing code. Follow the rules in §2 exactly: most failed integrations break one of them.

---

## 1. Before you write code

Ask the developer (or work out from the codebase) the following:

1. **Which features do they need?** Pick from the list below and only build those.
   | Feature | Use it to | Section |
   |---|---|---|
   | Virtual accounts | Give each customer a permanent bank account number. Money sent to it is credited to the business. | §4 |
   | Temporary accounts | Give a one-off account number that expires (e.g. per order). | §4.2 |
   | Refunds | Return all or part of a virtual-account deposit to the payer. | §5 |
   | Webhooks | Receive notifications of deposits and refund results. | §6 |
2. **Credentials.** They need an **API key** and **secret key** from the Gafiapay dashboard (**API Settings**). The business must be **verified**, or every call returns `401 Business not verified`. Store both keys as server-side environment variables, e.g. `GAFIAPAY_API_KEY` and `GAFIAPAY_SECRET_KEY`. Never hard-code them.
3. **Webhook URL.** If they use virtual accounts or refunds, they need a public HTTPS endpoint, and they must enter its URL in the dashboard's **Webhook** settings. Gafiapay doesn't take a webhook URL per request.
4. **Language/framework** of the backend. The examples here are Node.js; translate them faithfully, especially §2.2.

---

## 2. Rules (read carefully)

### 2.1 Basics

- **Base URL:** `https://api.gafiapay.com/api/v1/external`
- **All calls are server-to-server.** The secret key must never reach a browser or mobile app. Front ends call your own backend, and your backend calls Gafiapay.
- **Amounts are in Naira** (e.g. `1500` or `1500.50`), at most 2 decimal places, unless a field says otherwise. Currency is `NGN`.
- **Responses** look like this:
  - success: `{ "status": "success", "data": { ... } }`
  - error: `{ "status": "fail" | "error", "message": "human readable reason" }`. `fail` is a 4xx and `error` is a 5xx. Always show or log `message`.
- **429 Too Many Requests:** back off and retry later.

### 2.2 Request signing (the #1 source of bugs)

Send these headers on every request:

| Header | Value |
|---|---|
| `x-api-key` | your API key |
| `x-timestamp` | current Unix time in **milliseconds**, as a string. It must be within **5 minutes** of server time. |
| `x-signature` | `hex( HMAC_SHA256( secretKey, bodyString + timestamp ) )`. Required for every request **with a JSON body** (all POSTs). GET requests with no body don't need it, but sending it is harmless. |
| `Content-Type` | `application/json` |

The server parses your JSON and re-serializes it with JavaScript's `JSON.stringify` (compact, no spaces, keys in the order you sent them) before checking the HMAC. Therefore:

1. **Serialize the body exactly once** into a string. Sign that string and send **that exact string** as the request body. Don't let your HTTP client re-serialize an object.
2. The string must be **compact JSON**: no whitespace between tokens.
   - Python: `json.dumps(body, separators=(",", ":"), ensure_ascii=False)`. The default `json.dumps` adds spaces and **will fail**.
   - PHP: `json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)`. Escaped `\/` **will fail**.
   - Go: `json.Marshal` is compact, but call `SetEscapeHTML(false)` on an Encoder if values may contain `<`, `>` or `&`.
   - Java/Kotlin/C#: use the default compact serializer, no pretty-printing.
3. **Numbers:** send whole numbers as integers (`1500`, not `1500.0`). JavaScript re-serializes `1500.0` as `1500`, and the signature won't match.
4. For a POST with no fields, sign and send `{}`.
5. Generate a **new timestamp and signature for every request**, including retries.

Reference client (Node.js 18+, no dependencies). Port it to the project's language:

```js
// gafiapay.js
const crypto = require('crypto');

const BASE_URL = 'https://api.gafiapay.com/api/v1/external';
const API_KEY = process.env.GAFIAPAY_API_KEY;
const SECRET_KEY = process.env.GAFIAPAY_SECRET_KEY;

async function gafiapay(method, path, body) {
  const timestamp = Date.now().toString();
  const headers = { 'x-api-key': API_KEY, 'x-timestamp': timestamp, 'Content-Type': 'application/json' };
  let payload;
  if (body !== undefined) {
    payload = JSON.stringify(body); // serialize ONCE
    headers['x-signature'] = crypto.createHmac('sha256', SECRET_KEY).update(payload + timestamp).digest('hex');
  }
  const res = await fetch(BASE_URL + path, { method, headers, body: payload }); // send the SAME string
  const json = await res.json().catch(() => ({}));
  if (!res.ok || (json.status !== 'success' && json.status !== 'processing')) {
    const err = new Error(json.message || `Gafiapay HTTP ${res.status}`);
    err.status = res.status;
    err.body = json;
    throw err;
  }
  return json;
}

module.exports = { gafiapay };
```

Python equivalent of the signing step:

```python
import hmac, hashlib, json, time
payload = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
timestamp = str(int(time.time() * 1000))
signature = hmac.new(SECRET_KEY.encode(), (payload + timestamp).encode(), hashlib.sha256).hexdigest()
requests.post(url, data=payload.encode("utf-8"), headers={...})  # data=, NOT json=
```

Authentication errors (all `401`): `Missing required header: …`, `Invalid API key`, `Business not verified`, `Request timestamp expired` (check the server clock), `Invalid signature` (re-read this section). A `403` means the business is suspended or blocked.

### 2.3 Money safety

- **Save every reference** Gafiapay returns (`orderNo`, transaction `id`, refund `REF…` orderId) with your own record. Before retrying a refund, check its status; a blind retry can refund twice.
- Treat every webhook as a **hint**, then confirm it (§6.3). Handlers must be idempotent, because the same event can arrive more than once.

---

## 3. Endpoint index

| Method | Path | Purpose |
|---|---|---|
| POST | `/account/generate` | Create (or update) a permanent virtual account for a customer |
| POST | `/account/generate/temporary` | Create an expiring virtual account |
| GET | `/account/list?page=&limit=` | List active virtual accounts |
| POST | `/refund/create` | Refund a completed payment |
| GET | `/refund/status/:orderId` | Refund status |
| GET | `/refund/list?page=&limit=&status=` | List refunds |

---

## 4. Virtual accounts (bank transfer collections)

A virtual account is a real NUBAN (bank: **PalmPay**). When anyone transfers money to it, Gafiapay credits the business balance minus the collection fee and sends a `payment.received` webhook (§6). There's no "create a payment" step: the customer just transfers.

### 4.1 Create a permanent account: `POST /account/generate`

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Customer/account display name |
| `email` | string | yes | **Your customer key.** One permanent account per email per business. Calling again with the same email updates and returns that customer's account instead of creating a new one. |
| `bvn` | string | one of these | Customer BVN, 11 digits |
| `nin` | string | one of these | Customer NIN, 11 digits |
| `businessNumber` | string | one of these | CAC number, e.g. `RC1234567` / `BN1234567`, for business customers |

Identity: send **one** of `bvn`, `nin` or `businessNumber`. Priority if several are sent: CAC > BVN > NIN. If you send none, Gafiapay falls back to the business's own stored identity, unless the business has disabled that (then you get a `400`).

```json
// request
{ "name": "Jane Doe", "email": "jane@example.com", "bvn": "22212345678" }
// response 200
{ "status": "success", "data": { "bankName": "Palmpay", "accountNumber": "6612345678", "accountName": "Merchant-Jane Doe", "userName": "Merchant-Jane Doe" } }
```

**Implement:** save `accountNumber` against the customer and show it with `bankName` and `accountName`. Match incoming `payment.received` webhooks to the customer by `data.transaction.email` or `metadata.virtualAccountNo`.

### 4.2 Temporary account: `POST /account/generate/temporary`

Same fields as §4.1, plus `duration` (minutes, default `40`). It returns a new account every call (no reuse by email). The response `data` includes `accountNumber`, `accountName`, `bankName`, `isTemporary: true`, `expiresAt` and `expiresInMinutes`. Typical use: one account per order, shown at checkout with a countdown.

Transfers made **after** an account expires are refunded to the payer automatically, and you receive `refund.completed`. Don't treat late transfers as payments.

### 4.3 List: `GET /account/list?page=1&limit=10`

```json
{ "status": "success",
  "data": [ { "bankName": "Palmpay", "accountNumber": "6612345678", "email": "jane@example.com", "accountName": "…", "createdAt": "…" } ],
  "pagination": { "page": 1, "limit": 10, "total": 42, "pages": 5 } }
```

---

## 5. Refunds

A refund returns money from a **completed** virtual-account deposit to the payer. The amount is debited from the business balance immediately, and the balance must cover it.

### 5.1 Create: `POST /refund/create`

| Field | Type | Required | Notes |
|---|---|---|---|
| `orderNo` | string | one of the two | The payment's `orderNo` from the `payment.received` webhook |
| `transactionId` | string | one of the two | Gafiapay transaction `id` (e.g. `data.transaction.id` from a webhook) |
| `amount` | number | yes | Naira, > 0, ≤ original payment amount, ≤ 2 decimal places |
| `reason` | string | yes | ≤ 200 characters |

Each payment can have **one** refund (full or partial). Once a refund exists, the payment shows as `refunded`, and another refund attempt returns `404`. A **failed** refund makes the payment refundable again.

```json
// response 201
{ "status": "success", "data": { "refund": {
  "id": "6710a1c2…", "orderId": "REF1760500000123A1B2C3D4", "orderNo": "PP…", "amount": 1500, "formattedAmount": "1500.00",
  "currency": "NGN", "status": "pending", "reason": "Customer cancelled",
  "originalTransaction": { "id": "670f9b11…", "orderNo": "PP…", "amount": 2000 },
  "createdAt": "…" }, "message": "Refund initiated. …" } }
```

**Save `refund.orderId`** (starts with `REF`). It's the refund's reference for status lookups.

Errors: `400` (validation, amount too high, insufficient balance, provider rejected), `404` (no completed payment with that reference for this business, including payments already refunded), `409` (another refund for the business is processing; retry in a few seconds), `502` (provider unreachable; nothing moved, safe to retry).

### 5.2 Status: `GET /refund/status/:orderId`
`:orderId` is the refund's `REF…` orderId. It returns the same object as `refund` above. If the refund is still `pending`, the status is refreshed with the provider before the response. `status` is `pending`, `completed` or `failed`; a failed refund's amount is credited back to the balance.

### 5.3 List: `GET /refund/list?page=1&limit=20&status=pending`
Returns `{ refunds: [...], pagination: { page, limit, total, pages } }`. `limit` is at most 100, and `status` is optional (`pending`, `completed` or `failed`).

Final outcomes also arrive as `refund.completed` / `refund.failed` webhooks.

---

## 6. Webhooks

### 6.1 Delivery
Gafiapay `POST`s JSON to the business's webhook URL with these headers:
```
Content-Type: application/json
x-api-key:   <your API key>
x-timestamp: <ms timestamp>
x-signature: hex( HMAC_SHA256( secretKey, x-timestamp ) )
```
**The webhook signature covers only the timestamp, not the body.** This is different from request signing. `timestamp` and `signature` are also repeated inside the body.

Respond with **HTTP 2xx quickly (under 10 seconds)**, then do slow work asynchronously. Any non-2xx or timeout is retried up to **8 times**, roughly 5s, 30s, 4m, 10m, 1h, 2h, 6h and 15h later. Each retry has a fresh timestamp and signature.

### 6.2 Events

| `event` | When | Confirm with |
|---|---|---|
| `payment.received` | Money arrived in a virtual account (permanent or temporary) | dedupe on `data.transaction.id` |
| `refund.completed` / `refund.failed` | A refund reached a final state. Also sent when a late transfer to an expired account is auto-refunded. | `GET /refund/status/:orderId` |

`payment.received`:
```json
{ "event": "payment.received",
  "data": { "transaction": {
    "id": "671…", "orderNo": "PP25101500012345", "email": "jane@example.com",
    "amount": 5000, "currency": "NGN", "status": "completed",
    "charges": { "amount": 50, "type": "payment", "chargeType": "percentage", "percentage": 1, "cap": 300 },
    "metadata": { "payerAccountName": "JOHN SMITH", "payerAccountNo": "0123456789", "payerBankName": "GTBank",
                  "virtualAccountNo": "6612345678", "virtualAccountName": "Merchant-Jane Doe",
                  "grossAmount": 5000, "netAmount": 4950, "chargeAmount": 50 },
    "description": "incoming payment of NGN5000 from JOHN SMITH (GTBank)" } },
  "timestamp": "1760500000000", "signature": "…" }
```
`amount` is what the payer sent. `metadata.netAmount` is what was credited after fees. Keep `orderNo` and `id`, because you'll need them to refund this deposit.

`refund.*`:
```json
{ "event": "refund.completed",
  "data": { "transaction": { "id": "…", "orderId": "REF…", "orderNo": "…", "amount": 1500, "currency": "NGN",
    "status": "completed", "originalTransaction": "<payment transaction id>", "reason": "…", "completedAt": "…", "failedAt": null } },
  "timestamp": "…", "signature": "…" }
```

### 6.3 Handler requirements

1. Read the **raw** request and parse the JSON yourself.
2. Check that `x-api-key` equals your API key.
3. Compute `hex(HMAC_SHA256(secretKey, x-timestamp))` and compare it to `x-signature` with a **constant-time** comparison. Reject with `401` if they differ.
4. Reject timestamps more than ~10 minutes from now.
5. **Dedupe:** store `event + data.transaction.id` in a table with a unique constraint. If the pair was already processed, return `200` and stop.
6. For `refund.*`, **confirm the state** by calling the status endpoint in the table above before changing money-related records. The signature doesn't cover the body, so the API is the source of truth. For `payment.received`, check that `data.transaction.metadata.virtualAccountNo` is an account you issued and that it maps to the customer.
7. Return `200` quickly. Run the business logic in a job/queue if it's slow.

```js
// Express example
const crypto = require('crypto');
app.post('/webhooks/gafiapay', express.json(), async (req, res) => {
  const ts = req.get('x-timestamp') || '';
  const sig = req.get('x-signature') || '';
  const expected = crypto.createHmac('sha256', process.env.GAFIAPAY_SECRET_KEY).update(ts).digest('hex');
  const ok = req.get('x-api-key') === process.env.GAFIAPAY_API_KEY
    && sig.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    && Math.abs(Date.now() - Number(ts)) < 10 * 60 * 1000;
  if (!ok) return res.status(401).end();

  const { event, data } = req.body;
  const isNew = await markWebhookProcessed(`${event}:${data.transaction.id}`); // unique insert; false if seen
  res.status(200).json({ received: true });
  if (isNew) queueWebhookJob(event, data.transaction); // confirm via API inside the job (rule 6)
});
```

---

## 7. Implementation checklist

When integrating, the agent should produce:

- [ ] A single Gafiapay client module (signing per §2.2, error handling, base URL and keys from env).
- [ ] Database fields to persist every reference: Gafiapay `orderNo`, transaction `id`, refund `REF…` orderId, and the status.
- [ ] Only the flows the developer asked for (§4–§5).
- [ ] Server-side confirmation of webhooks before changing money-related records (§6.3).
- [ ] A webhook endpoint if virtual accounts or refunds are used (§6.3), registered in the dashboard.
- [ ] A background job that polls `pending` refunds that haven't resolved.
- [ ] Tests for the signer: given body `{"name":"John Doe","email":"john@example.com"}`, timestamp `1678901234567` and secret `test_secret`, the signature must be `86fa6f1ced575abf0998dedac310154af688e0058d33cf809bdb7f00c33b205c`. If your port produces anything else, the serialization is wrong (§2.2).
- [ ] A short README section telling the developer which env vars to set and which webhook URL to register.

Questions about the API or your account: support via the Gafiapay dashboard.
