S
docs.syncra.money

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

ScenarioHTTP statusBehavior
First request with a new idempotency_key200 OKThe 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 OKNo new deal is created. The original response of the first request is returned. The logs record idempotent replay.
Request without idempotency_key400 Bad RequestThe request is rejected before deal creation. No deal is created.
Retry with the same key but a different body (amount/currency)200 OKThe 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_key column on the deal itself (p2p_orders, a partial UNIQUE (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 as 409, 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:

EndpointFieldWhat it creates
POST /deals/payinidempotency_keyA PayIn deal (a deposit).
POST /deals/payoutidempotency_keyA 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

  1. The key is generated once. Outside the for loop — otherwise every retry creates a new deal.
  2. 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.
  3. 4xx (except 408/429) is not retried. An argument error will not go away on repeat.
  4. 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.


  • Quickstart — the full scenario with idempotency_key in action.
  • Accepting payments (PayIn) — all CreateMerchantPayIn request fields.
  • Authentication — the HMAC-SHA256 signature that accompanies every request.
  • Callbacks — webhook notifications should also be handled idempotently (by deal_id).

On this page