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
500internal 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
| Step | Action |
|---|---|
| 1 | Generate a UUID v4 externalId and store it before sending anything. |
| 2 | Send the transfer request. |
| 3 | On timeout or 5xx, retry the identical request (same externalId) with exponential backoff — for example 2s, 4s, 8s, up to 4 attempts. |
| 4 | If retries are exhausted, stop writing and read: GET /transactions/{externalId}. |
| 5 | Apply 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
// 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
| Result | Meaning | Action |
|---|---|---|
404 Not found | The transfer was never created | Re-submit with the same externalId |
PENDING | The transfer exists and is being processed | Wait for the callback; do not resend |
SUCCESS | The beneficiary was paid | Mark as complete |
FAILED | The transfer failed definitively | Create 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.