Errors, idempotency, and retries
Error codes, safe retries, lists, and versioning.
Error shape
Every error response has the same body, so you can branch on error.code:
{ "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
200and"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/customerswith anexternalUserIdreturns409 conflictif the customer exists. Fetch it withGET /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 and in each webhook's apiVersion. Additive changes can ship at any time. Breaking changes are announced at least 30 days in advance.