Idempotency
Retry safely — Idempotency-Key and the unique external_id make sure a timeout never creates two orders.
Networks fail: a request can time out after the order was already created. The Partner API gives you two independent protections against duplicates.
Idempotency-Key
Send a unique Idempotency-Key header with every creating request — POST /v1/orders and
POST /v1/orders/batch:
POST /v1/orders HTTP/1.1
Authorization: Bearer sk_test_…
Idempotency-Key: 7f0c1e9a-3d2b-4c8e-9a51-2b6d0e4f8a13
Content-Type: application/json| Repeat within 24 hours | Result |
|---|---|
| same key, same body | the first response is returned again, byte for byte — status, body, the same order |
| same key, another body | 409 idempotency_conflict — nothing is created |
| same key while the first request is still running | 409 idempotency_in_progress — retry in a second |
Keys live for 24 hours per API key environment. Use 1–255 printable ASCII characters; a UUID v4 is the simplest choice. Generate the key once per business operation, store it next to your order and reuse it for every retry of that operation.
import { randomUUID } from "node:crypto";
async function createOrder(order) {
order.idempotencyKey ??= randomUUID(); // persisted with the order before the first attempt
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch("https://partner.sapamoto.kz/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAPA_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": order.idempotencyKey,
},
body: JSON.stringify(order.payload),
signal: AbortSignal.timeout(10_000),
});
if (res.status === 409 && (await res.clone().json()).code === "idempotency_in_progress") throw new Error("retry");
if (res.status >= 500) throw new Error(`HTTP ${res.status}`);
return res; // 2xx or a business error — do not retry
} catch (e) {
if (attempt === 5) throw e;
await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
}
}
}import time, uuid, requests
def create_order(order):
order.setdefault("idempotency_key", str(uuid.uuid4())) # persisted before the first attempt
for attempt in range(1, 6):
try:
res = requests.post(
"https://partner.sapamoto.kz/v1/orders",
headers={"Authorization": f"Bearer {KEY}", "Idempotency-Key": order["idempotency_key"]},
json=order["payload"],
timeout=10,
)
retry = res.status_code >= 500 or (res.status_code == 409 and res.json()["code"] == "idempotency_in_progress")
if not retry:
return res
except requests.RequestException:
pass
time.sleep(2 ** attempt * 0.25)
raise RuntimeError("Sapa Moto is unavailable")external_id
external_id is your own order number. It is unique per organization and environment — independently of the
idempotency key. A second order with an existing external_id (from another key, another server or a week later) is
refused:
{
"type": "urn:sapa:problem:duplicate_external_id",
"status": 409,
"code": "duplicate_external_id",
"message": "Заказ с таким внешним номером уже есть",
"details": [],
"order_id": "0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"order_url": "/v1/orders/0192f1b2-6d4e-7a10-b3c2-8e1f0d9a4c57",
"request_id": "0192f1c3-9e8d-7f6a-b5c4-d3e2f1a0b9c8"
}Treat it as “already created”: fetch order_url and continue. Every endpoint with {id} also accepts your
external_id.
Which requests to retry
| Response | Retry? |
|---|---|
timeout, connection error, 500, 502, 503, 504 | yes — with the same Idempotency-Key, with backoff |
429 rate_limited | yes — after Retry-After seconds |
409 idempotency_in_progress | yes — after a second |
other 4xx | no — fix the request; retrying gives the same answer |
GET requests are safe to retry at any time. PATCH, /ready, /cancel and /payment are idempotent by their
meaning: repeating them leaves the order in the same state.