Надёжность

Идемпотентность

Сеть ненадёжна: запрос может дойти, а ответ — потеряться. Заголовок 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 уже использован с другими параметрами"
  }
}
Тело сравнивается точно
Повтор должен совпадать с первым запросом: тот же метод, путь и те же значения в JSON-теле. Порядок полей и пробелы не важны — тело сравнивается по содержимому.

Повтор возвращает ответ, сохранённый в момент первого запроса, — даже если объект с тех пор изменился. Актуальный статус получайте через 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));

Коды и типы ошибок — в разделе Ошибки.