S
docs.syncra.money
API ReferenceMerchant API

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

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

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

Платежи критичны к дублям. Если сеть моргнула и ваш клиент повторил запрос — игрок должен быть списан один раз, а не дважды. Идемпотентность гарантирует именно это: повторная отправка запроса с тем же ключом возвращает исходный результат вместо создания новой сделки.


Что такое идемпотентность

Идемпотентность — свойство операции, при котором многократное её выполнение даёт тот же результат, что и однократное. В API платёжной системы это значит:

POST /deals/payin  + key=ABC   →  создаётся сделка #1, возвращается 200
POST /deals/payin  + key=ABC   →  сделка #1 НЕ создаётся заново, возвращается тот же ответ
POST /deals/payin  + key=XYZ   →  создаётся сделка #2 (другой ключ → другая операция)

Почему это важно именно для платежей

  • Сетевые сбои. Ваш запрос мог дойти и выполнить платёж, но ответ потерялся по дороге. Ваш HTTP-клиент делает retry — и без идемпотентности игрок задвоится.
  • Двойные клики. Игрок дважды нажал «Пополнить» в спешке.
  • Таймауты балансировщика. Шлюз обработал запрос, но не успел вернуть ответ в отведённый таймаут — upstream делает повтор.
  • Перезапуск пода. Ваш сервис перезапустился посреди batch-запроса и после старта прогоняет его заново.

В любой из этих ситуаций идемпотентный ключ спасает от двойного списания/зачисления.


Как использовать

В Syncra Merchant API идемпотентность работает через поле idempotency_key в теле JSON-запроса (а не через отдельный HTTP-заголовок). Это значит: для мутирующих POST-запросов вы обязаны сгенерировать уникальный ключ и передать его прямо в теле.

Правила формирования ключа

  • Формат: рекомендуем UUID v4 (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx, в нижнем регистре). Формат ключа платформой не валидируется — фактически принимается любая непустая строка; требования к уникальности и стабильности ниже важнее конкретного формата.
  • Уникальность: один ключ — одна логическая операция. Генерируйте новый ключ на каждый новый платёж.
  • Стабильность при retry: если вы повторяете запрос (например, из-за таймаута) — передавайте тот же ключ. Только так система поймёт, что это дубликат, а не новый платёж.
  • Привязка: ключ скоупится по мерчанту внутри тенанта. Одинаковые ключи у разных мерчантов не конфликтуют и не влияют друг на друга.

Пример (с идемпотентным ключом — правильно)

# Генерируем UUID v4 — это и будет наш idempotency_key.
# Он должен быть одинаковым для всех retry одного и того же платежа.
IDEMPOTENCY_KEY=$(uuidgen | tr 'A-Z' 'a-z')

BODY='{"amount":150000,"currency":"RUB","idempotency_key":"'"$IDEMPOTENCY_KEY"'","client_id":"player_9921"}'

# Рассчитываем HMAC-SHA256 подпись (см. Authentication).
SECRET='whsec_your_webhook_secret_here'
TOKEN='mtoken_your_merchant_token_here'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST https://api.syncra.money/api/v1/p2p/merchant/deals/payin \
  -H "X-Merchant-Token: $TOKEN" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: t=$TS,v1=$SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Пример (без ключа — ошибка)

BODY='{"amount":150000,"currency":"RUB","client_id":"player_9921"}'
# idempotency_key отсутствует → запрос будет отклонён.

# ... (расчёт подписи опущен для краткости)

curl -X POST https://api.syncra.money/api/v1/p2p/merchant/deals/payin \
  -H "X-Merchant-Token: $TOKEN" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: t=$TS,v1=$SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Ответ сервера (RFC 9457 problem+json, см. Коды ошибок):

HTTP/1.1 400 Bad Request

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "idempotency_key is required",
  "instance": "/api/v1/p2p/merchant/deals/payin"
}

Поведение

