Idempotency & retries
A submission is a chargeable side effect. Idempotency keys are how you retry safely without double-charging.
The rule
idempotencyKeyis mandatory onPOST /v1/submissionsand unique per(tenantId, idempotencyKey)(DB-enforced).- Re-POSTing the same key + same body returns the existing submission with
200(not 201) andidempotent: true. No new row, no second charge. - Re-POSTing the same key + different body returns
409 idempotency_key_conflictwith the originalsubmissionIdin the body.
Pick a stable key
Derive the key from your own durable record — your queue row id, your application id — so the same logical job always produces the same key.
// Good: deterministic, survives a process restart.
const idempotencyKey = `apply-fun:queue:${queueRowId}`;
// Bad: a fresh random key on every attempt creates duplicate submissions.
const idempotencyKey = crypto.randomUUID(); // ← only for one-shot demos
Retry pattern
The SDK deliberately does not auto-retry POST /v1/submissions: a blind retry could double-charge if the first attempt reached the server but the response was lost. Instead, on a transient failure you re-call with the same key — the server dedupes.
import {
GoFundNodeClient,
GoFundNodeIdempotencyConflictError,
} from "@gofundnode/vendor-client";
async function submitWithRetry(client, input, maxAttempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
// Same idempotencyKey across every attempt → at-least-once, exactly-once charge.
return await client.submitAtsApplication(input);
} catch (err) {
if (err instanceof GoFundNodeIdempotencyConflictError) {
// Reused key with a DIFFERENT payload — a caller bug, not transient.
throw err;
}
if (attempt >= maxAttempts) throw err;
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); // backoff
}
}
}
Runnable: examples/idempotency-retry.ts in the SDK package demonstrates both the 200 replay and the 409 conflict.
What the SDK retries automatically
Safe-by-default reads + the idempotent cancel are retried with jittered exponential backoff on transport errors, 5xx, and 429:
GET /v1/submissions/:idGET /v1/credits/balanceGET /v1/rate-cardPOST /v1/submissions/:id/cancel(intrinsically idempotent)
Not auto-retried: POST /v1/submissions and POST /v1/credits/purchase-intent (both stateful). Use the same-key pattern above.
Cancellation is idempotent too
Cancelling an already-cancelled submission returns the same 200 receipt. Cancelling one that already terminated (submitted / failed) returns 409 terminal_state with the current status. See Submissions.
Webhooks are at-least-once
Webhook delivery is at-least-once; dedupe on the X-GFN-Delivery header before applying any side effect. See Webhooks.
docs/V1-API.md §7 (idempotency), in the repository.