Guide · 03 / 10
Making requests
The {data, error, meta} envelope, the error catalog, idempotency, and pagination.
This page covers the conventions shared by every endpoint: the response envelope, the error-code catalog, idempotency, and pagination.
The response envelope
Every response — success or failure — is a JSON object with exactly three top-level keys:
{
"data": { },
"error": null,
"meta": { "request_id": "req_9f2c1a" }
}
| Field | On success | On failure |
|---|---|---|
data | The resource (object or array). | null |
error | null | { "code", "message", "details"? } |
meta | { "request_id", "next_cursor"? } | { "request_id" } |
meta.request_idis present on every response. Log it — it is the fastest way for support to trace a request.meta.next_cursorappears only on paginated list responses (see Pagination).
A failure looks like:
{
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation.",
"details": { "fields": [{ "field": "amount", "message": "must be a positive integer" }] }
},
"meta": { "request_id": "req_9f2c1a" }
}
Always branch on the HTTP status and error.code — never parse
error.message, which is human-facing and may change.
Error-code catalog
These are the codes the platform returns, with their HTTP status. They are the
single source of truth from libs/common/src/http/errors.ts.
| Code | HTTP | Meaning | How to resolve |
|---|---|---|---|
AUTH_INVALID_KEY | 401 | API key missing, malformed, unknown, revoked, expired, IP-blocked, or out of scope. | Check the three auth headers and that the key is active and in-scope. See Authentication. |
AUTH_SIGNATURE_MISMATCH | 401 | The HMAC signature does not match the payload, or the timestamp is outside the ±300s window. | Re-check you signed `${timestamp}.${rawBody}` over the exact bytes sent, and sync your clock. |
VALIDATION_ERROR | 422 | Request body failed schema validation. | Inspect details.fields and fix the offending fields. |
IDEMPOTENCY_CONFLICT | 409 | The same Idempotency-Key was reused with a different payload. | Use a fresh key for a new operation, or resend the identical payload to replay. |
INSUFFICIENT_FUNDS | 402 | Wallet balance is too low for this operation. | Fund the wallet or lower the amount. |
RAIL_UNAVAILABLE | 503 | The requested payment rail is temporarily unavailable (circuit breaker open). | Retry with backoff, or use an alternate rail. |
RATE_LIMITED | 429 | API rate limit exceeded. | Back off; honor the Retry-After response header (seconds). |
RESOURCE_NOT_FOUND | 404 | The resource does not exist or is not accessible to your merchant. | Verify the ID; you can only access your own resources. |
COMPLIANCE_HOLD | 403 | Action blocked pending compliance review, or the merchant account is suspended/frozen. | Contact WasaaPay compliance; the block clears when review completes. |
PERMISSION_DENIED | 403 | The authenticated principal may not perform this action. | Use a principal/scope with the required permission. |
INTERNAL_ERROR | 500 | An unexpected internal error (already logged). | Retry later; if it persists, share the meta.request_id with support. |
Notes:
429responses include aRetry-Afterheader with the number of seconds to wait.- The
error.codeenum published in the OpenAPI reference lists the nine codes you can receive on documented endpoint responses;PERMISSION_DENIEDandINTERNAL_ERRORare platform-wide codes that can also occur.
Idempotency
Fund-moving and resource-creating POSTs require an Idempotency-Key header:
a client-generated string unique to one logical operation (a UUID v4 is ideal).
Idempotency-Key: 3f9a1c2e-...
Semantics
- First time a key is seen — the request runs normally and its result is stored against the key.
- Same key + identical payload — the stored original result is returned
without re-executing. The status is
200and the effect is not duplicated. This makes retries after a network timeout safe. - Same key + different payload — rejected with
409 IDEMPOTENCY_CONFLICT. A key is bound to the exact payload it was first used with (compared by a canonical, key-sorted SHA-256 hash of the body).
Keys are scoped to your merchant, so they never collide with another merchant's.
Where it is required
The header is required on:
| Operation | Endpoint |
|---|---|
| Create collection | POST /collections |
| Refund a collection | POST /collections/{id}/refunds |
| Create checkout session | POST /checkout-sessions |
| Pay a checkout session | POST /checkout-sessions/{id}/pay |
| Onboard a sub-merchant | POST /sub-merchants |
| Create a payout | POST /payouts |
| Create a bulk payout | POST /payouts/bulk |
GET requests never take an idempotency key. POST /split-rules and
POST /fx/rate-locks do not require one (they are naturally re-runnable / not
fund-moving).
Retry pattern
const key = crypto.randomUUID();
async function createWithRetry(body) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await post('/collections', body, { 'Idempotency-Key': key }); // same key every attempt
} catch (e) {
if (attempt === 2 || !isTransient(e)) throw e; // 5xx / network only
await sleep(250 * 2 ** attempt);
}
}
}
Reuse the same key across every retry of the same logical attempt; generate a new key for a genuinely new operation.
Pagination
List endpoints use opaque cursor pagination — never numeric offsets.
Request:
GET /transactions?limit=50&cursor=<opaque>
limit— page size,1–100, default50. Out-of-range values are clamped.cursor— omit for the first page; then pass the previous response'smeta.next_cursor.
Response:
{
"data": [ /* up to `limit` items */ ],
"error": null,
"meta": {
"request_id": "req_9f2c1a",
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNVQwOToxNTowMFoiLCJpZCI6IjNmYTg1ZjY0In0"
}
}
Keep following next_cursor until it comes back null, which means there are
no more pages. The cursor is a base64url token; treat it as opaque and pass it
back unchanged.
let cursor = undefined;
do {
const q = new URLSearchParams({ limit: '50', ...(cursor ? { cursor } : {}) });
const res = await get(`/transactions?${q}`);
for (const tx of res.data) handle(tx);
cursor = res.meta.next_cursor; // string, or null when done
} while (cursor);