К содержимому

Разработчикам

Ключи идемпотентности в платёжном API: как не создать платёж и не заплатить дважды

Почему повторы запросов создают дубли платежей и выплат и как их исключает CryptoLike: Idempotency-Key, payout_id, order_id и event_id вебхуков, с примерами кода.

Главное

  • Таймаут не говорит, выполнен ли запрос. Повтор без ключа идемпотентности может создать второй платёж или вторую выплату.
  • Передавайте Idempotency-Key в каждом POST: повтор в течение 24 часов вернёт сохранённый ответ с заголовком Idempotent-Replayed: true.
  • Для выплат передавайте ещё и свой payout_id: он уникален в аккаунте без ограничения по времени, и повтор вернёт ту же выплату.
  • Вебхуки доставляются как минимум один раз и в произвольном порядке: сохраняйте event_id и применяйте каждое событие один раз.

Запрос к платёжному API может «упасть» уже после того, как сделал свою работу: платёж создан, но ответ потерялся по таймауту, и код не знает, повторять ли запрос. Идемпотентность делает повтор безвредным. В CryptoLike вы передаёте заголовок Idempotency-Key в каждом POST, payout_id в каждой выплате и, по желанию, order_id в каждом платеже — и повтор того же запроса возвращает исходный результат, а не создаёт второй. С вебхуками та же забота нужна на вашей стороне: они могут прийти больше одного раза.

Откуда берутся дубли

Дубль платежа или выплаты почти никогда не ошибка платёжной платформы — это повтор запроса, первая попытка которого прошла успешно. Сеть теряет ответы, балансировщик обрывает соединение по таймауту, воркер перезапускается посреди запроса, покупатель дважды жмёт «Оплатить». В каждом случае у кода один и тот же вопрос: прошёл ли первый запрос?

Ситуация Что произошло Без идемпотентности С идемпотентностью CryptoLike
HTTP-клиент не дождался ответа на POST /v1/invoices Платёж создан, ответ потерян Повтор создаёт второй платёж к заказу Повтор возвращает первый платёж
Воркер перезапустился во время отправки выплаты Выплата то ли есть, то ли нет Повтор может заплатить получателю дважды payout_id возвращает существующую выплату
Покупатель дважды нажал «Оплатить криптовалютой» Два запроса на один заказ Два открытых платежа, два адреса idempotency_by_order_id возвращает открытый платёж
Ваш сервер ответил на вебхук через 12 с CryptoLike считает доставку неудачной и повторяет Заказ обрабатывается дважды Вы пропускаете уже применённый event_id

Когда речь о деньгах, без повторов не обойтись — от выплаты нельзя просто отказаться, — поэтому надёжна только схема, в которой повтор безвреден.

Как работает Idempotency-Key в CryptoLike

CryptoLike принимает заголовок Idempotency-Key во всех POST публичного API: создание и отмена платежей, создание и архивирование статических адресов, создание, оценка и отмена выплат, управление эндпоинтами вебхуков и повторными доставками. Заголовок необязателен: без него каждый запрос выполняется как новый. С ним первый успешный ответ хранится 24 часа и возвращается на каждый повтор.

Правило Значение
Формат 1–128 печатных символов ASCII (от ! до ~, без пробелов); более длинное значение или другие символы дают 400 validation_error с details.field: Idempotency-Key. Пустой заголовок игнорируется
Область действия Отдельно для каждого API-ключа: два ключа могут использовать одно значение независимо
Сколько хранится ответ 24 часа
Что считается тем же запросом Тот же метод, путь с параметрами и тело
Повтор завершённого запроса Сохранённые статус и тело с заголовком Idempotent-Replayed: true
Тот же ключ, другое тело 422 idempotency.mismatch
Тот же ключ, пока первый запрос выполняется 409 idempotency.in_progress; повторите после короткой паузы
Ответы с ошибкой (4xx, 5xx) Не сохраняются: ключ освобождается, и повтор выполняется заново

Сверено с API CryptoLike 24.09.2026; заголовок описан в справочнике API, подпись запросов — в аутентификации API.

На практике важны две детали. Сохраняются только успешные ответы, поэтому исправить запрос после 400 и отправить его с тем же ключом можно. А блокировка на время выполнения снимается через 60 секунд, так что запрос, упавший на полпути, не держит ключ целые сутки.

Как выбрать ключ

Ключ идемпотентности должен обозначать бизнес-операцию, а не HTTP-попытку. Сгенерируйте его один раз — в момент, когда решили создать платёж или выплату, — сохраните вместе с операцией и используйте при каждом повторе. Новый случайный ключ на каждую попытку лишает идемпотентность смысла: каждая попытка становится новой операцией.

