Skip to main content

Handle Errors and Retries

Every production integration hits errors. Banks go offline, rate limits kick in, and networks drop requests. This guide shows you how to classify NextAPI errors, build retry logic that won't create duplicate transactions, and surface failures to your team before they affect users.

Error classification

All NextAPI errors return a JSON body with a code, message, and request_id. See Errors for the full response format and complete domain error code reference.

The first decision when handling an error is whether to retry. Errors fall into four categories:

CategoryHTTP statusExamplesAction
Transient5xxPROCESSING_FAILED, BANK_UNAVAILABLE, RAIL_UNAVAILABLERetry with exponential backoff
Rate limited429Too many requestsWait for Retry-After header, then retry
Client error4xx (not 409, 422)401 Unauthorized, 404 Not FoundFix request — do not retry
Permanent422INSUFFICIENT_FUNDS, INVALID_ACCOUNT, ACCOUNT_CLOSEDRequires user or operator action — do not retry blindly
Idempotency conflict409Duplicate key with different paramsLogic error — inspect the original request, do not retry
function classifyError(httpStatus, errorCode) {
if (httpStatus === 429) return 'rate_limited';
if (httpStatus === 409) return 'conflict';
if (httpStatus >= 500) return 'transient';

const transientCodes = ['BANK_UNAVAILABLE', 'RAIL_UNAVAILABLE', 'PROCESSING_FAILED'];
if (transientCodes.includes(errorCode)) return 'transient';

// 4xx with a domain code = permanent (bad input, insufficient funds, etc.)
return 'permanent';
}

Retry with exponential backoff

For transient errors, retry with exponential backoff and jitter. Always reuse the same X-Idempotency-Key on every attempt — this ensures the API deduplicates requests that may have reached the server before the network dropped.

import crypto from 'crypto';

async function callWithRetry(requestFn, maxAttempts = 3) {
// Generate the idempotency key once — never change it on retry
const idempotencyKey = crypto.randomUUID();
let attempt = 0;

while (attempt < maxAttempts) {
attempt++;

try {
const res = await requestFn(idempotencyKey);

if (res.ok) return await res.json();

const body = await res.json().catch(() => ({}));
const code = body?.error?.code ?? '';
const category = classifyError(res.status, code);

if (category === 'permanent' || category === 'conflict') {
throw Object.assign(new Error(body?.error?.message ?? 'Request failed'), {
category,
code,
status: res.status,
requestId: body?.error?.request_id,
});
}

if (category === 'rate_limited') {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '5', 10);
await sleep(retryAfter * 1000);
continue;
}

// Transient — exponential backoff with ±10% jitter
if (attempt < maxAttempts) {
const base = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s
const jitter = base * 0.1 * (Math.random() * 2 - 1);
await sleep(base + jitter);
}

} catch (err) {
// Re-throw permanent errors immediately
if (err.category === 'permanent' || err.category === 'conflict') throw err;
if (attempt === maxAttempts) throw err;
}
}

throw new Error(`Request failed after ${maxAttempts} attempts`);
}

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

Wrapping a payout with retry

Using callWithRetry for a payout request:

async function createPayoutWithRetry(accountId, payload) {
const credentials = Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString('base64');

return callWithRetry((idempotencyKey) =>
fetch(`https://api.partners.nextpay.world/v2/payout-requests`, {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ account_id: accountId, ...payload }),
})
);
}

Handling rate limits

NextAPI enforces per-credential rate limits. When you exceed them, you receive a 429 with a Retry-After header:

HTTP/1.1 429 Too Many Requests
Retry-After: 3

The value is in seconds. Always honour this header rather than using a fixed delay. The callWithRetry function above handles this automatically.

For batch operations (mass payroll, bulk collection), introduce deliberate spacing between requests to stay within limits:

async function batchWithRateLimit(items, processFn, delayMs = 100) {
const results = [];

for (const item of items) {
results.push(await processFn(item));
await sleep(delayMs);
}

return results;
}

Recovering from a crashed process

If your server crashes mid-flight, you may not know whether a request reached NextAPI. Use external_id to look up a resource by your own identifier before creating a new one:

async function getOrCreatePayout(externalId, accountId, payload) {
const credentials = Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString('base64');

// Check if the payout already exists
const lookup = await fetch(
`https://api.partners.nextpay.world/v2/payout-requests?external_id=${externalId}`,
{ headers: { 'Authorization': `Basic ${credentials}` } }
);

if (lookup.ok) {
const existing = await lookup.json();
if (existing.data?.length > 0) {
return existing.data[0]; // Already created — return it
}
}

// Not found — safe to create
return createPayoutWithRetry(accountId, { ...payload, external_id: externalId });
}
tip

Set a stable external_id before any network call — derived from your internal record ID. If the call succeeds but your process crashes before saving the response, the lookup will recover it on restart.

Idempotency conflicts (409)

A 409 Conflict means an X-Idempotency-Key was reused with different request parameters. This is a code bug, not a transient failure:

  1. Do not retry with the same key.
  2. Fetch the original request to see what parameters were actually submitted.
  3. Fix the mismatch — then create a new request with a fresh key.

Common cause: reusing a key across different transaction types (e.g., using the same key for a payout and a transfer).

Setting up error alerts

Log the request_id from every error response. Set up alerts for:

  • Any 5xx response that exhausts all retries
  • INSUFFICIENT_FUNDS — your account needs topping up
  • BANK_UNAVAILABLE persisting beyond one retry cycle — may indicate a wider rail outage
function handleFinalError(err, context) {
console.error({
message: err.message,
code: err.code,
status: err.status,
request_id: err.requestId,
context, // e.g. { payoutId, merchantId }
});

// Alert your on-call channel if the error is operational
if (err.code === 'INSUFFICIENT_FUNDS' || err.status >= 500) {
alertOncall(`NextAPI error [${err.code}]: ${err.message}`, context);
}
}