Docs

Transfer API

Error handling & retries

Network timeouts and 5xx errors are ambiguous: the transfer may or may not have been created. This page describes how to recover without ever double-paying a beneficiary.

What an ambiguous response is

An ambiguous response is any case where you did not receive a clear 2xx or 4xx answer:

  • The request timed out before a response arrived.
  • The connection was interrupted mid-request.
  • The API returned a 500 internal server error.

In each case the transfer may already exist on our side. Treating it as a failure and re-sending with a new reference is the one thing that can create a duplicate payout.

Golden rule

Never generate a new externalId when retrying. Always resend the exact same payload, with the exact same externalId.

Recommended strategy

StepAction
1Generate a UUID v4 externalId and store it before sending anything.
2Send the transfer request.
3On timeout or 5xx, retry the identical request (same externalId) with exponential backoff — for example 2s, 4s, 8s, up to 4 attempts.
4If retries are exhausted, stop writing and read: GET /transactions/{externalId}.
5Apply the outcome: 404 means the transfer was never created and can be safely re-submitted; PENDING means wait for the callback; SUCCESS / FAILED are final.

Reference implementation

retry-and-reconcile.js
// 1. Generate ONE externalId per logical transfer, and persist it.
const externalId = crypto.randomUUID();
await db.transfers.insert({ externalId, status: "UNKNOWN" });

// 2. Send the transfer. On timeout or 5xx, retry the SAME payload.
async function submit(attempt = 1) {
  try {
    const res = await fetch("https://api.katika-bridge.com/transactions/transfer", {
      method: "POST",
      headers: {
        "x-api-key": API_KEY,
        "x-api-secret": API_SECRET,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ ...payload, externalId }),
    });
    if (res.status >= 500) throw new Error("retryable");
    return await res.json();
  } catch (e) {
    if (attempt >= 4) {
      // 3. Stop retrying and reconcile instead.
      return reconcile(externalId);
    }
    await sleep(2 ** attempt * 1000);
    return submit(attempt + 1);
  }
}

// 4. Reconcile with the source of truth.
async function reconcile(externalId) {
  const res = await fetch(
    `https://api.katika-bridge.com/transactions/${externalId}`,
    { headers: { "x-api-key": API_KEY, "x-api-secret": API_SECRET } },
  );
  return res.json(); // PENDING | SUCCESS | FAILED, or 404 if never created
}

Interpreting the reconciliation result

ResultMeaningAction
404 Not foundThe transfer was never createdRe-submit with the same externalId
PENDINGThe transfer exists and is being processedWait for the callback; do not resend
SUCCESSThe beneficiary was paidMark as complete
FAILEDThe transfer failed definitivelyCreate a new transfer with a new externalId

Do not rely on callbacks alone

A missing callback does not mean the transfer failed — the notification may simply not have reached your endpoint. Always keep a reconciliation job that polls GET /transactions/{externalId} for transfers still marked as unknown or pending after a few minutes.