Idempotency
How idempotency keys work and why they are required for payments in Syncra
Idempotency
Payments are critically sensitive to duplicates. If the network blinks and your client repeats the request, the client must be charged once, not twice. Idempotency guarantees exactly that: re-sending a request with the same key returns the original result instead of creating a new deal.
What is idempotency
Idempotency is the property of an operation where executing it multiple times yields the same result as executing it once. For a payment system API, this means:
POST /deals/payin + key=ABC → deal #1 is created, 200 returned
POST /deals/payin + key=ABC → deal #1 is NOT created again, the same response is returned
POST /deals/payin + key=XYZ → deal #2 is created (a different key → a different operation)Why this matters for payments
- Network failures. Your request may have gone through and executed the payment, but the response was lost on the way back. Your HTTP client retries — and without idempotency the client gets charged twice.
- Double clicks. The client pressed "Top up" twice in a rush.
- Load balancer timeouts. The gateway processed the request but failed to return the response within the timeout — the upstream retries.
- Pod restarts. Your service restarted in the middle of a batch and replays it after startup.
In every one of these situations, an idempotency key prevents a double charge or double credit.
How to use it
In the Syncra Merchant API, idempotency works through the
idempotency_key field in the JSON request body (not through a
separate HTTP header). This means: for mutating POST requests, you must
generate a unique key and pass it directly in the body.
Key generation rules
- Format: we recommend UUID v4
(
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx, lowercase). The platform does not validate the key format — in practice, any non-empty string is accepted; the uniqueness and stability requirements below matter more than the exact format. - Uniqueness: one key — one logical operation. Generate a new key for every new payment.
- Stability on retry: if you repeat a request (after a timeout, for example), pass the same key. That is the only way the system recognizes it as a duplicate rather than a new payment.
- Scoping: the key is scoped per merchant within a tenant. Identical keys held by different merchants do not conflict and never affect each other.
Correct example (with an idempotency key)
# Generate a UUID v4 — this will be our idempotency_key.
# It must stay the same across all retries of the same payment.
IDEMPOTENCY_KEY=$(uuidgen | tr 'A-Z' 'a-z')
BODY='{"amount":150000,"currency":"RUB","idempotency_key":"'"$IDEMPOTENCY_KEY"'","client_id":"player_9921"}'
# Compute the HMAC-SHA256 signature (see 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-Signature: t=$TS,v1=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"Failing example (without a key)
BODY='{"amount":150000,"currency":"RUB","client_id":"player_9921"}'
# idempotency_key is missing → the request will be rejected.
# ... (signature computation omitted for brevity)
curl -X POST https://api.syncra.money/api/v1/p2p/merchant/deals/payin \
-H "X-Merchant-Token: $TOKEN" \
-H "X-Signature: t=$TS,v1=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"The server response (RFC 9457 problem+json, see Error codes):
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"
}Behavior
| Scenario | HTTP status | Behavior |
|---|---|---|
First request with a new idempotency_key | 200 OK | The deal is created and its response returned. The response is cached under this key. |
| Retry with the same key (even hours or days later) | 200 OK | No new deal is created. The original response of the first request is returned. The logs record idempotent replay. |
Request without idempotency_key | 400 Bad Request | The request is rejected before deal creation. No deal is created. |
| Retry with the same key but a different body (amount/currency) | 200 OK | The original response is returned. The second request's body is ignored — the key is "bound" to the first operation. Do not try to override a payment by changing the body. |
The "retry → original response" behavior guarantees idempotency at the
deal level. This means that if the first request created a deal, any
retry with the same key returns exactly that deal — even if its status
has changed to COMPLETED by the time of the retry. To learn the current
status, call GET /deals/{deal_id}.
TTL and storage
Idempotency follows a column-on-row pattern (the key lives exactly as long as the deal itself; there is no separate TTL or cleanup):
- PayIn — a string
idempotency_keycolumn on the deal itself (p2p_orders, a partialUNIQUE (tenant_id, idempotency_key)). A retry is found by key and returns the original deal; a parallel duplicate is blocked by the unique index at the database level. - PayOut — a deterministic UUID derived from
(tenant, merchant, idempotency_key), stored as a column on the payout (p2p_payouts.idempotency_key,UNIQUE). Identical keys from different merchants never converge on the same payout — the key is scoped per merchant; a collision with someone else's row (possible only on historical unscoped keys) is returned as409, not as a replay of another merchant's deal.
Shared properties:
- Retention: matches the lifetime of the deal itself. There is no separate cleanup — the key lives exactly as long as the payment. This preserves the idempotency guarantee even after long periods (useful during manual investigations).
- Response storage: a retry returns the original deal (its current
representation by
deal_id). Error responses (4xx/5xx) are not cached — they are safe to retry with the same key. - Atomicity: concurrent requests with the same key cannot create a duplicate even at the database level (a unique index plus recovery of the existing row on conflict).
Some platform services (tron-wallet-api for crypto withdrawals, for
example) use the IETF-compatible Idempotency-Key header and mark the
replayed response with the Idempotency-Replayed: true header. For the
Merchant API, idempotency works through the idempotency_key body
field — that is the contract described in the proto messages and above.
Endpoints that require idempotency_key
An idempotency key is required for all mutating operations that create entities:
| Endpoint | Field | What it creates |
|---|---|---|
POST /deals/payin | idempotency_key | A PayIn deal (a deposit). |
POST /deals/payout | idempotency_key | A PayOut deal (a payout). |
POST /wallet/withdraw (a USDT crypto withdrawal) is a special case:
the idempotency_key field exists in the contract but is not
validated — withdrawal idempotency is provided by the platform itself
with a deterministic server-side key (merchant + address + amount + the
current fee), so repeating an identical request never produces a second
transfer (details in
Wallet).
For GET requests (/deals, /balance, /banks, /appeals), an
idempotency key is not needed — they do not mutate state and are safe
to run any number of times.
Do not pass a key on GET requests — it is ignored. The key belongs exactly where a payment entity is created.
Example: retry logic in Node.js
A correct client always retries a request with the same key after a network error. The key is generated once per logical operation and reused across all retries.
// retry-payin.js — creating a PayIn with automatic retry and key preservation.
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';
/**
* Creates a PayIn with retry. IMPORTANT: the idempotency_key is generated ONCE
* for the entire payment lifecycle and NEVER changes between attempts.
*/
async function createPayIn({ amount, currency, clientId }) {
// 1. Generate the key once — it will outlive all retries.
const idempotencyKey = crypto.randomUUID();
const maxAttempts = 4;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// 2. Rebuild the body on every attempt (the key stays the same).
const body = JSON.stringify({
amount,
currency,
idempotency_key: idempotencyKey, // ← the key does NOT change
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-Signature': `t=${timestamp},v1=${signature}`,
'Content-Type': 'application/json',
},
body,
});
// 3. 200 — success (a new deal OR an idempotent replay).
if (res.ok) {
return await res.json();
}
// 4. 400 (a missing idempotency_key, for example) — do not retry.
if (res.status === 400) {
throw new Error(`Bad request: ${await res.text()}`);
}
// 5. 5xx / timeout — retry with the same key.
throw new Error(`HTTP ${res.status}`);
} catch (err) {
// A network error (fetch threw) — retry as well.
if (attempt === maxAttempts) throw err;
// Exponential backoff: 1s, 2s, 4s...
await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
}
}
}
// Usage
createPayIn({ amount: 150000, currency: 'RUB', clientId: 'player_9921' })
.then(r => console.log('payment_url:', r.deal.payment_url))
.catch(console.error);Key points of the retry logic
- The key is generated once. Outside the
forloop — otherwise every retry creates a new deal. - 200 after a retry = success. It does not matter whether the server created the deal now or returned the cached first response — the outcome is the same for you.
- 4xx (except 408/429) is not retried. An argument error will not go away on repeat.
- 5xx and network errors are retried. With the same signature and the same key.
Common mistakes
I pass the same key for different payments
This is a mistake. The same key for different payments means the second payment returns the first one's response — the second client's money never goes through. Generate a fresh UUID v4 for every new payment. A key is reused only across retries of the same payment.
I generate the key inside the retry loop
This breaks idempotency. If the first request timed out but the deal was already created, a retry with a new key creates a second deal. The key must be generated before the first request and stay unchanged across all retries.
I do not persist the key, so I cannot repeat the request
If your service restarted after sending the request but before receiving
the response, you need the key to retry safely. Store the
idempotency_key alongside the order in your database (before calling
the API). Then, after a restart, you can re-send the request with the same
key without risking a duplicate.
I pass the key in the Idempotency-Key header
For the Merchant API, the key is passed in the JSON body
(idempotency_key), not in a header. The Idempotency-Key header (IETF
style) is used by internal platform services (tron-wallet-api, for
example) but not by the public Merchant API. A request without
idempotency_key in the body is rejected with 400.
Related materials
- Quickstart — the full scenario with
idempotency_keyin action. - Accepting payments (PayIn) — all
CreateMerchantPayInrequest fields. - Authentication — the HMAC-SHA256 signature that accompanies every request.
- Callbacks — webhook notifications should
also be handled idempotently (by
deal_id).