Хорошие ключи получаются из ваших собственных данных:

  • Платёж по заказу: order-A-1002 или order-A-1002-2, если покупатель сознательно начинает вторую оплату после истечения первой.
  • Выплата: ID строки в вашей таблице выплат, созданной до обращения к API.
  • Статический адрес пользователя: addr-user-8812-usdt-tron — хотя этот вызов и так безопасен: второй адрес для того же клиента и актива вернёт 409 conflict.

Храните ключ и тело вместе. Если код при повторе собирает тело заново — скажем, с новой датой в description, — это уже другой запрос, и CryptoLike ответит 422 idempotency.mismatch вместо создания дубля.

Обёртка для повторов на TypeScript

Эта обёртка повторяет вызов CryptoLike при сетевых ошибках, 409 idempotency.in_progress, 429 и 5xx — каждый раз с тем же ключом и тем же телом, но с новой подписью для каждой попытки. Каждый повтор ждёт как минимум до следующей секунды: время в подписи — целые секунды, и повтор, подписанный в ту же секунду, отклоняется как 401 auth.replay. Официальные SDK уже делают так. Обёртка использует функцию подписи запросов из гайда для Node.js, которая подписывает каждый вызов заново; доработайте её, чтобы она возвращала HTTP-статус, код ошибки и значение Retry-After.

ts
type ApiError = Error & { status?: number; code?: string; retryAfter?: number }; // retryAfter: seconds from the Retry-After header

const RETRYABLE = (e: ApiError) =>
  e.status === undefined ||                       // network error or timeout
  e.status === 429 ||
  e.status >= 500 ||
  e.code === "idempotency.in_progress";

export async function withRetry<T>(call: () => Promise<T>, attempts = 5): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await call(); // call() signs afresh: a new X-Timestamp and signature each attempt
    } catch (err) {
      const e = err as ApiError;
      if (i + 1 >= attempts || !RETRYABLE(e)) throw e;
      // Timestamps are whole seconds: a repeat signed in the same second is refused
      // as 401 auth.replay, so wait at least 1 s before the next attempt,
      // and never less than the Retry-After of a 429.
      const wait = Math.max(1000 * 2 ** i, (e.retryAfter ?? 0) * 1000); // 1 s, 2 s, 4 s, 8 s
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}

// The key and the body are fixed before the first attempt.
const body = { amount: "49.90", currency: "USD", order_id: "A-1002", idempotency_by_order_id: true };
const payment = await withRetry(() => cryptolike("POST", "/v1/invoices", body, "order-A-1002"));

Не повторяйте 400, 401, 403, 404 и 422: они будут падать так же, пока вы не измените запрос. 401 auth.invalid_signature обычно значит, что вы захешировали одно тело, а отправили другое.

Выплаты: передавайте свой payout_id

Для выплат у CryptoLike есть вторая защита, у которой нет срока: payout_id — ваш идентификатор длиной до 128 символов, уникальный в аккаунте. POST /v1/withdrawals с уже существующим payout_id возвращает существующую выплату, а не создаёт новую — с кодом 201, если она создана меньше 5 секунд назад, и с 200 позже, — если совпадают актив, сумма и адрес. Другие параметры с тем же payout_id дают 409 conflict с details.field: payout_id.

json
POST /v1/withdrawals
Idempotency-Key: payout-7731

{
  "coin_id": "<id USDT в сети tron из GET /v1/currencies>",
  "amount": "250.00",
  "to_address": "T…",
  "payout_id": "payout-7731"
}

Используйте оба: заголовок закрывает 24-часовое окно сетевых повторов, а payout_id — повтор через несколько дней, например перезапуск задачи после сбоя или та же строка в CSV, загруженном второй раз. В массовой выплате из CSV payout_id, повторённый внутри файла или уже использованный, — ошибка строки, и файл отклоняется до отправки чего-либо.

Платежи: один открытый платёж на заказ

Для платежей бизнес-ключ — ваш заказ. Передайте order_id вместе с idempotency_by_order_id: true, и CryptoLike вернёт открытый платёж этого заказа с кодом 200, а не создаст новый: двойной клик, перезагрузка страницы и повтор запроса приводят на одну и ту же страницу оплаты.

  • Тот же заказ, другая сумма или валюта: 409 conflict с details.reason: order_invoice_mismatch и ID существующего платежа. Если заказ действительно изменился, а платёж ещё в статусе new и на него ничего не отправлено, сначала отмените его; когда перевод уже в пути, отменить платёж нельзя.
  • По заказу уже пришли деньги: 409 conflict с details.reason: order_has_payment. Не открывайте второй платёж — разберитесь с первым.
  • Предыдущий платёж истёк: создаётся новый — с новым курсом, если цена в фиате.

