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:
-
Change the base URL of requests: In your API settings, replace the base request address
https://api-merchant.syncra.me/v1/withhttps://api.syncra.money/v1/. -
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 theMERCHANTandSIGNATUREheaders 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.
| Category | Actual V1 route | Payload format | Fields |
|---|---|---|---|
| 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), otherwise400 INVALID_ARGUMENT. - Idempotency by
requestId: a repeat request with the samerequestIdreturns 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(duplicatesid),userId,status,requestId,blockchain(always1),currency(the numeric USDT code),amount(a string),wallet,comment,isBalanceCommission,exchangeRate(1.0, USDT → USDT),sentAmount(equal to the amount only in status3),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 aliasescomment/proofImageBase64add 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/listaccept an optional body filter:status(a numeric V1 code; an unknown code answers400 INVALID_ARGUMENT),skip,take(1–100),startDate/endDate(RFC3339, by creation date);incoming/listadditionally acceptsid(UUID) to narrow to a single deal. - The GET endpoints
/payments/incoming/{id},/payments/outgoing/{id},/merchant/withdraw/{id},/disputs/{id}answer404 NOT_FOUNDfor an entity of another merchant — the fact of existence is not disclosed (not403). - 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) answerHTTP 503,failures[].id = "UNAVAILABLE", with the textno matching trader available, retry later— mirroring the V2 semantics (gRPCUNAVAILABLE→ 503); retry with exponential backoff.400 INVALID_ARGUMENTstays non-retryable (fix the request body). - The PayOut webhook:
PROCESSING→ code40(InProgress),FAILED/EXPIRED→ code30(Failed); previously both states arrived as code1(WaitingProcessing), and the merchant never learned about the payout failure. - GET
/v1/payments/incoming/{id}for a deal inREFUND_PENDINGreturns32(RollingBack), while the webhook at the same stage sends16(Returned) — the divergence between the surfaces is deliberate (see the V1 code registry).REFUND_PENDINGis a transit: the16webhook is reliably followed by12(Cancelled); the deal does not return to the payment cycle and never hangs in16.
Full migration (recommended)
- Get V2 credentials: Log in to the new admin dashboard and generate a V2 API token and Webhook Secret.
- Change the base URL: Update the request address from
https://api-merchant.syncra.me/v1/tohttps://api.syncra.money/api/v1/p2p/merchant/. - Update the authentication headers:
- Replace the
MERCHANTheader withX-Merchant-Token. - Replace the
SIGNATUREheader withX-Signature. - Add the mandatory
X-Timestampheader.
- Replace the
- Replace the signature algorithm: Switch from HMAC-SHA512 to HMAC-SHA256.
- 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. - Update the amount format: Move from string amounts with a dot (for
example,
"1500.50") to integer kopecks (for example,150050inint64). - Update the currency codes: Replace numeric ISO codes with
three-letter string codes (for example,
643→"RUB",10001→"USDT"). - Update response handling: Move from the old response envelope
{failures, value, isSuccess}to top-level proto fields with nodatawrapper: a single object —{"deal": {...}}, lists —{"deals": [...] , "next_page_token": ""}(gRPC-gateway renders the response directly). - Update webhook signature verification: Reconfigure your webhook
handler to decode the new
X-Signatureformat based on SHA-256 (instead of the V1 binding to the start of the day,StartOfDay). - 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 field | V2 API field | V1 type | V2 type | Conversion example |
|---|---|---|---|---|
payInId | idempotency_key | string | string | "order_123" → "order_123" |
amount | amount | string | int64 | "1500.50" → 150050 |
currency | currency | int | string | 643 → "RUB" |
clientId | client_id | string | string | "user_99" → "user_99" |
method | payment_method_id | string | string (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 field | V2 API field | V1 type | V2 type | Conversion example |
|---|---|---|---|---|
payOutId | idempotency_key | string | string | "payout_123" → "payout_123" |
amount | amount | string | int64 | "5000.00" → 500000 |
currency | currency | int | string | 643 → "RUB" |
cardNumber | target_requisite | string | string | "2202201234567890" → "2202201234567890" |
fullName | target_name | string | string | "Ivan I." → "Ivan I." |
paymentMethod | payment_method | string | string (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 code | V2 status (canonical) | Comment |
|---|---|---|
1 | INITIALIZED | WaitingPayment — the deal is created, awaiting payment. |
2 | PAYMENT_NOTIFIED | ConfirmedByPayer — "I paid". |
11 | COMPLETED | Paid — terminal success. |
12 | CANCELLED (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. |
13 | EXPIRED | Timeout — a terminal timeout. |
14 | COMPLETED (+ amount_match_status: overpaid) | Overpayment — V2 has no separate status for it. |
15 | COMPLETED (+ amount_match_status: partial) | Underpayment — V2 has no separate status for it. |
16 | REFUND_PENDING | Returned — the refund transit: the 16 webhook is followed by 12 Cancelled. |
21 | APPEALED | Disput — an appeal is open. |
31 | INITIALIZED | WaitingPaymentChannel — 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
-
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. -
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. -
PayOut statuses: The cascade auto-matching at creation (
POST /deals/payout) is synchronous: the response arrives immediately inMATCHED(a free trader was found) or inUNASSIGNED(no free capacity). ForUNASSIGNED, a retry worker keeps matching; if the assignment SLA expires, the payout moves to the terminalEXPIREDwith the hold returned to the merchant.INITIALIZEDis the state machine's starting status (observed only in edge cases: the provider branch, races) and is practically never seen in the create response. ThenMATCHED→PROCESSING(the provider executes the transfer) →COMPLETED|FAILED(in V1,EXPIRED/FAILEDmap to code30; aCANCELLEDstatus does not exist for PayOut payouts in V2 —CANCELLEDapplies only to deposit PayIn deals, see the Status reference). The full FSM and table are in the Status reference.