Skip to main content

Handle Payout Failures

Payouts can fail for reasons that are either permanent (wrong account number) or temporary (bank temporarily unavailable). This guide shows you how to classify errors and build retry logic that recovers from transient failures without creating duplicate transactions.

Error classification

Not all errors should be retried. Classify by whether a retry can succeed:

CategoryExamplesAction
TransientBANK_UNAVAILABLE, RAIL_UNAVAILABLE, 500 server errorsRetry with exponential backoff
Rate limited429 Too Many RequestsRetry after the indicated delay
PermanentINSUFFICIENT_FUNDS, INVALID_ACCOUNT, ACCOUNT_CLOSEDDo not retry — requires user action
Conflict409 Idempotency conflictDo not retry — inspect the original request
function classifyError(status, code) {
if (status === 429) return "rate_limited";
if (status === 409) return "conflict";
if (status >= 500) return "transient";

const transient = ["BANK_UNAVAILABLE", "RAIL_UNAVAILABLE", "PROCESSING_FAILED"];
if (transient.includes(code)) return "transient";

return "permanent";
}

Retry with exponential backoff

Use exponential backoff with jitter to avoid thundering-herd problems when retrying transient failures. Always reuse the same X-Idempotency-Key on retries — this ensures the API deduplicates any duplicate requests that slip through.

import crypto from "crypto";

async function createPayoutWithRetry(accountId, payload, maxAttempts = 3) {
// Generate a stable key once — reuse it on every retry attempt
const idempotencyKey = crypto.randomUUID();

const credentials = Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString("base64");

let attempt = 0;

while (attempt < maxAttempts) {
attempt++;

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

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 ?? "Payout failed"), {
category,
code,
status: res.status,
});
}

if (attempt === maxAttempts) {
throw Object.assign(new Error(`Payout failed after ${maxAttempts} attempts`), {
category,
code,
status: res.status,
});
}

// Exponential backoff with jitter: 1s, 2s, 4s (± 10%)
const base = 1000 * Math.pow(2, attempt - 1);
const jitter = base * 0.1 * (Math.random() * 2 - 1);
await new Promise((r) => setTimeout(r, base + jitter));
}
}

Checking payout status after failure

If your process crashes mid-flight, use external_id to look up the payout request without needing the server-assigned ID:

async function getPayoutByExternalId(externalId) {
const credentials = Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString("base64");

const res = await fetch(
`https://api.partners.nextpay.world/v2/payout-requests?external_id=${externalId}`,
{ headers: { Authorization: `Basic ${credentials}` } }
);

return res.ok ? await res.json() : null;
}

If the lookup returns a completed payout, do not retry — the original request succeeded even if your code never received the response.

Handling 409 Idempotency conflict

A 409 means the same X-Idempotency-Key was used with different parameters. This is a logic error, not a transient failure:

  1. Do not retry with the same key.
  2. Fetch the original payout request to see what was actually submitted.
  3. Fix the mismatch in your code before creating a new request with a new key.

Webhook-based confirmation

Instead of polling, listen for payout_request.completed or payout_request.failed webhook events. This is the most reliable way to confirm final status:

// Express webhook handler (abbreviated)
app.post("/webhooks/nextapi", (req, res) => {
const event = req.body;

if (event.event === "payout_request.failed") {
const { payout_request_id, failure_reason } = event.data;
// Log, alert, or queue for manual review
}

res.sendStatus(200);
});

See Setup Webhooks for signature verification and endpoint registration.

Common errors quick reference

Error codeStatusDo not retryFix
INSUFFICIENT_FUNDS422Top up the account before retrying
INVALID_ACCOUNT422Verify recipient account number
ACCOUNT_CLOSED422Use a different recipient account
BANK_UNAVAILABLE422Retry later; check GET /v2/service-health
RAIL_UNAVAILABLE422Retry later or wait for the PESONet batch window
PROCESSING_FAILED422Retry once; check payout details for provider reason
AMOUNT_ABOVE_MAXIMUM422Split into multiple requests or switch rails