S
docs.syncra.money
Reference

Migration guide (V1 → V2)

A step-by-step plan for migrating from the legacy V1 P2P platform to the new Syncra V2 infrastructure

Migration guide (V1 → V2)

This guide is for engineers at merchants already integrated with the legacy V1 P2P platform (built on the C#/.NET gateway api-merchant.syncra.me). It describes the detailed process of moving your system to the new V2 API (api.syncra.money).

For a seamless switchover, Syncra V2 ships a built-in Compatibility Adapter that lets your existing code keep working unchanged during the transition period (6 months). To take advantage of the higher performance, limits, and security, we recommend a full migration.


Seamless migration (2 steps)

If you want to switch traffic to the new Syncra V2 infrastructure instantly and without changing your code (no rewriting signatures, amount formats, or currency codes), use the built-in Compatibility Adapter.

The migration takes two simple steps:

  1. Change the base URL of requests: In your API settings, replace the base request address https://api-merchant.syncra.me/v1/ with https://api.syncra.money/v1/.

  2. Enable compatibility mode and get the secret: In the admin dashboard, create a merchant or open an existing merchant's settings and make sure API Version is set to v1. Copy the generated API token and Webhook Secret. Use them in the MERCHANT and SIGNATURE headers as usual.

After that, your integration keeps working as before — with the old V1 data structures, while payments are processed by the high-performance V2 core.


Reference: V1 request signatures (payload by endpoint)

The seamless Compatibility Adapter accepts exactly the same V1 HMAC-SHA512 signatures as the legacy api-merchant.syncra.me gateway. The endpoint category determines the signed payload format (fields joined with ;). This table is the canonical reference against the compat layer's actual routes; check your current signature generation code against it.

CategoryActual V1 routePayload formatFields
PayIn (incoming payment)POST /v1/payments/incoming (mirrors: /v1/payments/incoming/idempotency, /nochannel){timestamp};{payInId};{salt}timestamp — Unix seconds; payInId — the order's idempotency ID; salt — a random salt from the request body.
PayOut (payout to card/payment details)POST /v1/payments/outgoing{timestamp};{payOutId};{salt}payOutId — the payout's idempotency ID; salt — the salt. The structure is identical to PayIn; only the ID semantics differ.
Withdraw (TRC20 crypto withdrawal)POST /v1/merchant/withdraw{timestamp};{requestId};{wallet};{salt}requestId — the withdrawal's idempotency ID; wallet — the recipient's TRON address: the value of the eponymous request-body field (if only the address alias is sent — its value); salt — the salt. 4 fields, not 3 — skipping the address breaks the signature.
Dispute (deal appeal)POST /v1/disputs/create{timestamp};{paymentId}paymentId — the payment/deal ID. Only 2 fields, no salt.
Banks (bank reference)POST /v1/banks/list{timestamp}The timestamp only (GET-like requests with no identifier).

The algorithm is the same for all endpoints: HMAC-SHA512(webhook_secret, payload), the result in lowercase hex, sent in the SIGNATURE header. The MERCHANT (merchant GUID) and TIMESTAMP (the same timestamp as in the payload) headers are mandatory.

Replay protection: the adapter rejects requests with a timestamp older than 300 seconds or more than 60 seconds in the future (clock skew). Use the current server time.

Example: V1 Withdraw (PHP)

<?php
// V1 Withdraw — 4 fields in the payload; the address comes from the
// "wallet" field of the body
$merchantGuid   = "7ac148fe-19a3-45bb-b992-019fac55b721";
$merchantSecret = "legacy_secret_key";
$timestamp      = time();
$requestId      = "wd_98765";
$wallet         = "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"; // T + 33 base58
$salt           = "random_salt_wd";

$payload   = "{$timestamp};{$requestId};{$wallet};{$salt}";
$signature = hash_hmac('sha512', $payload, $merchantSecret);

$headers = [
    "MERCHANT: {$merchantGuid}",
    "TIMESTAMP: {$timestamp}",
    "SIGNATURE: {$signature}",
    "Content-Type: application/json"
];

$body = json_encode([
    // requestId — idempotency: a retry with the same requestId returns
    // the already existing withdrawal, with no second balance reservation
    "requestId" => $requestId,
    "amount" => "10.00",   // string decimal format
    "blockchain" => 1,     // 1 = Tron (TRC20) — the only chain
    "wallet" => $wallet,   // mandatory spec field; "address" is an alias
    "isBalanceCommission" => true,
    "salt" => $salt
]);
// POST https://api.syncra.money/v1/merchant/withdraw
?>

Example: V1 Dispute (PHP)

<?php
// V1 Dispute — only 2 fields, no salt
$merchantGuid   = "7ac148fe-19a3-45bb-b992-019fac55b721";
$merchantSecret = "legacy_secret_key";
$timestamp      = time();
$paymentId      = "pmnt_4242";

$payload   = "{$timestamp};{$paymentId}";
$signature = hash_hmac('sha512', $payload, $merchantSecret);

$headers = [
    "MERCHANT: {$merchantGuid}",
    "TIMESTAMP: {$timestamp}",
    "SIGNATURE: {$signature}",
    "Content-Type: application/json"
];
// POST https://api.syncra.money/v1/disputs/create
?>

V1 adapter contracts: fields and response shapes

The shapes below are the compat adapter's actual contract (updated 2026-09-09 after the V1 surface audit). A successful response is wrapped in the {failures, value, isSuccess, isFailure} envelope; lists additionally in the {count, data, items, next_page_token} envelope (items is a legacy alias of data).

Crypto withdrawal (POST /v1/merchant/withdraw, GET /v1/merchant/withdraw/{id}, POST /v1/merchant/withdraw/list)

The create body: requestId, amount (a string, "10.00"), blockchain (handled as 1 = Tron/TRC20 — the only withdrawal chain), wallet — the legacy spec's mandatory field (the address alias is accepted for symmetry; when both are set, wallet wins), comment, isBalanceCommission, salt, callbackUrl.

  • The address is validated on input: mainnet Base58Check TRON (T + 33 base58 characters), otherwise 400 INVALID_ARGUMENT.
  • Idempotency by requestId: a repeat request with the same requestId returns the already existing withdrawal — no second record and no second balance reservation is created (a race of two retries also produces one request).
  • The create/get/list response is a single 19-field profile: id, withdrawId (duplicates id), userId, status, requestId, blockchain (always 1), currency (the numeric USDT code), amount (a string), wallet, comment, isBalanceCommission, exchangeRate (1.0, USDT → USDT), sentAmount (equal to the amount only in status 3), commissionAmount, transactionHash, callbackUrl, createdAt, lastModifiedAt, sentAt.

Statistics (POST /v1/payments/incoming/statistics, POST /v1/payments/outgoing/statistics)

The request body takes optional startDate / endDate (RFC3339). The response is a {"statistics": [...]} object with an array of records per method, not {successCount, totalAmount}:

  • incoming, 14 fields: method, conversionPercent, paymentsCount, paymentsPaidCount, paymentsTimeoutCount, paymentsCancelledCount, paymentsReturnedCount, paymentsDisputCount, paymentsCurrency, paymentsAmount, paymentsPaidAmount, balanceCurrency, paymentsToBalanceAmount, paymentsCommissionAmount.
  • outgoing, 12 fields: method, conversionPercent, paymentsCount, paymentsSentCount, paymentsCancelledCount, paymentsDisputCount, paymentsCurrency, paymentsAmount, paymentsSentAmount, balanceCurrency, paymentsFromBalanceAmount, paymentsCommissionAmount.

Disputes (POST /v1/disputs/create, GET /v1/disputs/{id}, POST /v1/disputs/list)

  • create and get — a full 17-field profile: disputId, status, paymentId, receiptFileBody, receiptFileType, receiptFileUrl, clientComment, merchantComment, description, answerFileBody, answerFileType, answerFileUrl, traderComment, adminComment, createdAt, lastModifiedAt, finalizedAt.
  • list — a reduced 11-field profile: disputId, status, paymentId, clientComment, merchantComment, traderComment, adminComment, description, createdAt, lastModifiedAt, finalizedAt.
  • create reads from the body: paymentId (mandatory), receiptType, receiptUrl, description, clientComment, merchantComment (the legacy aliases comment / proofImageBase64 add to the reason text); the passed values are echoed back in the response.

Banks (POST /v1/banks/list)

The list element is a BankProfile of 5 fields: id, country, bankName, methodNames (an array of method names), currencies (an array of numeric currency codes). There are no displayName / supportedMethods fields in the response. The filter body: country, bankName, methodName, skip, take.

List filters, isolation, currency

  • The list endpoints withdraw/list, payments/incoming/list, payments/outgoing/list, disputs/list accept an optional body filter: status (a numeric V1 code; an unknown code answers 400 INVALID_ARGUMENT), skip, take (1–100), startDate / endDate (RFC3339, by creation date); incoming/list additionally accepts id (UUID) to narrow to a single deal.
  • The GET endpoints /payments/incoming/{id}, /payments/outgoing/{id}, /merchant/withdraw/{id}, /disputs/{id} answer 404 NOT_FOUND for an entity of another merchant — the fact of existence is not disclosed (not 403).
  • An unknown numeric currency code in PayIn/PayOut create answers 400 INVALID_ARGUMENT (unknown currency code: N); silent RUB substitution is disabled.
  • The retryable slot of PayIn create: an empty cascade (no free executor) — a temporary capacity state, not a client error. All three creation entry points (/v1/payments/incoming, /nochannel, idempotent) answer HTTP 503, failures[].id = "UNAVAILABLE", with the text no matching trader available, retry later — mirroring the V2 semantics (gRPC UNAVAILABLE → 503); retry with exponential backoff. 400 INVALID_ARGUMENT stays non-retryable (fix the request body).
  • The PayOut webhook: PROCESSING → code 40 (InProgress), FAILED/EXPIRED → code 30 (Failed); previously both states arrived as code 1 (WaitingProcessing), and the merchant never learned about the payout failure.
  • GET /v1/payments/incoming/{id} for a deal in REFUND_PENDING returns 32 (RollingBack), while the webhook at the same stage sends 16 (Returned) — the divergence between the surfaces is deliberate (see the V1 code registry). REFUND_PENDING is a transit: the 16 webhook is reliably followed by 12 (Cancelled); the deal does not return to the payment cycle and never hangs in 16.

  1. Get V2 credentials: Log in to the new admin dashboard and generate a V2 API token and Webhook Secret.
  2. Change the base URL: Update the request address from https://api-merchant.syncra.me/v1/ to https://api.syncra.money/api/v1/p2p/merchant/.
  3. Update the authentication headers:
    • Replace the MERCHANT header with X-Merchant-Token.
    • Replace the SIGNATURE header with X-Signature.
    • Add the mandatory X-Timestamp header.
  4. Replace the signature algorithm: Switch from HMAC-SHA512 to HMAC-SHA256.
  5. Change the signature payload format: Instead of joining fields with a semicolon (ts;id;salt), switch to the standard request-body signature formula: timestamp + "." + raw_body.
  6. Update the amount format: Move from string amounts with a dot (for example, "1500.50") to integer kopecks (for example, 150050 in int64).
  7. Update the currency codes: Replace numeric ISO codes with three-letter string codes (for example, 643 → "RUB", 10001 → "USDT").
  8. Update response handling: Move from the old response envelope {failures, value, isSuccess} to top-level proto fields with no data wrapper: a single object — {"deal": {...}}, lists — {"deals": [...] , "next_page_token": ""} (gRPC-gateway renders the response directly).
  9. Update webhook signature verification: Reconfigure your webhook handler to decode the new X-Signature format based on SHA-256 (instead of the V1 binding to the start of the day, StartOfDay).
  10. Run testing: Verify the whole payment flow on the staging environment and switch production traffic.

Parameter mapping table (PayIn request)

When moving from V1 to V2, the deposit request parameters change as follows:

V1 API fieldV2 API fieldV1 typeV2 typeConversion example
payInIdidempotency_keystringstring"order_123" → "order_123"
amountamountstringint64"1500.50" → 150050
currencycurrencyintstring643 → "RUB"
clientIdclient_idstringstring"user_99" → "user_99"
methodpayment_method_idstringstring (UUID or slug)"Sbp" → "550e8400-e29b-41d4-a716-446655440000" (UUID) or "sbp_rub" (slug)

The V2 field is called payment_method_id and is mandatory (since 2026-08-24): the routing cascade is method-scoped, and an empty value is rejected with 400 INVALID_ARGUMENT. Both the UUID (the canonical format) and the method slug are accepted — both values come from GET /api/v1/p2p/merchant/payment-methods (see Banks); GET /banks returns no UUID. There is no separate payment_method field for PayIn in V2.

The V1 fields successUrl / failUrl have no counterpart in the V2 CreateMerchantPayIn request. Redirect URLs (success_redirect_url, fail_redirect_url) are now configured at the merchant level in the admin panel and apply to all deals — they cannot be overridden per request. If you need different success pages for different products, create several merchants. See Hosted Checkout → Redirect URLs.

Parameter mapping table (PayOut request)

When moving from V1 to V2, the payout request parameters change as follows:

V1 API fieldV2 API fieldV1 typeV2 typeConversion example
payOutIdidempotency_keystringstring"payout_123" → "payout_123"
amountamountstringint64"5000.00" → 500000
currencycurrencyintstring643 → "RUB"
cardNumbertarget_requisitestringstring"2202201234567890" → "2202201234567890"
fullNametarget_namestringstring"Ivan I." → "Ivan I."
paymentMethodpayment_methodstringstring (slug or UUID)"Card" → "card_rub" (slug) or "550e8400-..." (UUID)
—target_bank—string (optional)The recipient's bank (slug/name) — for TRY/Havale payouts (v0.2.11).
—requisite_details—map (optional)Named payment details (iban, account_number, document_number, bank_name, phone_number) — for TRY/Havale payouts (v0.2.11).

payment_method in a V2 PayOut accepts a slug (card_rub, sbp_rub, mobcom_rub, express-havale) or the method's UUID, NOT the uppercase enum BANK_CARD/SBP. The method currency must match the deal currency (otherwise 400). Current identifiers come from GET /payment-methods. Details and a TRY example — in PayOut.

Status mapping table (PayIn: V1 code → V2 enum)

V1 codeV2 status (canonical)Comment
1INITIALIZEDWaitingPayment — the deal is created, awaiting payment.
2PAYMENT_NOTIFIEDConfirmedByPayer — "I paid".
11COMPLETEDPaid — terminal success.
12CANCELLED (and also SPAM_REJECTED)Cancelled in V1. Both V2 statuses — the explicit cancellation (CANCELLED) and the antispam rejection (SPAM_REJECTED) — are translated by the adapter into the single code 12; you can tell them apart only in the V2 contract.
13EXPIREDTimeout — a terminal timeout.
14COMPLETED (+ amount_match_status: overpaid)Overpayment — V2 has no separate status for it.
15COMPLETED (+ amount_match_status: partial)Underpayment — V2 has no separate status for it.
16REFUND_PENDINGReturned — the refund transit: the 16 webhook is followed by 12 Cancelled.
21APPEALEDDisput — an appeal is open.
31INITIALIZEDWaitingPaymentChannel — the channel is not chosen yet.

The full canonical table (including PayOut and USDT withdrawals) is in the Status reference. V1 PayOut codes differ from PayIn (10 = success, 30 = rejection) — see the PayOut table there.


Code example comparison (PHP)

How it was in API V1 (HMAC-SHA512 + ";" separator)

<?php
// API V1
$merchantGuid = "7ac148fe-19a3-45bb-b992-019fac55b721";
$merchantSecret = "legacy_secret_key";
$timestamp = time();
$payInId = "order_12345";
$salt = "random_salt_99";

$payload = "{$timestamp};{$payInId};{$salt}";
$signature = hash_hmac('sha512', $payload, $merchantSecret);

$headers = [
    "MERCHANT: {$merchantGuid}",
    "SIGNATURE: {$signature}",
    "Content-Type: application/json"
];

$body = json_encode([
    "payInId" => $payInId,
    "amount" => "1500.50",
    "currency" => 643,
    "salt" => $salt
]);
// Send to https://api-merchant.syncra.me/v1/payments/incoming
?>

How it is in API V2 (HMAC-SHA256 + body signature)

<?php
// API V2
$merchantToken = "a1b2c3d4e5f607182930a4b5c6d7e8f901a2b3c4d5e6f7081920a3b4c5d6e7f8";
$webhookSecret = "whsec_920ab3e09819cd8e412cfab9e87d0c3c";
$timestamp = time();

$body = json_encode([
    "amount" => 150050, // in kopecks
    "currency" => "RUB",
    "idempotency_key" => "order_12345"
]);

$payload = $timestamp . '.' . $body;
$signature = hash_hmac('sha256', $payload, $webhookSecret);

$headers = [
    "X-Merchant-Token: {$merchantToken}",
    "X-Timestamp: {$timestamp}",
    "X-Signature: t={$timestamp},v1={$signature}",
    "Content-Type: application/json"
];
// Send to https://api.syncra.money/api/v1/p2p/merchant/deals/payin
?>

Important gotchas and edge cases

  1. Amount conversion: When converting amounts from the V1 string format to the V2 int64, floating-point types (float, double) are strictly forbidden. Use string parsing: split the string at the dot ., check that the fractional part has at most two digits, pad with zeros if needed, and convert to an integer number of kopecks. For example, "1500.5" -> "1500" and "5" -> "1500" and "50" -> 150050.

  2. Webhook signatures: Old V1 webhooks were signed with the UTC start-of-day time (StartOfDay). New V2 webhooks use the actual send time of the webhook (Timestamp). Remember to update the validation logic in your receiving script.

  3. PayOut statuses: The cascade auto-matching at creation (POST /deals/payout) is synchronous: the response arrives immediately in MATCHED (a free trader was found) or in UNASSIGNED (no free capacity). For UNASSIGNED, a retry worker keeps matching; if the assignment SLA expires, the payout moves to the terminal EXPIRED with the hold returned to the merchant. INITIALIZED is the state machine's starting status (observed only in edge cases: the provider branch, races) and is practically never seen in the create response. Then MATCHED → PROCESSING (the provider executes the transfer) → COMPLETED | FAILED (in V1, EXPIRED/FAILED map to code 30; a CANCELLED status does not exist for PayOut payouts in V2 — CANCELLED applies only to deposit PayIn deals, see the Status reference). The full FSM and table are in the Status reference.

On this page