Idempotency

Network errors happen. If you retry a request that creates an order or moves money, it must not run twice. Send an Idempotency-Key header and Qasimha answers a retry with the stored result of the first request instead of running it again.

How to use it

Send an Idempotency-Key header on every POST, PUT, PATCH and DELETE:

  • Use a fresh UUID v4 for each new operation, such as "create order 10001" or "refund 100.00 on order X".
  • Reuse the same key and the same body when you retry that operation.
  • Store the key with the operation before you send it, so a retry after a crash or restart can reuse it.
curl -X POST https://api-uat.qasimha.com/api/v1/pay/session/create \
  -H "X-Api-Key: $QASIMHA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a9e-3b7d-4e8a-9c41-2d5f8b0e7a13" \
  -d @order.json

The header is optional. A request without it runs every time. GET requests ignore it; they are always safe to repeat.

How a key is matched

  • Scope: a key belongs to your merchant account, the HTTP method and the path. The same key on a different endpoint, or from another merchant, is a different key. Retries through a different API key of the same merchant still match.
  • Body: the first request's method, path and body are fingerprinted. A retry must send the byte-for-byte same body. Re-serialising the JSON with different spacing or key order counts as a different body.
  • Lifetime: keys are kept for 24 hours. After that, the same key is treated as new.

What happens on a repeat

RepeatResponseWhat to do
Same key, same body, first request finishedThe stored response, with its original HTTP status and the header Idempotent-Replay: trueTreat it as the original answer
Same key, first request still running409 REQUEST_IN_PROGRESSWait a moment and retry with the same key
Same key, different body422 IDEMPOTENCY_KEY_REUSEDA bug in your code. Use a new key for a new request
First request failed with an unexpected server errorThe key is releasedRetry with the same key; the request runs again
First request never finished (no answer after 60 seconds)The next retry takes the key over and runsRetry with the same key

Things to know:

  • Errors are stored too. If the first request got a 4xx from the endpoint, such as 400 VALIDATION_ERROR, 403 FORBIDDEN or 409 DUPLICATE_ORDER_REFERENCE, a retry with the same key and body replays that same error. Fix the request and send it with a new key; the same key with the corrected body returns 422 IDEMPOTENCY_KEY_REUSED.
  • Rejected before the endpoint, not stored. 401 (bad key) and 429 (rate limit) happen before the key is recorded. After fixing the key, or after waiting, retry with the same key.
  • The replay is the first answer. A replayed create returns the order as it was at creation, for example status: "AwaitingCheckout". Read the order to see where it is now.

Which calls to protect

CallWhy
POST /pay/session/create, /pay/pos/create, /pay/qr/createOne basket, one order
POST /merchant/checkout-linksOne link, one SMS
POST /merchant/orders/{id}/fulfillA repeated partial fulfilment would count twice
POST /merchant/orders/{id}/refundA repeated refund would refund twice
POST /merchant/orders/{id}/capture, /cancel, POST /merchant/checkout-links/{id}/cancelSafe to repeat anyway, but a replay gives you the original answer
POST, PUT, DELETE /merchant/webhooks…One endpoint, one secret

A retry loop

import { randomUUID } from 'node:crypto';

async function createSession(body: object) {
  const key = randomUUID();                       // one key for every attempt of this operation
  const payload = JSON.stringify(body);           // serialise once, send the same bytes each time

  for (let attempt = 0; attempt < 4; attempt++) {
    let res: Response;
    try {
      res = await fetch('https://api-uat.qasimha.com/api/v1/pay/session/create', {
        method: 'POST',
        headers: { 'X-Api-Key': process.env.QASIMHA_API_KEY!, 'Content-Type': 'application/json', 'Idempotency-Key': key },
        body: payload,
      });
    } catch {
      await sleep(backoff(attempt));              // network error: retry with the same key
      continue;
    }
    if (res.status === 429 || res.status === 409 && (await peekCode(res)) === 'REQUEST_IN_PROGRESS'
        || res.status === 502 || res.status === 503 || res.status === 504) {
      await sleep(retryAfterMs(res) ?? backoff(attempt));
      continue;
    }
    return res;                                    // success, a replay, or an error to handle
  }
  throw new Error('Qasimha unavailable, try again later');
}

const backoff = (n: number) => Math.min(2 ** n * 500, 8000) + Math.random() * 250;

peekCode, retryAfterMs and sleep are your own helpers: read code from a clone of the response body, read the Retry-After header, and wait.

Your own reference helps too

Give every order a unique orderReferenceId. Even with a new idempotency key, a second create with the same reference returns 409 DUPLICATE_ORDER_REFERENCE. On a payment session, when the order is yours, data includes its installmentId and paymentUrl, so you can resume the open checkout. See Choose a checkout flow.

The server SDKs use both layers: if a retried createSession gets 409 DUPLICATE_ORDER_REFERENCE naming your order, the SDK returns that order as the session, because the earlier attempt succeeded.

Troubleshooting

422 IDEMPOTENCY_KEY_REUSED on a retry

The retry's body differs from the first request's. Serialise the body once and resend the same bytes. If you changed the request on purpose, use a new key.

I fixed a validation error but keep getting the same 400

You are still sending the old key with the old body, so the stored 400 is replayed. Send the corrected request with a new key.

409 REQUEST_IN_PROGRESS keeps coming back

The first request is still running. Back off and retry. A request that has made no progress for 60 seconds is released to the next retry.

Related


Did this page help you?