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" }
}
FieldOn successOn failure
dataThe resource (object or array).null
errornull{ "code", "message", "details"? }
meta{ "request_id", "next_cursor"? }{ "request_id" }
  • meta.request_id is present on every response. Log it — it is the fastest way for support to trace a request.
  • meta.next_cursor appears 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.

CodeHTTPMeaningHow to resolve
AUTH_INVALID_KEY401API 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_MISMATCH401The 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_ERROR422Request body failed schema validation.Inspect details.fields and fix the offending fields.
IDEMPOTENCY_CONFLICT409The 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_FUNDS402Wallet balance is too low for this operation.Fund the wallet or lower the amount.
RAIL_UNAVAILABLE503The requested payment rail is temporarily unavailable (circuit breaker open).Retry with backoff, or use an alternate rail.
RATE_LIMITED429API rate limit exceeded.Back off; honor the Retry-After response header (seconds).
RESOURCE_NOT_FOUND404The resource does not exist or is not accessible to your merchant.Verify the ID; you can only access your own resources.
COMPLIANCE_HOLD403Action blocked pending compliance review, or the merchant account is suspended/frozen.Contact WasaaPay compliance; the block clears when review completes.
PERMISSION_DENIED403The authenticated principal may not perform this action.Use a principal/scope with the required permission.
INTERNAL_ERROR500An unexpected internal error (already logged).Retry later; if it persists, share the meta.request_id with support.

Notes:

  • 429 responses include a Retry-After header with the number of seconds to wait.
  • The error.code enum published in the OpenAPI reference lists the nine codes you can receive on documented endpoint responses; PERMISSION_DENIED and INTERNAL_ERROR are 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 200 and 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:

OperationEndpoint
Create collectionPOST /collections
Refund a collectionPOST /collections/{id}/refunds
Create checkout sessionPOST /checkout-sessions
Pay a checkout sessionPOST /checkout-sessions/{id}/pay
Onboard a sub-merchantPOST /sub-merchants
Create a payoutPOST /payouts
Create a bulk payoutPOST /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, 1100, default 50. Out-of-range values are clamped.
  • cursor — omit for the first page; then pass the previous response's meta.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);