Skip to content
SkanPay Docs

Idempotency

Mobile networks drop responses. An Idempotency-Key makes it safe to retry a request that moves money without ever charging or paying twice.

Every request that moves money requires an Idempotency-Key header:

  • POST /v1/charges
  • POST /v1/payouts
  • POST /v1/deposits
  • POST /v1/withdrawals

Without one, the request is refused with idempotency_key_required. The key is 8 to 255 characters. Your own order or reference id is the right choice: it is already unique per operation, and you have it on hand when you retry.

Request header
Idempotency-Key: order-9001

What happens on a retry

You sendSkanPay does
A key it has not seenProcesses the request normally.
The same key and the same bodyReturns the original response and status code, without doing anything again. The response carries Idempotent-Replayed: true.
The same key and a different body409 idempotency_key_reuse. A key names one operation.
The same key while the first request is still running409 idempotency_key_in_progress. Retry in a second or two.

Keys are scoped to your account and to the endpoint, so the same order id can be the key for a charge and, later, for a payout. They are kept for at least 24 hours.

If a request fails before anything is created (a validation error, an unknown number, a limit), its key is released, so a corrected retry with the same key is a fresh attempt. Once a charge exists, the key always returns that charge, whatever its outcome.

A safe retry loop

retry.mjs
async function createCharge(order) {
  const request = () =>
    fetch("https://api.skanpay.website/v1/charges", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SKANPAY_SECRET_KEY}`,
        "Idempotency-Key": order.id,          // the SAME key on every attempt
        "Content-Type": "application/json",
      },
      body: JSON.stringify({                  // the SAME body on every attempt
        amount: order.amount,
        currency: order.currency,
        msisdn: order.phone,
        reference: order.id,
      }),
    });

  for (let attempt = 1; attempt <= 4; attempt++) {
    try {
      const res = await request();
      // Retry only what can change on its own: 409 in progress, 429, 5xx.
      if (res.status === 409 || res.status === 429 || res.status >= 500) {
        const body = await res.json();
        if (body.error?.code === "idempotency_key_reuse") return body;
      } else {
        return await res.json();              // success, or an error to show the customer
      }
    } catch {
      // Network error or timeout: we do not know if it arrived. Retrying with
      // the same key is exactly what makes that safe.
    }
    await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
  }
  throw new Error("SkanPay unreachable; the order stays unpaid until a webhook says otherwise");
}

Want to charge the same order again after a genuine failure (the customer declined, then tried again)? That is a new operation: use a new key, for example order-9001-2, and a new reference if you want to tell the attempts apart.