Идемпотентность
Как работают идемпотентные ключи и почему они обязательны для платежей в 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_key | 200 OK | Сделка создаётся, возвращается её ответ. Ответ кэшируется под этим ключом. |
| Повтор с тем же ключом (даже через часы/дни) | 200 OK | Новая сделка не создаётся. Возвращается оригинальный ответ первого запроса. В логах фиксируется idempotent replay. |
Запрос без idempotency_key | 400 Bad Request | Запрос отклонён до создания сделки. Сделка не создаётся. |
| Повтор с тем же ключом, но другим телом (сумма/валюта) | 200 OK | Возвращается оригинальный ответ. Тело второго запроса игнорируется — ключ «привязан» к первой операции. Не пытайтесь переопределить платёж, меняя тело. |
Поведение «повтор → оригинальный ответ» гарантирует идемпотентность на
уровне сделки. Это значит, что если первый запрос создал сделку, любой
повтор с тем же ключом вернёт именно эту сделку — даже если в момент повтора
её статус уже изменился на COMPLETED. Хотите узнать актуальный статус —
вызывайте GET /deals/{deal_id}.
TTL и хранение
Идемпотентность реализована по column-on-row паттерну (ключ живёт столько же, сколько сама сделка; отдельного TTL/cleanup нет):
- PayIn — строковая колонка
idempotency_keyна самой сделке (p2p_orders, partialUNIQUE (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/payin | idempotency_key | Сделка PayIn (пополнение). |
POST /deals/payout | idempotency_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-логики
- Ключ генерируется один раз. Вне цикла
for— иначе каждый retry создаст новую сделку. - 200 после retry = успех. Неважно, создал ли сервер сделку сейчас или вернул кэшированный ответ с первой попытки — для вас результат одинаковый.
- 4xx (кроме 408/429) не ретраим. Ошибка аргументов не исчезнет при повторе.
- 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.
Связанные материалы
- Быстрый старт — полный сценарий с
idempotency_keyв действии. - Приём платежей (PayIn) — все поля запроса
CreateMerchantPayIn. - Аутентификация — HMAC-SHA256 подпись, которая сопровождает каждый запрос.
- Callbacks — webhook-уведомления тоже
рекомендуется обрабатывать идемпотентно (по
deal_id).