СценарийHTTP статусПоведение
Первый запрос с новым idempotency_key200 OKСделка создаётся, возвращается её ответ. Ответ кэшируется под этим ключом.
Повтор с тем же ключом (даже через часы/дни)200 OKНовая сделка не создаётся. Возвращается оригинальный ответ первого запроса. В логах фиксируется idempotent replay.
Запрос без idempotency_key400 Bad RequestЗапрос отклонён до создания сделки. Сделка не создаётся.
Повтор с тем же ключом, но другим телом (сумма/валюта)200 OKВозвращается оригинальный ответ. Тело второго запроса игнорируется — ключ «привязан» к первой операции. Не пытайтесь переопределить платёж, меняя тело.

Поведение «повтор → оригинальный ответ» гарантирует идемпотентность на уровне сделки. Это значит, что если первый запрос создал сделку, любой повтор с тем же ключом вернёт именно эту сделку — даже если в момент повтора её статус уже изменился на COMPLETED. Хотите узнать актуальный статус — вызывайте GET /deals/{deal_id}.


TTL и хранение

Идемпотентность реализована по column-on-row паттерну (ключ живёт столько же, сколько сама сделка; отдельного TTL/cleanup нет):

  • PayIn — строковая колонка idempotency_key на самой сделке (p2p_orders, partial UNIQUE (tenant_id, idempotency_key)). Повтор находится по ключу и возвращает исходную сделку; параллельный дубль блокируется уникальным индексом на уровне БД.
  • PayOut — детерминистичный UUID, выведенный из (tenant, merchant, idempotency_key), хранится колонкой на выплате (p2p_payouts.idempotency_key, UNIQUE). Одинаковые ключи у разных мерчантов никогда не сходятся на одной выплате — ключ скоупится по мерчанту; коллизия с чужой строкой (возможна только на исторических нескоупленных ключах) отдаётся как 409, а не replay чужой сделки.

Общие свойства:

  • Срок хранения: совпадает со сроком жизни самой сделки. Отдельного cleanup нет — ключ живёт столько же, сколько платёж. Это даёт гарантию идемпотентности даже спустя длительное время (полезно при ручных разбирательствах).
  • Хранение ответа: повтор возвращает оригинальную сделку (её текущее представление по deal_id). Ответы с ошибками (4xx/5xx) не кешируются — их можно безопасно ретраить с тем же ключом.
  • Атомарность: конкурентные запросы с одним ключом не создадут дубль даже на уровне БД (уникальный индекс + восстановление существующей строки по конфликту).

В отдельных сервисах платформы (например, tron-wallet-api для крипто-выводов) применяется IETF-совместимый заголовок Idempotency-Key и ответ помечается заголовком Idempotency-Replayed: true. Для Merchant API идемпотентность работает через поле тела idempotency_key — это контракт, описанный в proto-сообщениях и приведённый выше.


Какие эндпоинты требуют idempotency_key

Идемпотентный ключ обязателен для всех мутирующих операций создания сущностей:

ЭндпоинтПолеЧто создаётся
POST /deals/payinidempotency_keyСделка PayIn (пополнение).
POST /deals/payoutidempotency_keyСделка PayOut (выплата).

POST /wallet/withdraw (крипто-вывод USDT) — особый случай: поле idempotency_key есть в контракте, но не проверяется — идемпотентность вывода обеспечивает сама платформа детерминированным серверным ключом (мерчант + адрес + сумма + актуальная комиссия), поэтому повтор идентичного запроса никогда не порождает второй перевод (подробнее — Wallet).

Для GET-запросов (/deals, /balance, /banks, /appeals) идемпотентный ключ не нужен — они не мутируют состояние и безопасно выполняются любое число раз.

Не передавайте ключ для GET-запросов — он будет проигнорирован. Ключ нужен именно там, где создаётся платёжная сущность.


Пример: retry-логика на Node.js

Правильный клиент всегда повторяет запрос с тем же ключом при сетевой ошибке. Ключ генерируется один раз на логическую операцию и переиспользуется во всех retry.