Без этого флага order_id — просто ссылка, и уникальность не проверяется. Все плагины CryptoLike для CMS работают в этом режиме, поэтому покупатель, дважды нажавший «Оплатить», видит одну страницу оплаты.

Вебхуки: применяйте каждое событие один раз

Идемпотентность нужна и в обратную сторону. CryptoLike доставляет вебхуки как минимум один раз: доставка без ответа 2xx за 10 секунд повторяется через 1, 5 и 15 минут, 1, 6 и 24 часа — 7 попыток примерно за 31 час, — а сбой между вашей обработкой и ответом означает, что событие придёт снова. Порядок доставок тоже может быть любым.

Обработчик надёжен, если соблюдает три правила:

  1. Отбрасывайте дубли по event_id. Он есть в теле и в заголовке X-Event-Id и одинаков во всех доставках события.
  2. Записывайте событие и применяйте его в одной транзакции, а потом отвечайте 200. Если транзакция не прошла, ответьте ошибкой — CryptoLike повторит доставку.
  3. Никогда не откатывайте статус назад. Опоздавший invoice.pending не должен отменять invoice.paid: ранжируйте статусы или сравнивайте время.
sql
-- One row per applied event; the unique key makes the second delivery a no-op.
INSERT INTO cryptolike_events (event_id, type, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;
-- 1 row inserted → apply the event to the order in the same transaction.
-- 0 rows → already applied: commit and answer 200.

Подпись проверяйте раньше всего остального — код на четырёх языках есть в гайде проверка подписи вебхуков. Доставку, исчерпавшую попытки, можно отправить снова через POST /v1/webhooks/deliveries/{id}/retry, когда эндпоинт исправлен; список доставок — GET /v1/webhooks/deliveries.

Ошибки и что делать

HTTP · код Что произошло Что делать
409 idempotency.in_progress Первый запрос с этим ключом ещё выполняется Повторить после паузы с тем же ключом и телом
422 idempotency.mismatch Ключ уже использован с другим телом Восстановить исходное тело или взять новый ключ для новой операции
400 validation_error, поле Idempotency-Key Ключ длиннее 128 символов или содержит символы вне печатных ASCII (пустой заголовок игнорируется) Использовать 1–128 печатных символов ASCII без пробелов
409 conflict, поле payout_id payout_id уже есть с другими параметрами Проверить существующую выплату; для новой — новый ID
409 conflict, order_invoice_mismatch У заказа есть открытый платёж с другой суммой Отменить его, пока он в статусе new, или использовать
503 unavailable Хранилище ключей временно недоступно Повторить с паузой и тем же ключом

Полный список кодов ошибок — в справочнике API.

Вопросы и ответы

Заголовок Idempotency-Key обязателен?

Нет, во всех POST API CryptoLike он необязателен. Без него каждый запрос выполняется как новый, и повтор после таймаута может создать дубль. Передавайте его в каждом вызове, который что-то создаёт, особенно в платежах и выплатах.

Сколько CryptoLike помнит ключ идемпотентности?

24 часа для успешного ответа, отдельно для каждого API-ключа. Ответы с ошибкой не сохраняются, поэтому исправленный запрос с тем же ключом выполнится. Для выплат у payout_id срока нет — это защищает от повторов через несколько дней.

Что значит Idempotent-Replayed: true?

Ответ — копия первого успешного ответа на тот же запрос с тем же ключом; ничего нового не создано. Обрабатывайте его так же, как исходный ответ. Заголовок помогает в логах отличить повтор от новой операции.

Может ли вебхук прийти дважды?

Да. CryptoLike доставляет вебхуки как минимум один раз: доставку без ответа 2xx за 10 секунд повторяет — до 7 попыток примерно за 31 час, — а сбой может повторить уже обработанную доставку. Отбрасывайте дубли по event_id и применяйте каждое событие в одной транзакции базы данных.

Можно ли брать случайный UUID в качестве ключа?

Можно, если генерировать его один раз на операцию и сохранять до первой попытки. Новый UUID на каждый повтор делает каждую попытку новой операцией и лишает идемпотентность смысла. Ключи из ID заказа или выплаты к тому же проще отследить.

Термины на странице

Читать дальше

Начните с тестового платежа

Создайте аккаунт, попросите у поддержки доступ к тестовой среде и посмотрите, как платёж проходит путь от pending до paid в тестовой сети.