Идемпотентность
Сеть ненадёжна: запрос может дойти, а ответ — потеряться. Заголовок Idempotency-Key позволяет безопасно повторить запрос и не создать второй платёж или второй возврат.
Как это работает
Передайте в заголовке Idempotency-Key уникальную строку до 255 символов. Ключ поддерживают запросы, которые создают объекты:
POST /payments— создание платежа;POST /payments/:id/refunds— создание возврата.
Первый запрос с ключом выполняется как обычно, а его результат запоминается. Ключи хранятся не меньше 24 часов и действуют в пределах сервиса и режима: один и тот же ключ в тестовом и боевом режиме — это два разных ключа. Без заголовка запрос просто выполняется каждый раз.
curl https://pay.sezgir.com/api/v1/payments \
-H "Authorization: Bearer $SEZGIR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a4e-7b3d-4e8a-a1f0-5c6d7e8f9a0b" \
-d '{ "type": "link", "amount": 15000, "external_id": "order_1042" }'Варианты ответа
| Ситуация | Ответ |
|---|---|
| Первый запрос с ключом | Запрос выполняется, результат сохраняется |
| Повтор с тем же ключом и тем же телом | Тот же код и то же тело, что в первый раз, плюс заголовок Idempotent-Replayed: true. Повторно ничего не выполняется |
| Тот же ключ, другое тело или другой путь | 409 с кодом idempotency_mismatch |
| Тот же ключ, пока первый запрос ещё выполняется | 409 с кодом idempotency_in_progress — повторите через несколько секунд |
| Ключ длиннее 255 символов | 400 с кодом idempotency_key_too_long |
{
"error": {
"type": "conflict",
"code": "idempotency_mismatch",
"message": "Idempotency-Key уже использован с другими параметрами"
}
}Повтор возвращает ответ, сохранённый в момент первого запроса, — даже если объект с тех пор изменился. Актуальный статус получайте через GET /payments/:id или из вебхуков.
Ошибки не сохраняются
Запоминаются только успешные ответы (2xx). Если запрос завершился ошибкой — неверные параметры, 409, недоступность Kaspi, — ключ освобождается, и повтор с тем же ключом выполнит запрос заново. Это позволяет исправить причину и повторить, не придумывая новый ключ.
- Ответ
202на возврат — успешный: повтор с тем же ключом вернёт тот же возврат в статусеpending, а не создаст новый. - Если Kaspi был недоступен при создании QR или счёта (
503 provider_unavailable), в списке платежей останется запись со статусомfailed. Повтор с тем же ключом создаст новый платёж — это безопасно: первый не был выставлен покупателю.
Как выбирать ключ
- Генерируйте UUID v4 при создании заказа или возврата в вашей системе и сохраняйте его вместе с ними — ключ должен пережить перезапуск процесса.
- Можно строить ключ из ваших идентификаторов:
order_1042-payment-1,order_1042-refund-2. Главное — одна операция, один ключ. - Для новой попытки оплаты того же заказа (например, когда ссылка истекла) нужен новый ключ — иначе вы получите уже созданный, истёкший платёж.
- Не используйте один ключ для разных эндпоинтов и не передавайте в ключе персональные данные.
Пример повтора
Повторяйте при сетевых ошибках, таймаутах, 429, 409 idempotency_in_progress, 500 и 503 — с тем же ключом и экспоненциальной задержкой. Не повторяйте 400, 401, 404, 502 и остальные 409 — они не исправятся сами. Исключение — 500 с кодом outcome_unknown: операция могла выполниться, повтор с тем же ключом вернёт тот же ответ, поэтому сначала проверьте объект через GET.
const RETRYABLE = (status, body) =>
body?.error?.code !== 'outcome_unknown' &&
(status === 429 || status === 500 || status === 503 || body?.error?.code === 'idempotency_in_progress');
export async function sezgirPost(path, payload, idempotencyKey, attempts = 5) {
const body = JSON.stringify(payload); // одна и та же строка при каждом повторе
for (let i = 0; ; i++) {
let res;
try {
res = await fetch(`https://pay.sezgir.com/api/v1${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SEZGIR_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body,
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
if (i + 1 >= attempts) throw err; // сеть/таймаут — повторяем
await sleep(2 ** i * 500);
continue;
}
const data = await res.json().catch(() => null); // прокси может ответить не JSON
if (res.ok) return data;
if (!RETRYABLE(res.status, data) || i + 1 >= attempts) {
throw Object.assign(new Error(data?.error?.message ?? `HTTP ${res.status}`), data?.error);
}
await sleep(2 ** i * 500);
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));Коды и типы ошибок — в разделе Ошибки.