S
docs.syncra.money
Payments

Accepting payments (PayIn)

Integrating inbound P2P payments with the Syncra Merchant API V2

Accepting payments (PayIn)

The inbound payments feature (PayIn) lets merchants automate accepting funds from individuals via SBP, bank cards (Card2Card), international IBAN transfers, and other online payment methods. The platform is not tied to a single country: the available markets (Russia, CIS, Turkey, EU, ...) are defined by your tenant configuration, and the channel resolved underneath a payment_method_id is determined by its payment details type (RequisiteType). For the full type reference, see Requisite types.


Payment flow (PayIn Flow)

Загрузка диаграммы...
  1. Deal creation. The merchant sends POST /deals/payin with amount, currency, idempotency_key, and payment_method_id (a UUID or a method slug from the catalog).
  2. Getting the payment URL. The response contains deal_id (the deal UUID) and payment_url (a hosted payform).
  3. Transferring the funds. The client follows payment_url, sees the P2P trader's payment details, and makes the transfer in their mobile banking app.
  4. Notification. Once the receipt is verified, the gateway sends the merchant the deal.completed webhook (see Callbacks).

PayIn creation endpoint

POST /api/v1/p2p/merchant/deals/payin

Request parameters (JSON Body)

FieldTypeRequiredDescription
amountint64YesAmount in minor units (kopecks for RUB). 150000 = 1,500.00 RUB.
currencystringYesISO 4217 currency code ("RUB", "KZT", "TRY", ...).
idempotency_keystringYesUnique request ID on the merchant side (idempotency).
client_idstringNoPayer ID in your system (your service, dashboard).
payment_method_idstringYes — REQUIRED; empty → 400 INVALID_ARGUMENTReference to the payment method: a UUID (canonical format, the fast path with no DB lookup) or a slug (sbp_rub, card_rub, ...) from GET /api/v1/p2p/merchant/payment-methods — both values resolve to the same method (see Banks). An unknown value → 400 with the list of valid slugs. The routing cascade is method-scoped, so the method is mandatory.
currency_idstring(UUID)NoCurrency UUID. Required for pass-matching; can be omitted for a regular deal.
target_bankstringNoCanonical bank code of the requisite (bank routing): the bank_name value from GET /banks?method=.... The "1click Alfa" button = method + target_bank: alfa — the cascade returns a requisite of that bank only (a strict tenant policy). When omitted, routing works by method as before. An unknown code → 400 with the list of valid ones.
payer_bankstringNoHint about the PAYER's bank (free text, display only; it does not route — for routing there is target_bank).
descriptionstringNoPayment description from the merchant. Shown to the client on the payment page.
callback_urlstringNoWebhook handler URL for this deal (overrides the merchant's webhook_url delivery address; PayIn only).
is_client_commissionboolNoCommission mode: true — the client pays it; false (default) — it is deducted from the merchant's amount.
modestringNoDeal presentation mode: "HOSTED" (default; same as empty) — payment details on the hosted payform, or "DIRECT" — the recipient's payment details are returned in the API response (see Direct mode (H2H)). Matching, escrow, and webhooks do not depend on the mode. Any other value → 400 INVALID_ARGUMENT ("invalid mode ...: must be HOSTED, DIRECT or empty (default HOSTED)"); the deal is not created.

callback_url overrides only the webhook delivery address. It does NOT waive the precondition of configuring webhook_url + the webhook secret at the merchant account level (dashboard → Webhooks). Without the account-level configuration, deal creation → 400 FAILED_PRECONDITION.

Client redirect URLs (success_url, failed_url) are not proto fields of CreateMerchantPayInRequest and are not passed in the request. They are configured at the merchant level through the admin panel and apply to all deals automatically. Per-deal success_url/failed_url passthrough is not supported in V2.

Request example

{
  "amount": 250050,
  "currency": "RUB",
  "idempotency_key": "order_uuid_abc123456",
  "client_id": "player_user_9921",
  "payment_method_id": "550e8400-e29b-41d4-a716-446655440000",
  "currency_id": "9a3f1c2e-1234-4abc-9def-56789abcdef0",
  "payer_bank": "sber",
  "description": "Client account top-up for player_user_9921",
  "callback_url": "https://merchant.example/callbacks/syncra",
  "is_client_commission": false
}

Response example (200 OK)

The response is a MerchantDealInfo object in the top-level deal field (there is no data wrapper). The key identifier field is deal_id (NOT id). The payment page URL is payment_url (NOT payment_page_url).

{
  "deal": {
    "deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
    "status": "ESCROW_LOCKED",
    "amount": "250050",
    "currency": "RUB",
    "created_at": "2026-07-12T17:20:00Z",
    "client_id": "player_user_9921",
    "deal_type": "PAYIN",
    "updated_at": "2026-07-12T17:20:00Z",
    "payment_url": "https://checkout.syncra.money/pay/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
    "target_requisite": "",
    "received_amount": "0",
    "amount_match_status": "",
    "amount_usdt": "512400",
    "exchange_rate_micro": "488181",
    "mode": "",
    "requisites": null
  }
}

payment_url is built as {checkout_base_url}/pay/{order.Hash}, where order.Hash is the 64-hex public identifier of the deal (unguessable, no auth). target_requisite is empty for PayIn (it is a field of payout deals).

In the 200 OK response the status is ESCROW_LOCKED: trader selection and the escrow lock run synchronously within the request (when matching fails, the call ends with an error instead of returning a "hanging" deal). INITIALIZED is a short-lived transitional status meaning "the deal is created, the payment details are not allocated yet": it is practically never seen in the creation response, but can appear in GET /deals/GET /deals/{id} before selection completes.

Response example for TRY (BANK_TRANSFER / Havale)

Creating a PayIn in TRY is no different from RUB: the same endpoint, the same field set — only currency and payment_method_id change (the UUID of an IBAN method from GET /payment-methods).

{
  "deal": {
    "deal_id": "9d4a07c2-3b1e-4f6a-9c25-8e7f1b0d3a44",
    "status": "ESCROW_LOCKED",
    "amount": "2500000",
    "currency": "TRY",
    "created_at": "2026-08-18T10:05:00Z",
    "client_id": "player_user_4477",
    "deal_type": "PAYIN",
    "updated_at": "2026-08-18T10:05:00Z",
    "payment_url": "https://checkout.syncra.money/pay/f3e2d1c0b9a80796f5e4d3c2b1a0f9e8d7c6b5a49382716055f4e3d2c1b0a9988",
    "target_requisite": "",
    "received_amount": "0",
    "amount_match_status": "",
    "amount_usdt": "512400",
    "exchange_rate_micro": "205049",
    "mode": "",
    "requisites": null
  }
}

amount: 2500000 = 25,000.00 TRY (minor units are kuruş, 2 decimal places). target_requisite is always empty for PayIn — it is a field of payout deals; the recipient payment details (IBAN, holder, bank) are shown to the client on the payment page (see below).


Direct mode (H2H): payment details in the response

POST /deals/payin supports two deal presentation modes: the optional mode field determines who renders the recipient's payment details. Matching, escrow, fees, the status model, and webhooks are identical in both modes.

HOSTED (default)DIRECT
Payment details are seen bythe client on our payformthe merchant in the API response
Payment screenhosted checkout at payment_urlyour interface (merchant dashboard)
Fits whenyou need speed and a ready-made pagethe payment screen must stay on your side

In DIRECT mode, our payment page is not shown to the client: the same endpoint additionally returns the requisites block (payment details, holder, bank, deadline), which you render in your own interface. payment_url is returned for DIRECT too — the {hash} suffix remains a client capability and is needed for payment confirmation (see below).

Merchant flow (Direct Flow)

  1. Deal creation. POST /deals/payin with "mode": "DIRECT" — the rest of the fields are identical to HOSTED.
  2. Rendering. Show the client the amount (amount), the rate (exchange_rate_micro, amount_usdt), and the details from requisites; start a timer counting down to requisites.expires_at (equal to the deal's payment_timeout_at).
  3. Payment. The client transfers the funds in their bank using the payment details — the channel, cascade, and trader are the same as on the hosted page.
  4. Payment confirmation. Your server (or an "I paid" button in your UI) calls notify-payment — the deal status → PAYMENT_NOTIFIED.
  5. Verification and completion. The trader confirms the receipt, and the deal is driven to COMPLETED by the standard verify → complete cycle — no action is required from you.
  6. Webhooks. The standard deal.* set arrives (see Webhook events); webhook payloads carry no PII.

Mode invariants

  • Single use, tied to the deal. The requisite was issued by cascade matching to this specific deal and is valid until requisites.expires_at (== payment_timeout_at). After it expires, the deal moves to the terminal EXPIRED (terminal statuses are immutable) and the requisite is no longer valid. Reissuing = a new deal with a new idempotency_key — a retry with the old key returns the same EXPIRED deal; there is no separate reissue endpoint.
  • Single source of truth. requisites.requisite_id is exactly the requisite that cascade matching attached to the deal; store it in your logs for reconciliation. Within a live deal, payment details are never regenerated or changed.
  • PII visibility. The requisites block is returned only while the deal awaits payment (ESCROW_LOCKED, PAYMENT_NOTIFIED); in terminal statuses — requisites: null, and PAN/IBAN are not exposed again.
  • Ownership. Payment details are available only to the merchant who owns the deal: GET /deals/{deal_id} on another merchant's DIRECT deal → 403 PERMISSION_DENIED, with no requisites block in the response.
  • Unchanged rate. amount, amount_usdt, and exchange_rate_micro arrive as before (as int64 strings) — show the rate to the client with the same code you use for HOSTED.

DIRECT deal response (mode + requisites)

Besides the standard MerchantDealInfo fields, a DIRECT deal response carries mode: "DIRECT" and the requisites message (HOSTED deals have mode: "" and requisites: null). Fields of requisites:

FieldTypeDescription
requisite_typestringPayment details type — the same name and vocabulary as in requisite_types of the GET /payment-methods catalog: BANK_CARD — bank card, E_WALLET — e-wallet, SBP — SBP (transfer by phone number), SIM — mobile commerce (transfer to a carrier phone number), BANK_TRANSFER — international bank transfer. This is the payment details type, not the payment method: the payment method is still set by the payment_method_id field at deal creation (for example, card_rub, sbp_rub).
card_numberstringFull PAN (unmasked). Only requisite_type=BANK_CARD; "" for the rest.
cardholder_namestringCard/account holder full name. Filled for BANK_CARD/BANK_TRANSFER; for other types — when the requisite contains the holder's name.
bank_namestringDisplay name of the bank/channel (from the method catalog).
phonestringPhone number for the transfer. Filled for E_WALLET/SBP/SIM.
ibanstringFull IBAN. Only requisite_type=BANK_TRANSFER; "" for the rest.
qr_payloadstringReserved for QR; currently always "".
requisite_idstring(UUID)Identifier of the issued requisite (for reconciliation).
expires_atstring(RFC3339)Payment deadline; equal to the deal's payment_timeout_at.

Response example (200 OK, mode: DIRECT)

{
  "deal": {
    "deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
    "status": "ESCROW_LOCKED",
    "amount": "250050",
    "currency": "RUB",
    "created_at": "2026-09-11T10:00:00Z",
    "client_id": "player_user_9921",
    "deal_type": "PAYIN",
    "updated_at": "2026-09-11T10:00:00Z",
    "payment_url": "https://checkout.syncra.money/pay/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
    "target_requisite": "",
    "received_amount": "0",
    "amount_match_status": "",
    "amount_usdt": "500100",
    "exchange_rate_micro": "50000000",
    "mode": "DIRECT",
    "requisites": {
      "requisite_type": "BANK_CARD",
      "card_number": "2202201234567890",
      "cardholder_name": "Ivan Ivanovich",
      "bank_name": "Sberbank",
      "phone": "",
      "iban": "",
      "qr_payload": "",
      "requisite_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "expires_at": "2026-09-11T10:20:00Z"
    }
  }
}

amount_usdt is the deal's USDT equivalent in minor units of USDT (500100 = 5,001.00 USDT), and exchange_rate_micro is the effective rate at creation time (50000000 = 50.00 RUB per 1 USDT): the same rate field as for HOSTED deals.

Compact requisites blocks of the other kinds:

SBP — SBP, transfer by phone number:

{
  "requisite_type": "SBP",
  "card_number": "",
  "cardholder_name": "",
  "bank_name": "T-Bank",
  "phone": "+79001234567",
  "iban": "",
  "qr_payload": "",
  "requisite_id": "6f1d2b3c-9e47-4a58-8c21-0b5d3e7f9a11",
  "expires_at": "2026-09-11T10:20:00Z"
}

SIM — mobile commerce, transfer to a carrier phone number (for e-wallet requisites, the block has the same format with requisite_type=E_WALLET):

{
  "requisite_type": "SIM",
  "card_number": "",
  "cardholder_name": "",
  "bank_name": "Beeline",
  "phone": "+79067654321",
  "iban": "",
  "qr_payload": "",
  "requisite_id": "8c2e4f60-1d3b-4c5a-9e77-2f8a0b1c3d52",
  "expires_at": "2026-09-11T10:20:00Z"
}

BANK_TRANSFER — international transfer (Havale / IBAN):

{
  "requisite_type": "BANK_TRANSFER",
  "card_number": "",
  "cardholder_name": "Ahmet Yilmaz",
  "bank_name": "Ziraat — Havale",
  "phone": "",
  "iban": "TR330006100519786457841326",
  "qr_payload": "",
  "requisite_id": "2b7a9c10-5e63-4f88-a2d4-6c0b7e9f1a33",
  "expires_at": "2026-09-11T10:20:00Z"
}

When matching is not immediately available (SLU)

Mainline matching is synchronous: a regular creation response is already ESCROW_LOCKED with ready payment details. The other outcomes:

Creation responseWhat the merchant should do
200, status: "ESCROW_LOCKED", requisites filledThe regular path: render the payment details and the timer.
200, status: "INITIALIZED", requisites: nullThe deal is waiting for the matching verdict: wait for the deal.escrow_locked webhook, then fetch the payment details via GET /api/v1/p2p/merchant/deals/{deal_id} (the same requisites block).
503 UNAVAILABLE ("no matching trader available, retry later")No free provider is available; the deal is immediately recorded in the terminal EXPIRED. Create the payment again later — with a new idempotency_key (a retry with the old one returns the EXPIRED deal). Retry with backoff — per the retry matrix.

Client payment confirmation (notify-payment)

Two public endpoints perform the same ESCROW_LOCKED → PAYMENT_NOTIFIED transition; pick one.

POST /api/v1/p2p/checkout/{hash}/notify-payment

No authorization is needed: {hash} is the 64-hex suffix of payment_url after /pay/ (for DIRECT, payment_url is returned the same way as for HOSTED). The body is optional; you can pass the UUIDs of proof files (receipt screenshots):

# hash = the last segment of payment_url after /pay/
curl -X POST https://api.syncra.money/api/v1/p2p/checkout/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/notify-payment \
  -H "Content-Type: application/json" \
  -d '{"payment_proof_file_ids": ["f3a1b2c3-4d5e-6f7a-8b9c-0d1e2f3a4b5c"]}'
CodeWhen
200Deal → PAYMENT_NOTIFIED; the response is an updated CheckoutView. Calling again with the same hash idempotently collapses into the already-performed transition (a deterministic server-side key): repeated "I paid" taps do not create second transitions.
400The body is invalid JSON.
403The client is blocked (antifraud).
404Unknown hash.
409Invalid transition: the deal is already PAYMENT_NOTIFIED or in a later/terminal status — a second "I paid" signal is not accepted.

Endpoint by order_id (alternative)

POST /api/v1/p2p/orders/{order_id}/notify-payment

Also public (the payer has no account) — the mandatory deal_hash serves as the capability gate. The body:

FieldTypeRequiredDescription
order_idstring(UUID)YesDeal identifier (in the URL path).
deal_hashstringYesThe 64-hex suffix of payment_url — the payer's capability; empty or mismatched → 403 PERMISSION_DENIED.
idempotency_keystring(UUID)YesNotification key: a retry with the same key → 200 replay; a new key on a deal that has already received the signal (PAYMENT_NOTIFIED) → 400 FAILED_PRECONDITION — do not retry.
tenant_idstring(UUID)NoResolved from the deal itself by order_id.
file_idsarrayNoUUIDs of proof files from the files service.
curl -X POST https://api.syncra.money/api/v1/p2p/orders/7ac148fe-19a3-45bb-b992-019fac55b721/notify-payment \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
    "idempotency_key": "0d3ee6e2-7c4f-4d3a-9f2e-6b8a1c5d9e01",
    "deal_hash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
  }'

After PAYMENT_NOTIFIED, the rest is the standard cycle: trader verify → complete and the deal.payment_verified, deal.completed webhooks (see Webhook events).

The default mode is HOSTED: the mode field is optional, and existing integrations do not change. For hosted deals the response carries an empty mode ("") and requisites: null — the previous values are preserved, and only two new keys were added to the JSON (the serializer emits all proto fields). Error handling and the retry matrix do not change: do not retry 400; retry 503 with backoff.


PayIn deal statuses

The canonical lifecycle is domain.OrderStatus (11 values, UPPER_SNAKE). The full table with the V1 codes is in the status reference.

V2 statusFinal?Description
INITIALIZEDNoDeal created, waiting for the escrow lock.
ESCROW_LOCKEDNoEscrow reserved, payment details issued.
PAYMENT_NOTIFIEDNoThe client pressed "I paid".
PAYMENT_VERIFIEDNoReceipt confirmed, amount reconciled.
COMPLETEDYesCompleted successfully (amount_match_status=full/partial/overpaid).
EXPIREDYesExpired by payment_timeout_at.
APPEALEDNoAn appeal is open.
REFUND_PENDINGNoInternal transit to CANCELLED: a rejected appeal or a third-party flag. Externally — a pair of webhooks deal.refund_pending → deal.cancelled; no merchant action required.
SPAM_REJECTEDYesRejected by anti-spam.
SOFT_DISPUTEDNoSoft dispute.
CANCELLEDYesThe client or the merchant explicitly canceled the deal before payment confirmation, or a REFUND_PENDING transit was finalized (see Canceling a deal).

Canceling a deal (Cancel)

A PayIn deal can be explicitly canceled before the payment is confirmed — the status moves to the terminal CANCELLED. Cancellation is available from INITIALIZED, ESCROW_LOCKED, and PAYMENT_NOTIFIED; after PAYMENT_VERIFIED (and even more so from COMPLETED/APPEALED and the other states) cancellation is impossible — the payment has already been confirmed by the trader.

Two endpoints, one and the same FSM transition:

Cancellation by the merchant

POST /api/v1/p2p/merchant/deals/{deal_id}/cancel

Authentication is the merchant's standard HMAC pair (X-Merchant-Token / X-Signature, same as the other /merchant/* endpoints). The body:

FieldTypeRequiredDescription
reasonstringNoFree-form cancellation reason (logged by the platform).

The 200 OK response is the canceled deal's MerchantDealInfo object in the deal field (the same structure as GET /deals/{deal_id}, with status: "CANCELLED").

Cancellation by the client (payment page)

POST /api/v1/p2p/checkout/{hash}/cancel

A public endpoint by the checkout hash from payment_url (no authorization, empty body) — called by the cancel button on the hosted payform. Returns an updated CheckoutView.

Rules and response codes

CodeWhen
200The deal is canceled; calling again on an already-CANCELLED deal idempotently returns 200 without a second transition.
400Invalid transition — the deal is already in PAYMENT_VERIFIED/COMPLETED/a terminal status.
404deal_id not found, belongs to another merchant, or is the ID of a PayOut payout (cancel is a PayIn-only operation: PayOut deals live in a separate table and answer 404 without revealing their existence). For the checkout branch — an unknown hash.
409Checkout branch: the deal is in a terminal status and can no longer be canceled.
  • Canceling after ESCROW_LOCKED automatically releases the escrow holds (order-level + per-match) — the funds are "unfrozen" without merchant involvement.
  • fee_breakdown is zeroed out, and the deal's matches move to FAILED (the same fail-family semantics as for EXPIRED).
  • A deal.cancelled webhook arrives about the cancellation (see Callbacks).

Amount discrepancies (full / partial / overpaid)

The actual amount transferred by the payer is reconciled against the deal amount: the result is recorded in the deal's received_amount (minor units) and amount_match_status fields and delivered in webhooks after verification.

The tolerance is zero. Automatic completion is possible only when the amounts match exactly — down to the kopeck, with no rounding, fees, or rate windows. Any mismatch (partial/overpaid) means the payment is considered unmatched: no ledger entries arise for the surplus or the shortfall, the deal lives until the timeout and ends in a terminal stage; after that — only manual review through an appeal. "Auto-complete by the actual amount" does not exist and cannot be enabled.

amount_match_statusSituationWhat happens
fullExactly the declared amount was received (an exact match).The standard flow: verify → complete, the deal.completed webhook.
partialLess than declared was received.The payment did not match the deal amount: auto-completion is blocked, the deal stays in its current status and is handed to operators. The timeout keeps ticking: unless the operator changes the deal amount (see Changing the amount) or credits the payment, the deal expires in EXPIRED with the recorded received_amount — afterwards, a settle-as-received is possible.
overpaidMore than declared was received.The payment did not match the deal amount: auto-completion is blocked the same way; the surplus is extraneous income that produces no ledger entries of any kind: the platform records the payment details (amount, time, source, related deal) and hands them to operators; any further investigation is manual, through an appeal.

Changing the deal amount (amend)

While a deal is alive (the statuses INITIALIZED, ESCROW_LOCKED, PAYMENT_NOTIFIED, SOFT_DISPUTED — everything before PAYMENT_VERIFIED), a platform operator can change its fiat amount: for a partial payment, a merchant amount mistake, and similar cases. This is an operator action in the cabinet (not a merchant-API call):

POST /api/v1/p2p/deals/{deal_id}/amount
Body fieldTypeRequiredDescription
amount_fiatint64YesThe new amount in minor units (> 0).
reasonstringYesThe reason for the change — recorded verbatim in the immutable audit log.
idempotency_keystring(UUID)YesOperator action key: a retry with the same key and the same amount → replay; with a different amount → 409.
expected_amount_versionint64NoOptimistic locking (an If-Match-style mechanism): the amount version the operator saw. 0/not set — do not check; a mismatch → 409, and the operator re-reads the deal.

What happens on amend (atomically, in a single transaction):

  • amount_usdt is recalculated at the deal's own rate (exchange_rate_micro, fixed at creation — the settlement terms do not change);
  • the escrow holds are re-created for the new amount: order-level + all per-match (the holds' idempotency keys are derived from (deal_id, amount_version));
  • amount_version is incremented by 1 (if expected_amount_version was passed and did not match — a 409 refusal);
  • an immutable (INSERT-only) audit row p2p_deal_amount_changes is written, and the deal.amount_changed event is emitted (V2 only).

After PAYMENT_VERIFIED, changing the amount is impossible — the amount has already been authorized; discrepancies are closed by settlement (below) or a refund.


Late payment settlement (Settle-as-Received)

A deal in EXPIRED never comes back to life (terminal statuses are immutable). If the payment actually arrived by the time of expiry (received_amount > 0, usually amount_match_status = partial/overpaid), the operator closes the dispute with a settlement: per the appeal resolution, a new settlement deal is created:

POST /api/v1/p2p/appeals/{appeal_id}/settlement
Body fieldTypeRequiredDescription
received_amount_overrideint64NoAmount override (by default — the original's received_amount). When set, reason is mandatory (the override_used audit flag).
reasonstringOn overrideJustification for the manual override.
idempotency_keystring(UUID)YesAction key; a retry is a replay of the existing settlement.

Settlement properties:

  • it is a new deal_id with the links parent_deal_id (the expired original) and origin_appeal_id (one appeal → at most one settlement, UNIQUE);
  • the amount = the original's received_amount; the rate is new, as of the resolution moment (fixed in the settlement record);
  • the money path is standard: escrow hold → verify → complete in a single action; fees and the ledger are computed as for a regular deal;
  • the settlement receives the full set of deal.created ... deal.completed webhooks (both API versions);
  • the appeal is resolved as SATISFIED with a reference to the settlement in resolution_note;
  • the original stays EXPIRED forever; its received_amount is kept as a historical fact. Listings show both records with the link (parent_deal_id).

Requirements: the parent is EXPIRED, the appeal is in OPEN/IN_PROGRESS/REOPENED, received_amount > 0 (or a justified override). Violations → 409/400.


Supported payment methods

A single contract for all methods. The POST /deals/payin endpoint accepts the same field set for any payment method — method-specific request parameters do not exist. Choosing a method is nothing more than payment_method_id (a UUID); all the channel specifics (payment details type, display format, payer instructions) are encapsulated on the platform side and shown on the payment page. If connecting a new method requires nothing from you apart from getting its UUID, that is the norm, not an exception.

Do not hardcode method UUIDs in your code. The identifiers are generated per tenant and change when a method is re-created; the set of available methods and their status are managed by the platform (a method can be enabled, disabled, replaced) without a release on your side. Read GET /api/v1/p2p/merchant/payment-methods when initializing the integration and refresh the cached list at the start of every payment session: new methods then appear for your clients without changes to your code, and disabled ones correctly disappear from the checkout. Store the mapping of your products/categories to payment_method_id in your database, not in a config file.

The available methods are defined by the tenant configuration (the p2p_payment_methods table). When creating a payment, pass the method reference in the payment_method_id field: a UUID (canonical format — the catalog's id field) or a slug (the slug field). The canonical source for both forms is the merchant endpoint GET /api/v1/p2p/merchant/payment-methods: it returns your tenant's methods with their id (UUID), slug, name, requisite_types, currency_code, and status (see Getting the method catalog via the API). An unknown UUID/slug is rejected with 400 INVALID_ARGUMENT, and the error message lists the valid slugs of the active methods.

GET /banks does not return method UUIDs — its methods field contains requisite types (RequisiteType: BANK_CARD, SBP, BANK_TRANSFER, ...), not identifiers. Do not use values from GET /banks in payment_method_id — take the UUID (id) or the slug (slug) only from GET /payment-methods.

Typical channels (in payment_method_id, pass the id (UUID) or the slug of a specific method of your tenant from GET /payment-methods — catalog canonical slugs such as sbp_rub, card_rub, mobcom_rub, express-havale):

  • SBP — transfer by phone number. RequisiteType: SBP (the catalog slug — sbp_rub).
  • Transfer by card number. RequisiteType: BANK_CARD (the slug — card_rub).
  • Sberbank / T-Bank quick payment. RequisiteType: BANK_CARD / SBP.
  • Mobile commerce methods — topping up a mobile carrier's account (RequisiteType: SIM; the slug — mobcom_rub). The requisite shown to the payer is the carrier's phone number; the carrier name is shown on the payment page as the "receiving bank". The payer's own phone number (MSISDN) is not required and is never sent anywhere: the payer makes the transfer from their own banking mobile app.
  • International transfer by IBAN (ISO 13616) for non-RU markets (TRY, EUR, ...). RequisiteType: BANK_TRANSFER (a slug like express-havale — set when the method is created for your tenant).

BANK_TRANSFER is not a separate endpoint but one of the requisite types. A PayIn is created with the same POST /deals/payin with the payment_method_id of the corresponding IBAN method; the specifics are shown only on the payment page (CheckoutView.iban).


Where to find the recipient's payment details in PayIn

In the default mode (HOSTED), the recipient's payment details (card number, SBP phone, IBAN for BANK_TRANSFER) are not returned in the response body of POST /deals/payin — it contains only deal_id and payment_url (PII minimization: payment data never leaves the payment perimeter). The single exception is Direct mode (H2H): for DIRECT deals, the requisites block is given to the owner merchant directly in the creation response and in GET /deals/{deal_id}.

The client gets the payment details by opening payment_url: the payment page calls the public endpoint

GET /api/v1/p2p/checkout/{hash}

where {hash} is the 64-hex identifier from payment_url (a capability token, no authorization needed). In the response (CheckoutView):

FieldWhat it contains
ibanRecipient IBAN (ISO 13616) for BANK_TRANSFER deals.
holder_nameFull name of the trader's card/account holder.
payment_method_nameBank/channel name (for example, "Havale — Ziraat").
card_number / phone_numberFull PAN / phone for BANK_CARD / SBP / SIM (for SIM methods — the carrier's phone number).

The full CheckoutView contract and a response example are in SDK Examples §9; the IBAN payment screen is described in Hosted Checkout → IBAN transfers. In terminal states (COMPLETED, EXPIRED, ...), the sensitive fields are zeroed out.


curl example

curl -X POST https://api.syncra.money/api/v1/p2p/merchant/deals/payin \
  -H "X-Merchant-Token: your_token_here" \
  -H "X-Timestamp: 1783856120" \
  -H "X-Signature: t=1783856120,v1=9e87d0c3c8801d0a5198d023b6b19a16f2c8d2037920ab3e09819cd8e412cfab" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100000,
    "currency": "RUB",
    "payment_method_id": "550e8400-e29b-41d4-a716-446655440000",
    "idempotency_key": "my_unique_p2p_key_001"
  }'

Webhook events

Creating a PayIn generates the following events (delivered to your callback URL):

EventOrder statusDescription
deal.createdINITIALIZEDOrder created
deal.escrow_lockedESCROW_LOCKEDUSDT collateral reserved
deal.payment_notifiedPAYMENT_NOTIFIEDThe client pressed "I paid"
deal.amount_changed(no status change)An operator changed the amount of a live deal; payload: old_amount + the new amount + amount_version. V2 only
deal.payment_verifiedPAYMENT_VERIFIEDThe trader confirmed the receipt
deal.completedCOMPLETEDThe deal is completed, the funds are credited
deal.expiredEXPIREDThe order expired by timeout
deal.appealedAPPEALEDA dispute was opened
deal.refund_pendingREFUND_PENDINGTransit notification: the deal is being finalized with a cancellation; deal.cancelled always follows it
deal.cancelledCANCELLEDThe client or the merchant explicitly canceled the deal before payment confirmation

On this page