# Errors, idempotency, and retries

## Error shape

Every error response has the same body, so you can branch on `error.code`:

```json
{ "error": { "code": "insufficient_balance", "message": "Insufficient spendable balance." } }
```

| Code | Status | What to do |
| --- | --- | --- |
| `invalid_request` | 400 | Fix the request. `message` names the field. Do not retry unchanged. |
| `unauthorized` | 401 | Send a valid key as `Authorization: Bearer pk_…`, for the right environment. |
| `forbidden` | 403 | The key cannot perform this action. |
| `not_found` | 404 | The resource does not exist in this account and environment. |
| `conflict` | 409 | The current state does not allow the action, for example a sealed or already used card. Fetch the resource and decide. |
| `idempotency_conflict` | 409 | The `Idempotency-Key` was already used with different data. Use a new key for a new request. |
| `insufficient_balance` | 409 | Top up, then retry with the same key. |
| `out_of_stock` | 409 | The vending machine cannot supply the quantity. |
| `service_unavailable` | 503 | A dependency is not ready. Retry later with backoff. |

Unrecognized fields in a request body are rejected with `400`, so typos surface immediately. Responses, on the other hand, may gain new fields at any time: ignore fields you do not know.

## Idempotency

`POST /api/v2/operations` requires an `Idempotency-Key` header of 1 to 255 characters.

- The first request with a key creates the operation and returns `201`.
- Repeating the **same** request with the same key returns the original operation with `200` and `"replayed": true`. Nothing is charged or credited again.
- Reusing the key with **different** data returns `409 idempotency_conflict`.
- Keys are scoped to your account and environment and do not expire.

Derive the key from your own record, such as `order-{yourOrderId}`, and store it before calling the API. If a request times out or fails with a network error, retry with the same key: you will get the operation that was created, or create it now.

Other writes are safe to retry for different reasons:

- `POST /api/v2/customers` with an `externalUserId` returns `409 conflict` if the customer exists. Fetch it with `GET /api/v2/customers/by-external-id/{externalUserId}`.
- Reveal, refresh, and webhook changes can be repeated without side effects beyond the first.

## Retries

Retry network errors, `5xx`, and `503` with exponential backoff and jitter, for example 1, 2, 4, 8 seconds, up to a minute. Do not retry `4xx` responses other than `409 insufficient_balance` after a top-up.

If you receive `429 Too Many Requests`, wait for the `Retry-After` header before retrying. Keep concurrency per account modest; Packflip may apply rate limits to protect the service.

## Lists

List endpoints return the newest items first and accept `limit`:

| Endpoint | Default | Maximum |
| --- | --- | --- |
| `GET /api/v2/customers` | 50 | 200 |
| `GET /api/v2/customers/{id}/cards` | 50 | 200 |
| `GET /api/v2/ledger-entries` | 50 | 200 |
| `GET /api/v2/funding-transactions` | 50 | 100 |
| `GET /api/v2/funding-intents` | 50 | 100 |

Keep your own record of operations and customers rather than paging through everything.

## Money and time

- Amounts are decimal strings with six places, such as `"12.500000"`. Use a decimal library; never parse them as floats.
- Timestamps are ISO 8601 in UTC.
- IDs are opaque strings with a type prefix: `par_`, `cus_`, `card_`, `op_`, `oco_`, `ocb_`, `shp_`, `wh_`, `evt_`, `ftx_`. Do not parse them.

## Versioning

The API version is a date, currently `2026-09-12`, shown in the [OpenAPI document](/openapi.json) and in each webhook's `apiVersion`. Additive changes can ship at any time. Breaking changes are announced at least 30 days in advance.
