Idempotency
Network calls fail. Retrying a failed POST without idempotency is the fastest way to mint two passes when you wanted one. Idempotency makes retries safe.
How it works
For every state-changing request (POST, PATCH, DELETE against an unstable id), send a unique Idempotency-Key header:
curl https://api.qairopay.com/v1/passes \ -H "Authorization: Bearer $QAIROPAY_KEY" \ -H "Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \ -H "Content-Type: application/json" \ -d '{ ... }'QairoPay stores the result (status code, headers, body) of the first request keyed by (tenant_id, idempotency_key). Any subsequent request with the same key, within the retention window, returns the stored result without re-running the operation.
Conventions
- Use UUID v4 or a ULID.
uuidgenandcrypto.randomUUID()both work. - One key per logical operation. If you’re issuing one pass, generate one key, and reuse it on every retry of that pass.
- A new logical operation needs a new key. Don’t reuse keys across distinct operations.
- Keys are tenant-scoped. The same key string is independent across tenants.
- Keys are case-sensitive and limited to 255 characters.
[a-zA-Z0-9_-]only.
With the TypeScript SDK
@qairopay/sdk handles idempotency for you. Every write call (POST, PATCH, DELETE) gets a UUID v4 attached automatically, and the same key is reused on every retry of the same logical operation — so 5xx retries can never duplicate writes:
import { QairoPay } from "@qairopay/sdk";
const qp = new QairoPay({ apiKey: process.env.QAIROPAY_KEY! });
// No idempotencyKey provided → SDK generates one and reuses it// on every retry the transport performs internally.const pass = await qp.passes.create({ template_id: "tpl_01HZX...", holder: { email: "rider@example.com" },});
// Need to control the key (e.g., derive it from your own request id)?// Pass it explicitly:const passWithKey = await qp.passes.create( { template_id: "tpl_01HZX...", holder: { email: "rider@example.com" } }, { idempotencyKey: "my-order-id-12345" },);The SDK never attaches Idempotency-Key on reads (GET/HEAD/OPTIONS) — reads are inherently idempotent.
Retention
Idempotency results are retained for 24 hours after the original request completes. Retries after that window are treated as new requests. If you need a longer retention, request an extension via your account contact.
What counts as a “match”
A retry must be byte-identical to the original request body. If the body differs, QairoPay returns:
HTTP/1.1 409 Conflict{ "error": { "type": "idempotency_conflict", "message": "Idempotency-Key was reused with a different request body.", "param": "Idempotency-Key" }}This is intentional: if you sent a different body, you almost certainly meant to perform a different operation and should generate a new key.
In-flight requests
If a retry arrives while the original is still in flight, the second request returns:
HTTP/1.1 409 Conflict{ "error": { "type": "idempotency_inflight", "message": "A request with this key is already in progress. Wait and retry.", "retry_after_ms": 250 }}Honor retry_after_ms with exponential backoff. The SDKs do this automatically.
When you don’t need a key
GET, HEAD, and OPTIONS requests are idempotent by HTTP semantics — no key needed. PUT operations against a known id (PUT /v1/passes/pass_XXX) are also naturally idempotent and ignore the header if present.
Patterns
A clean pattern: generate the key once when the user takes the action, persist it alongside your domain object, and reuse it on every retry until the operation succeeds.
async function issueLoyaltyPass(userId: string) { const idempotencyKey = (await db.passIssuance.findKey(userId)) ?? (await db.passIssuance.createKey(userId, crypto.randomUUID()));
return qp.passes.create({ /* ... */ }, { idempotencyKey });}This makes your client crash-safe: even if your process dies between sending the request and recording the response, restarting will retry with the same key and pick up the original result.