Payouts to clients (PayOut)
Fund withdrawal: the merchant creates the request, and the transfer to the recipient is executed by a trader or an API provider
Payouts to clients (PayOut)
A payout (PayOut) is a P2P withdrawal operation in which the merchant creates a transfer request, and the executor — a trader or an API provider — makes the actual transfer to the recipient from their own bank account.
Payout flow (PayOut Flow)
The funds reach the recipient from the executor's account (the trader's or the API provider's), not directly from the merchant's balance. The merchant's balance compensates the executor for the completed transfer inside the Syncra platform.
PayOut creation endpoint
POST /api/v1/p2p/merchant/deals/payoutRequest parameters (JSON Body)
| Field | Type | Required | Description |
|---|---|---|---|
amount | int64 | Yes | Payout amount in minor units (kopecks). 500000 = 5,000.00 RUB. |
currency | string | Yes | ISO 4217 fiat currency code of the payout ("RUB", "KZT", "TRY", ...). Echoed back in the response. |
wallet_id | string(UUID) | Yes | The merchant's USDT wallet the funds are debited from: the amount hold is created in USDT (at the SELL rate at creation time), not on the fiat wallet. This is the wallet_id of the USDT row from GET /balance. |
target_requisite | string | Yes | Recipient payment details (card number, phone, IBAN). |
target_name | string | No | Recipient name for verification by the bank/trader. |
target_bank | string | Conditional* | Recipient bank: a slug ("ziraat", "garanti") or a name. Used for intra-bank cascade matching (relevant for TRY/Havale). Empty = no bank hint (cards/SBP). |
requisite_details | map<string,string> | No | Named recipient details (see below). Valid keys: iban, account_number, document_number, bank_name, phone_number. Unknown keys → 400 with the list of valid ones. |
payment_method | string | Yes | Reference to a payout method from your GET /payment-methods catalog: a slug ("sbp_rub", "card_rub", "mobcom_rub", "express-havale", ...; identical across the stage/prod environments) or a UUID (the id field) — both values resolve to the same method. A missing value, an unknown value, or a method of a foreign currency → 400 INVALID_ARGUMENT (with the list of valid slugs). |
idempotency_key | string | Yes | Unique operation ID (idempotency). |
* target_bank is mandatory for bank methods (BANK_TRANSFER / Havale)
when the bank is not derived from the request itself: the platform
resolves the bank from the TCMB code embedded in the IBAN (positions 5–9)
against the GET /merchant/banks reference. If neither target_bank nor
the IBAN-derived code is known, the request is rejected with 400 INVALID_ARGUMENT before the deal and the hold are created (see
Error codes).
In PayOut, payment_method accepts a slug (sbp_rub, card_rub,
mobcom_rub) or the method's UUID, and NOT the uppercase enum
BANK_CARD / SBP. The RequisiteType enum values (BANK_CARD,
E_WALLET, ...) describe the trader's requisite type: in the
GET /payment-methods response (the requisite_types field), they are
reference information about the channel; they are never passed in
requests. PayIn's payment_method_id has the same unified contract
(a UUID or a slug) — both legs accept both formats.
The method currency must match the deal currency. The resolver
checks that the found method serves the request's currency: a
card_rub payout with currency: "TRY" is rejected with 400 INVALID_ARGUMENT ("payment method currency does not match the deal
currency"). Pick a method of the same currency from the
GET /payment-methods catalog (the currency_code field).
Request example
{
"amount": 500000,
"currency": "RUB",
"wallet_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"target_requisite": "2202201234567890",
"target_name": "Ivan Ivanovich I.",
"payment_method": "card_rub",
"idempotency_key": "payout_order_99812"
}Response example (200 OK)
The response is a MerchantDealInfo object in the top-level deal field
(there is no data wrapper). The deal identifier is deal_id (NOT
id). For PAYOUT, payment_url is empty (it exists only for PayIn);
instead, target_requisite is filled. currency echoes the ISO code of
the payout's fiat currency from the request.
The status in the response depends on where the cascade routed the payout (matching runs synchronously, at creation time):
PROCESSING— routed to an API provider: the payout was created at the provider within the same request, and the transfer is already in progress;MATCHED— routed to a trader, and a free executor was found (the example below);UNASSIGNED— no free executor at creation time; the retry worker keeps matching (see PayOut deal statuses).
The terminal status (COMPLETED / FAILED / EXPIRED) arrives by
webhook — the PROCESSING and MATCHED holders in the creation response
are equivalent in terms of the further lifecycle.
{
"deal": {
"deal_id": "e81d77a0-0d3a-4422-b91c-7cb052a2119c",
"status": "MATCHED",
"amount": "500000",
"currency": "RUB",
"created_at": "2026-07-12T17:21:00Z",
"client_id": "",
"deal_type": "PAYOUT",
"updated_at": "2026-07-12T17:21:00Z",
"payment_url": "",
"target_requisite": "2202201234567890",
"received_amount": "0",
"amount_match_status": ""
}
}In V2, target_requisite in the response is an echo of the request
value, without masking: these are the merchant's own payment details,
not trader data. The mask like 220220******7890 remains only in the V1
legacy endpoints (GET /v1/payments/outgoing/{id} and the V1 callback
envelope).
For payouts routed to an API provider, the same creation response
arrives with "status": "PROCESSING" — the other fields are identical.
A created payout is a full-fledged deal: deal_type=PAYOUT records are
visible in GET /deals (a combined listing with PayIn) and available
via GET /deals/{deal_id} by the deal_id from the creation response
(see Deals).
PayOut deal statuses
The canonical lifecycle is domain.PayOutStatus (7 values). PayOut uses
a status domain separate from PayIn. The full table with the V1 codes
is in the
status reference.
| V2 status | Final? | Description |
|---|---|---|
INITIALIZED | No | Payout created, balance on hold, waiting for matching. |
MATCHED | No | A trader/team has been assigned. The creation-response status when routed to a trader (a free executor exists). |
UNASSIGNED | No | No executor at creation time. The retry worker keeps matching; after the SLA expires — EXPIRED. |
PROCESSING | No | Transfer in progress: for an API provider — the creation-response status (the payout was created at the provider synchronously); for a trader — after the assignment is confirmed. |
COMPLETED | Yes (success) | The funds have been sent, the hold captured. |
FAILED | Yes (declined) | Rejected (wrong payment details, cancellation). The hold returned to the merchant. |
EXPIRED | Yes (timeout) | Expired before the transfer completed (including by SLA from UNASSIGNED). |
Special statuses
UNASSIGNED— at creation time, the cascade auto-matching found no free trader (all busy / limits exhausted / no requisites for that currency and amount). The deal is returned with this status in thePOST /deals/payoutresponse, after which the retry worker automatically retries matching: on success, the status moves toMATCHED→ then the regular lifecycle; if the assignment SLA expires, the payout moves to the terminalEXPIREDand the hold is returned to the merchant. Not a terminal status. Manual assignment (admin override) remains available to the operator.- PayOut does not use the PayIn statuses (
PAYMENT_NOTIFIED,PAYMENT_VERIFIED,APPEALED,SOFT_DISPUTED).
PayOut webhook events use the same names as PayIn: deal.completed
(success) and deal.failed (declined, status: FAILED). NOT
payout.*. For details, see
Callbacks.
TRY / IBAN payouts (Havale)
For Turkey, payouts go as a bank transfer by IBAN (RequisiteType
BANK_TRANSFER): the base requisite is target_requisite (the
recipient's IBAN), with target_bank and requisite_details filled in
addition.
New request fields
| Field | Type | Required | Description |
|---|---|---|---|
target_bank | string | Conditional | Recipient bank: a slug ("ziraat", "garanti", ...) or a name. Passed to the provider as the payout bank and used for intra-bank cascade matching. Mandatory when the bank is not derivable from the IBAN (see "Bank derivation" below); not passed for cards/SBP. |
requisite_details | map<string,string> | No | Named details beyond target_requisite/target_name. Valid keys: iban, account_number, document_number, bank_name, phone_number. Unknown keys → 400 InvalidArgument with the list of valid ones. A missing/empty map = "no extra details". |
Bank derivation
For a bank method (BANK_TRANSFER / Havale), the platform must know the
recipient bank before the deal is created. The bank counts as derived
when:
- a valid
target_bankis passed (a slug fromGET /merchant/banks), or - the payment details contain a Turkish IBAN whose TCMB code (positions
5–9:
TRxx BBBBB+ account) resolves against the platform's bank reference — the bank is derived automatically, and passingtarget_bankis not needed.
If neither holds, the request is rejected immediately, before the deal is created and the escrow hold is taken:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"target_bank: bank not derivable from IBAN — pass target_bank (see GET /merchant/banks)\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}Inside the platform, the safety-net managed refusal no_bank_derived
(status FAILED + webhook deal.failed + hold release) remains for the
case where the bank reference changes between request validation and
matching. For an integrator, the equivalent "200 → instantly FAILED"
behavior is not a contract: the only expected response for an
underivable bank is the 400 above.
The values of requisite_details are the merchant's own payment details
(their recipient's data), so responses return them as an echo, without
masking (unlike trader requisites in PayIn). In logs, the platform
keeps only the set of keys, without the values.
Request example (TRY / Havale)
{
"amount": 2500000,
"currency": "TRY",
"wallet_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"target_requisite": "TR33 0006 1005 1978 6457 8413 26",
"target_name": "Ahmet Yilmaz",
"target_bank": "ziraat",
"requisite_details": {
"iban": "TR33 0006 1005 1978 6457 8413 26",
"document_number": "12345678901"
},
"payment_method": "express-havale",
"idempotency_key": "payout_try_order_00042"
}amount: 2500000= 25,000.00 TRY (minor units are kuruş, 2 decimal places).payment_method— the slug (or UUID) of the Havale method created for your tenant (for example,express-havale); the value is resolved against your tenant's active methods — an unknown reference or a method of another currency is rejected with400(the list of your tenant's methods and slugs —GET /api/v1/p2p/merchant/payment-methods, see Banks).requisite_details.document_number— an example of an extra detail (TC kimlik / document number), not always required: pass it when the method or the recipient bank requires it.
Response example (200 OK)
{
"deal": {
"deal_id": "c25f9b31-7e40-4a11-8f0a-6d92b7c4e155",
"status": "MATCHED",
"amount": "2500000",
"currency": "TRY",
"created_at": "2026-08-18T11:02:00Z",
"client_id": "",
"deal_type": "PAYOUT",
"updated_at": "2026-08-18T11:02:00Z",
"payment_url": "",
"target_requisite": "TR33 0006 1005 1978 6457 8413 26",
"received_amount": "0",
"amount_match_status": ""
}
}target_requisite — an echo from the request, without masking (see the
NOTE above).
Single payout limits
Amount limits are not hardcoded — they are determined dynamically by the tenant configuration:
| Limit source | Managed by | Description |
|---|---|---|
Cascade rules (cascade_rules.max_amount) | Admin panel | The maximum amount for a specific routing cascade. |
Trader requisites (requisites.max_amount) | Trader / Admin | The per-transaction limit for a specific requisite. |
Tenant config (system_config) | Superadmin | Global auto-approval limits. |
Limit values can be changed by an administrator in real time without
restarting the services. When you need to send an amount above the
limit in a single transaction, split it into several requests with
unique idempotency_key values.
Webhook events
| Event | Payout status | Description |
|---|---|---|
deal.created | INITIALIZED / MATCHED / UNASSIGNED / PROCESSING | Payout created (the status is the one in the creation response after synchronous auto-matching; PROCESSING — routed to an API provider). |
deal.completed | COMPLETED | Payout completed |
deal.failed | FAILED | Payout declined |
deal.expired | EXPIRED | Payout expired before the transfer completed (including by SLA from UNASSIGNED); the hold returned |
IBAN validation
If the payout method accepts an IBAN (requisite type BANK_TRANSFER), the
recipient goes through an ISO 13616 checksum check: a mistyped requisite
is rejected immediately (400, with the IBAN masked in the error
message); spaces and letter case are normalized before the check. Card
and phone methods require no validation.