// retry-payin.js — создание PayIn с автоматическим retry и сохранением ключа.
const crypto = require('crypto');

const SYNCRA_BASE = 'https://api.syncra.money';
const MERCHANT_TOKEN = 'mtoken_your_merchant_token_here';
const WEBHOOK_SECRET = 'whsec_your_webhook_secret_here';

/**
 * Создаёт PayIn с retry. ВАЖНО: idempotency_key генерируется ОДИН РАЗ
 * на весь жизненный цикл платежа и НЕ меняется между попытками.
 */
async function createPayIn({ amount, currency, clientId }) {
  // 1. Генерируем ключ один раз — он переживёт все retry.
  const idempotencyKey = crypto.randomUUID();

  const maxAttempts = 4;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    // 2. Тело собираем заново на каждой попытке (ключ — тот же).
    const body = JSON.stringify({
      amount,
      currency,
      idempotency_key: idempotencyKey,   // ← ключ НЕ меняется
      client_id: clientId,
    });

    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(`${timestamp}.${body}`)
      .digest('hex');

    try {
      const res = await fetch(`${SYNCRA_BASE}/api/v1/p2p/merchant/deals/payin`, {
        method: 'POST',
        headers: {
          'X-Merchant-Token': MERCHANT_TOKEN,
          'X-Timestamp': timestamp,
          'X-Signature': `t=${timestamp},v1=${signature}`,
          'Content-Type': 'application/json',
        },
        body,
      });

      // 3. 200 — успех (новая сделка ИЛИ идемпотентный повтор).
      if (res.ok) {
        return await res.json();
      }

      // 4. 400 (например, отсутствует idempotency_key) — не ретраим.
      if (res.status === 400) {
        throw new Error(`Bad request: ${await res.text()}`);
      }

      // 5. 5xx / таймаут — ретраим с тем же ключом.
      throw new Error(`HTTP ${res.status}`);
    } catch (err) {
      // Сетевая ошибка (fetch бросил) — тоже ретраим.
      if (attempt === maxAttempts) throw err;
      // Экспоненциальная задержка: 1с, 2с, 4с...
      await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
    }
  }
}

// Использование
createPayIn({ amount: 150000, currency: 'RUB', clientId: 'player_9921' })
  .then(r => console.log('payment_url:', r.deal.payment_url))
  .catch(console.error);

Ключевые моменты retry-логики

  1. Ключ генерируется один раз. Вне цикла for — иначе каждый retry создаст новую сделку.
  2. 200 после retry = успех. Неважно, создал ли сервер сделку сейчас или вернул кэшированный ответ с первой попытки — для вас результат одинаковый.
  3. 4xx (кроме 408/429) не ретраим. Ошибка аргументов не исчезнет при повторе.
  4. 5xx и сетевые ошибки ретраим. С той же подписью и тем же ключом.

Частые ошибки

Я передаю один и тот же ключ для разных платежей

Это ошибка. Одинаковый ключ для разных платежей приведёт к тому, что второй платёж вернёт ответ первого — деньги второго игрока не пройдут. Генерируйте новый UUID v4 на каждый новый платёж. Ключ переиспользуется только в рамках retry одного и того же платежа.

Я генерирую ключ внутри цикла retry

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

Я не сохраняю ключ, и не могу повторить запрос

Если ваш сервис перезапустился после отправки запроса, но до получения ответа — вам нужно знать ключ, чтобы безопасно повторить. Сохраняйте idempotency_key вместе с заказом в вашей БД (до вызова API). Тогда после рестарта вы сможете переотправить запрос с тем же ключом без риска дубля.

Я передаю ключ в заголовке Idempotency-Key

Для Merchant API ключ передаётся в теле JSON (idempotency_key), а не в заголовке. Заголовок Idempotency-Key (IETF-стиль) используется во внутренних сервисах платформы (например, tron-wallet-api), но не в публичном Merchant API. Запрос без idempotency_key в теле будет отклонён с 400.


Связанные материалы

On this page