# Limits and quotas

import { ApiExample, ApiResponse } from "../../../src/ApiExample";

## Rate limiting

Every response carries the state of your quota:

```http title="Response headers"
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 583
X-RateLimit-Reset: 1788171600
```

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | requests allowed in the current window |
| `X-RateLimit-Remaining` | requests left |
| `X-RateLimit-Reset` | Unix timestamp (seconds) of the reset |

Going over returns `429`:

<ApiResponse
  status={429}
  body={{
    error: {
      code: "RATE_LIMIT_EXCEEDED",
      message: "Trop de requêtes, réessayez après la fenêtre indiquée",
      retryAfterSeconds: 34,
    },
  }}
/>

**Respect `X-RateLimit-Reset`** instead of retrying straight away: hammering a `429` extends the outage.

```javascript title="JavaScript"
async function call(url, options, attempt = 0) {
  const response = await fetch(url, options);

  if (response.status !== 429 || attempt >= 3) return response;

  const reset = Number(response.headers.get("X-RateLimit-Reset")) * 1000;
  const wait = Math.max(reset - Date.now(), 2 ** attempt * 1000);
  await new Promise((r) => setTimeout(r, wait));

  return call(url, options, attempt + 1);
}
```

```python title="Python"
import time

def call(session, url, headers, attempt=0):
    response = session.get(url, headers=headers, timeout=30)

    if response.status_code != 429 or attempt >= 3:
        return response

    reset = int(response.headers["X-RateLimit-Reset"])
    time.sleep(max(reset - time.time(), 2**attempt))

    return call(session, url, headers, attempt + 1)
```

Quotas are set per introducer account, not per key: adding keys does not raise them. If your legitimate volume exceeds them, ask your Instafuel contact for a higher limit rather than working around it.

<Callout type="tip" title="Writes cost more than reads">
  Write operations have a narrower window than reads. A bulk onboarding loop must be spread out, not fired in a burst.
</Callout>

## Pagination

Every collection is paginated, with the same shape.

| Parameter | Default | Maximum |
|---|---|---|
| `page` | 1 | — |
| `perPage` | 20 | 100 |

<ApiExample path="/v1/papi/transactions" query={{ page: 2, perPage: 50 }} />

<ApiResponse
  status={200}
  body={{
    data: [
      {
        ref: "TRX-9D02C15E",
        companyId: 4790,
        station: { id: 12, name: "Station Yopougon Ananeraie" },
        driver: { id: 3311, name: "Salif Traoré" },
        vehicle: { id: 812, registration: "5678 CD 01" },
        fuelType: "SUPER",
        liters: { estimate: 30, actual: 30 },
        selfService: false,
        unitPriceFcfa: 880,
        amountFcfa: 26400,
        status: "CONFIRMED",
        createdAt: "2026-08-29T16:05:52.000Z",
        confirmedAt: "2026-08-29T16:05:52.000Z",
      },
      {
        ref: "TRX-1A73F4B0",
        companyId: 4832,
        station: { id: 12, name: "Station Plateau" },
        driver: { id: 3311, name: "Yao N'Guessan" },
        vehicle: { id: 812, registration: "1234 AB 01" },
        fuelType: "GASOIL",
        liters: { estimate: 55, actual: 55 },
        selfService: false,
        unitPriceFcfa: 750,
        amountFcfa: 41250,
        status: "PREAUTHORIZED",
        createdAt: "2026-08-29T11:22:07.000Z",
        confirmedAt: "2026-08-29T11:22:07.000Z",
      },
    ],
    pagination: {
      page: 2,
      perPage: 50,
      total: 318,
      totalPages: 7,
    },
  }}
/>

A `PENDING` transaction is an open fill-up: funds are held, not yet debited. Do not add it to `VALIDATED` ones in a spend total.

Walking the whole collection without hammering the API:

```javascript title="JavaScript"
async function allTransactions(base, params = {}) {
  const all = [];
  let page = 1;
  let totalPages = 1;

  while (page <= totalPages) {
    const query = new URLSearchParams({
      ...params,
      page: String(page),
      perPage: "100",
    });

    const response = await call(`${base}/v1/papi/transactions?${query}`, {
      headers: { Authorization: `Bearer ${process.env.INSTAFUEL_API_KEY}` },
    });

    const { data, pagination } = await response.json();
    all.push(...data);
    totalPages = pagination.totalPages;
    page += 1;
  }

  return all;
}
```

```php title="PHP"
<?php
function allTransactions(string $base, array $params = []): array
{
    $all = [];
    $page = 1;
    $totalPages = 1;

    while ($page <= $totalPages) {
        $query = http_build_query($params + ['page' => $page, 'perPage' => 100]);
        [$status, $body] = call($base . '/v1/papi/transactions?' . $query);

        $all = array_merge($all, $body['data']);
        $totalPages = $body['pagination']['totalPages'];
        $page++;
    }

    return $all;
}
```

<Callout type="caution" title="Date-sorted collections move while you paginate">
  Transactions come back newest first. If new ones land between two pages, an item can be seen twice or missed.

  For a reliable export, bound the period with `from` and `to` on elapsed dates, or deduplicate on `ref` on arrival.
</Callout>

## Large volumes

There is no file export on this API: it only produces JSON. Two habits are enough to hold the load.

**Slice by period, not by page.** A one-month range cut into days gives stable, replayable batches, and avoids the pagination drift described above.

**Consolidate with the summary.** For totals, `GET /v1/papi/reports/portfolio-summary` returns in one call what paginating transactions would make you recompute over hundreds of pages.

```http title="Endpoints"
GET /v1/papi/reports/portfolio-summary?from=2026-08-01&to=2026-08-31
```

If you need a spreadsheet, download it from the partner portal.

## Data freshness

| Data | Freshness |
|---|---|
| Wallet balance, transactions | real time |
| Portfolio-wide balances | real time, computed on the fly |
| Portfolio summary | computed at call time over the requested period |

For a real-time display of a company's balance, read its wallet directly rather than the consolidated summary.

## Idempotency and replay

The `X-Idempotency-Key` header and the retry policy are covered in [Errors and idempotency](/en/guides/erreurs). Worth repeating here: a `429` is retried **with the same key**.

## Timeouts and drops

Allow a client-side timeout of at least **30 seconds** on writes and proof uploads: a 10 MB file over an average Ivorian link does not arrive in two seconds.

If the connection drops before the response, do not guess — replay with the same idempotency key.
