Idempotency & retries

Which Fungies API calls are safe to retry, how to make a refund retry safe, and how to deduplicate webhook events.

Networks drop responses. When that happens you know your request may have been applied, but you can't tell. This page says which calls you can safely repeat.

What's safe to retry

RequestSafe to retry?
Any GETYes. Reads have no side effects.
PATCH /v0/orders/{orderIdOrNumber}/refundYes, with your own idempotencyKey — see below.
Every other write (POST, PATCH, DELETE)Treat as not idempotent.

For every other write, don't blind-retry after a lost response. Read the resource back first (list or fetch it by id) and only re-send if the change clearly didn't land.

Refunds

PATCH /v0/orders/{orderIdOrNumber}/refund accepts an optional idempotencyKey body field, 1–255 characters. Always send one.

curl -X PATCH "https://api.fungies.io/v0/orders/L8VQK3N2M7KpQ9nR/refund" \
  -H "x-fngs-public-key: pub_your_public_key" \
  -H "x-fngs-secret-key: sec_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 1999,
    "reason": "requested_by_customer",
    "idempotencyKey": "refund-order-42"
  }'

Use a key that is stable across retries of the same intent — derive it from your own refund record's id, not from a timestamp or a random value generated per attempt.

Why the key matters

Send your own key on any refund you might retry. Without one we derive a key from the request itself, which deduplicates a retry for 23 hours and no longer — past that window an identical request is treated as a new refund and the customer is refunded again.

Repeating a non-exhausting partial refund returns the original refund and makes no further changes: no duplicate refund, no status change, no extra customer notification. The response carries "replayed": true when that happens, so you can tell an existing refund from one this call created.

To refund the same amount twice on purpose — a second goodwill credit, say — send a different idempotencyKey, or vary the metadata: both are part of the request the derived key is built from. Repeating the identical body inside the 23-hour window returns the first refund instead.

A repeat that a fresh eligibility check would now reject — a full refund, a partial that exhausts the remaining balance, or one exceeding the currently refundable amount — returns 400 instead. That is a signal to stop and reconcile, not to retry harder: fetch the order and check its refund state, or contact support to confirm what the original call did.

Retry strategy

For retryable failures — 429 and 5xx — back off exponentially with jitter rather than retrying immediately.

async function withRetry(request, { attempts = 5 } = {}) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    const response = await request();
    if (response.ok) return response;

    const retryable = response.status === 429 || response.status >= 500;
    if (!retryable || attempt === attempts - 1) return response;

    const backoff = 2 ** attempt * 500;
    await sleep(backoff + Math.random() * 250);
  }
}

Pick your own retry schedule. See Rate limits.

Webhook events

Delivery is at-least-once, so your endpoint will occasionally receive the same event twice — after a network blip, or after a retry that raced your response.

Deduplicate on the event's idempotencyKey, which is always equal to its id:

app.post("/webhooks/fungies", async (req, res) => {
  const event = req.body;

  // Acknowledge fast, then process — record the key before doing any work.
  const isNew = await markProcessed(event.idempotencyKey);
  res.sendStatus(200);

  if (!isNew) return;
  await handleEvent(event);
});

Events can also arrive out of order — subscription_created and payment_success for the same signup are produced by different upstream notifications and may land seconds apart in either order. Key your handler off the event's contents rather than assuming arrival order. See the Event object and Webhooks overview.

Last updated