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:
| Category | HTTP status | Examples | Action |
|---|---|---|---|
| Transient | 5xx | PROCESSING_FAILED, BANK_UNAVAILABLE, RAIL_UNAVAILABLE | Retry with exponential backoff |
| Rate limited | 429 | Too many requests | Wait for Retry-After header, then retry |
| Client error | 4xx (not 409, 422) | 401 Unauthorized, 404 Not Found | Fix request — do not retry |
| Permanent | 422 | INSUFFICIENT_FUNDS, INVALID_ACCOUNT, ACCOUNT_CLOSED | Requires user or operator action — do not retry blindly |
| Idempotency conflict | 409 | Duplicate key with different params | Logic 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.
- Node.js
- Python
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));
}
import time
import uuid
import random
import requests
import base64
def call_with_retry(request_fn, max_attempts=3):
idempotency_key = str(uuid.uuid4())
attempt = 0
while attempt < max_attempts:
attempt += 1
response = request_fn(idempotency_key)
if response.ok:
return response.json()
try:
body = response.json()
except Exception:
body = {}
code = body.get('error', {}).get('code', '')
category = classify_error(response.status_code, code)
if category in ('permanent', 'conflict'):
raise Exception(f"Request failed [{code}]: {body.get('error', {}).get('message', '')}")
if category == 'rate_limited':
retry_after = int(response.headers.get('Retry-After', 5))
time.sleep(retry_after)
continue
# Transient — exponential backoff
if attempt < max_attempts:
base = 1.0 * (2 ** (attempt - 1))
jitter = base * 0.1 * (random.random() * 2 - 1)
time.sleep(base + jitter)
raise Exception(f'Request failed after {max_attempts} attempts')
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 });
}
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:
- Do not retry with the same key.
- Fetch the original request to see what parameters were actually submitted.
- 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
5xxresponse that exhausts all retries INSUFFICIENT_FUNDS— your account needs topping upBANK_UNAVAILABLEpersisting 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);
}
}
Related
- Errors — complete HTTP status codes, domain error codes, and response format reference
- IDs & Idempotency — how idempotency keys prevent duplicates
- Handle Payout Failures — payout-specific failure recovery
- Setup Webhooks — receive failure events instead of polling