Перейти к содержимому
API-документация

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

Как безопасно повторять денежные запросы после таймаута или сбоя.

Сеть рвётся. Запрос на выпуск карты мог дойти и выполниться, а ответ — потеряться. Повторить его вслепую значит выпустить вторую карту и заплатить дважды. Заголовок Idempotency-Key решает это: повтор с тем же ключом возвращает исходную операцию и ничего не делает второй раз.

Где нужен ключ

ЭндпоинтIdempotency-Key
POST /cards — выпускобязателен
POST /cards/{id}/topup — пополнениеобязателен
POST /cards/{id}/close — закрытиеобязателен
POST /cardholders — держательнеобязателен: эндпоинт и так идемпотентен по external_id

Без заголовка там, где он обязателен, запрос не проходит проверку схемы (422).

Правила

  1. Один ключ — одна операция. Генерируйте UUID v4 под каждую новую операцию и сохраняйте его у себя до отправки запроса — например, в строке вашего заказа.
  2. Повтор — с тем же ключом. Таймаут, 5xx, обрыв соединения: отправьте тот же запрос с тем же ключом. Подпись при этом новая (новое время), см. Защита от повтора.
  3. Ключ живёт вечно в пределах аккаунта. Сервер находит операцию по ключу и возвращает её, даже если тело нового запроса другое. Поэтому не используйте ключ повторно для другой операции — вы получите старую.
  4. После failed — новый ключ. Повтор с ключом проваленной операции вернёт ту же проваленную операцию. Чтобы попробовать снова, сгенерируйте новый ключ.
  5. needs_review не повторяйте вовсе. Операция могла пройти; мы разбираемся вручную и сообщим итог событием.

Пример: выпуск с повтором при таймауте

import { randomUUID } from "node:crypto";
import { pp, PPError } from "./pp.mjs";

// Ключ создаётся и сохраняется ДО первой попытки.
order.idempotencyKey ??= randomUUID();
await db.save(order);

for (let attempt = 1; ; attempt++) {
  try {
    const op = await pp("POST", "/cards", {
      tariff_id: order.tariffId,
      holder_external_id: order.customerId,
      amount: order.amount,
    }, { idempotencyKey: order.idempotencyKey });
    order.operationId = op.id; // тот же id при любом числе повторов
    break;
  } catch (e) {
    const transient = !(e instanceof PPError) || e.status >= 500;
    if (!transient || attempt === 5) throw e;
    await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
  }
}
import time
import uuid

import requests

from pp import PPError, pp

# Ключ создаётся и сохраняется ДО первой попытки.
if not order.idempotency_key:
    order.idempotency_key = str(uuid.uuid4())
    order.save()

for attempt in range(1, 6):
    try:
        op = pp("POST", "/cards", body={
            "tariff_id": order.tariff_id,
            "holder_external_id": order.customer_id,
            "amount": order.amount,
        }, idempotency_key=order.idempotency_key)
        order.operation_id = op["id"]  # тот же id при любом числе повторов
        break
    except (PPError, requests.RequestException) as e:
        transient = not isinstance(e, PPError) or e.status >= 500
        if not transient or attempt == 5:
            raise
        time.sleep(2 ** attempt * 0.5)
require __DIR__ . '/pp.php';

// Ключ создаётся и сохраняется ДО первой попытки.
$order['idempotency_key'] ??= uuid4();
saveOrder($order);

for ($attempt = 1; ; $attempt++) {
    try {
        $op = pp('POST', '/cards', [
            'tariff_id' => $order['tariff_id'],
            'holder_external_id' => $order['customer_id'],
            'amount' => $order['amount'],
        ], $order['idempotency_key']);
        $order['operation_id'] = $op['id']; // тот же id при любом числе повторов
        break;
    } catch (RuntimeException $e) {
        $transient = $e->getCode() === 0 || $e->getCode() >= 500;
        if (!$transient || $attempt === 5) {
            throw $e;
        }
        usleep((2 ** $attempt) * 500_000);
    }
}

Первый и повторный ответ совпадают — тот же id:

{
  "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
  "kind": "issue",
  "status": "processing",
  "hold_amount": "57.0464",
  "net_amount": "50",
  "created_at": "2026-10-07T09:13:02Z"
}

Что повторять, а что нет

ОтветДействие
Таймаут, обрыв, 5xx (кроме 503 maintenance)Повторить с тем же ключом
503 maintenanceДеньги не двигались. Повторить позже с тем же ключом
429 rate_limitedПодождать и повторить с тем же ключом
4xx с бизнес-кодом (balance_exhausted, card_not_active, …)Не повторять как есть: исправить причину. Резерв не создавался
Операция failedПовторить с новым ключом, если причина устранена
Операция needs_reviewНе повторять. Ждать события