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)
- Deal creation. The merchant sends
POST /deals/payinwithamount,currency,idempotency_key, andpayment_method_id(a UUID or a method slug from the catalog). - Getting the payment URL. The response contains
deal_id(the deal UUID) andpayment_url(a hosted payform). - Transferring the funds. The client follows
payment_url, sees the P2P trader's payment details, and makes the transfer in their mobile banking app. - Notification. Once the receipt is verified, the gateway sends the
merchant the
deal.completedwebhook (see Callbacks).
PayIn creation endpoint
POST /api/v1/p2p/merchant/deals/payinRequest parameters (JSON Body)
| Field | Type | Required | Description |
|---|---|---|---|
amount | int64 | Yes | Amount in minor units (kopecks for RUB). 150000 = 1,500.00 RUB. |
currency | string | Yes | ISO 4217 currency code ("RUB", "KZT", "TRY", ...). |
idempotency_key | string | Yes | Unique request ID on the merchant side (idempotency). |
client_id | string | No | Payer ID in your system (your service, dashboard). |
payment_method_id | string | Yes — REQUIRED; empty → 400 INVALID_ARGUMENT | Reference 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_id | string(UUID) | No | Currency UUID. Required for pass-matching; can be omitted for a regular deal. |
target_bank | string | No | Canonical 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_bank | string | No | Hint about the PAYER's bank (free text, display only; it does not route — for routing there is target_bank). |
description | string | No | Payment description from the merchant. Shown to the client on the payment page. |
callback_url | string | No | Webhook handler URL for this deal (overrides the merchant's webhook_url delivery address; PayIn only). |
is_client_commission | bool | No | Commission mode: true — the client pays it; false (default) — it is deducted from the merchant's amount. |
mode | string | No | Deal 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 by | the client on our payform | the merchant in the API response |
| Payment screen | hosted checkout at payment_url | your interface (merchant dashboard) |
| Fits when | you need speed and a ready-made page | the 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)
- Deal creation.
POST /deals/payinwith"mode": "DIRECT"— the rest of the fields are identical to HOSTED. - Rendering. Show the client the amount (
amount), the rate (exchange_rate_micro,amount_usdt), and the details fromrequisites; start a timer counting down torequisites.expires_at(equal to the deal'spayment_timeout_at). - 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.
- Payment confirmation. Your server (or an "I paid" button in your
UI) calls notify-payment — the deal status →
PAYMENT_NOTIFIED. - Verification and completion. The trader confirms the receipt, and
the deal is driven to
COMPLETEDby the standard verify → complete cycle — no action is required from you. - 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 terminalEXPIRED(terminal statuses are immutable) and the requisite is no longer valid. Reissuing = a new deal with a newidempotency_key— a retry with the old key returns the same EXPIRED deal; there is no separate reissue endpoint. - Single source of truth.
requisites.requisite_idis 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
requisitesblock 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, andexchange_rate_microarrive 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:
| Field | Type | Description |
|---|---|---|
requisite_type | string | Payment 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_number | string | Full PAN (unmasked). Only requisite_type=BANK_CARD; "" for the rest. |
cardholder_name | string | Card/account holder full name. Filled for BANK_CARD/BANK_TRANSFER; for other types — when the requisite contains the holder's name. |
bank_name | string | Display name of the bank/channel (from the method catalog). |
phone | string | Phone number for the transfer. Filled for E_WALLET/SBP/SIM. |
iban | string | Full IBAN. Only requisite_type=BANK_TRANSFER; "" for the rest. |
qr_payload | string | Reserved for QR; currently always "". |
requisite_id | string(UUID) | Identifier of the issued requisite (for reconciliation). |
expires_at | string(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 response | What the merchant should do |
|---|---|
200, status: "ESCROW_LOCKED", requisites filled | The regular path: render the payment details and the timer. |
200, status: "INITIALIZED", requisites: null | The 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.
Endpoint by checkout hash (recommended)
POST /api/v1/p2p/checkout/{hash}/notify-paymentNo 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"]}'| Code | When |
|---|---|
200 | Deal → 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. |
400 | The body is invalid JSON. |
403 | The client is blocked (antifraud). |
404 | Unknown hash. |
409 | Invalid 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-paymentAlso public (the payer has no account) — the mandatory deal_hash serves
as the capability gate. The body:
| Field | Type | Required | Description |
|---|---|---|---|
order_id | string(UUID) | Yes | Deal identifier (in the URL path). |
deal_hash | string | Yes | The 64-hex suffix of payment_url — the payer's capability; empty or mismatched → 403 PERMISSION_DENIED. |
idempotency_key | string(UUID) | Yes | Notification 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_id | string(UUID) | No | Resolved from the deal itself by order_id. |
file_ids | array | No | UUIDs 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 status | Final? | Description |
|---|---|---|
INITIALIZED | No | Deal created, waiting for the escrow lock. |
ESCROW_LOCKED | No | Escrow reserved, payment details issued. |
PAYMENT_NOTIFIED | No | The client pressed "I paid". |
PAYMENT_VERIFIED | No | Receipt confirmed, amount reconciled. |
COMPLETED | Yes | Completed successfully (amount_match_status=full/partial/overpaid). |
EXPIRED | Yes | Expired by payment_timeout_at. |
APPEALED | No | An appeal is open. |
REFUND_PENDING | No | Internal 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_REJECTED | Yes | Rejected by anti-spam. |
SOFT_DISPUTED | No | Soft dispute. |
CANCELLED | Yes | The 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}/cancelAuthentication is the merchant's standard HMAC pair (X-Merchant-Token /
X-Signature, same as the other /merchant/* endpoints). The body:
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Free-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}/cancelA 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
| Code | When |
|---|---|
200 | The deal is canceled; calling again on an already-CANCELLED deal idempotently returns 200 without a second transition. |
400 | Invalid transition — the deal is already in PAYMENT_VERIFIED/COMPLETED/a terminal status. |
404 | deal_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. |
409 | Checkout branch: the deal is in a terminal status and can no longer be canceled. |
- Canceling after
ESCROW_LOCKEDautomatically releases the escrow holds (order-level + per-match) — the funds are "unfrozen" without merchant involvement. fee_breakdownis zeroed out, and the deal's matches move toFAILED(the same fail-family semantics as forEXPIRED).- A
deal.cancelledwebhook 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_status | Situation | What happens |
|---|---|---|
full | Exactly the declared amount was received (an exact match). | The standard flow: verify → complete, the deal.completed webhook. |
partial | Less 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. |
overpaid | More 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 field | Type | Required | Description |
|---|---|---|---|
amount_fiat | int64 | Yes | The new amount in minor units (> 0). |
reason | string | Yes | The reason for the change — recorded verbatim in the immutable audit log. |
idempotency_key | string(UUID) | Yes | Operator action key: a retry with the same key and the same amount → replay; with a different amount → 409. |
expected_amount_version | int64 | No | Optimistic 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_usdtis 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_versionis incremented by 1 (ifexpected_amount_versionwas passed and did not match — a409refusal);- an immutable (INSERT-only) audit row
p2p_deal_amount_changesis written, and thedeal.amount_changedevent 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 field | Type | Required | Description |
|---|---|---|---|
received_amount_override | int64 | No | Amount override (by default — the original's received_amount). When set, reason is mandatory (the override_used audit flag). |
reason | string | On override | Justification for the manual override. |
idempotency_key | string(UUID) | Yes | Action key; a retry is a replay of the existing settlement. |
Settlement properties:
- it is a new
deal_idwith the linksparent_deal_id(the expired original) andorigin_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.completedwebhooks (both API versions); - the appeal is resolved as
SATISFIEDwith a reference to the settlement inresolution_note; - the original stays
EXPIREDforever; itsreceived_amountis 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 likeexpress-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):
| Field | What it contains |
|---|---|
iban | Recipient IBAN (ISO 13616) for BANK_TRANSFER deals. |
holder_name | Full name of the trader's card/account holder. |
payment_method_name | Bank/channel name (for example, "Havale — Ziraat"). |
card_number / phone_number | Full 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):
| Event | Order status | Description |
|---|---|---|
deal.created | INITIALIZED | Order created |
deal.escrow_locked | ESCROW_LOCKED | USDT collateral reserved |
deal.payment_notified | PAYMENT_NOTIFIED | The 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_verified | PAYMENT_VERIFIED | The trader confirmed the receipt |
deal.completed | COMPLETED | The deal is completed, the funds are credited |
deal.expired | EXPIRED | The order expired by timeout |
deal.appealed | APPEALED | A dispute was opened |
deal.refund_pending | REFUND_PENDING | Transit notification: the deal is being finalized with a cancellation; deal.cancelled always follows it |
deal.cancelled | CANCELLED | The client or the merchant explicitly canceled the deal before payment confirmation |