Error codes
Error handling, failure response formats, a code reference, and the retry matrix in the Syncra Merchant API V2
Error handling in the API
When a request contains invalid parameters or an incorrect signature, or
runs with an insufficient balance, Syncra Merchant API V2 returns an error
with the corresponding HTTP status code and a response body in the
RFC 9457 Problem Details format (Content-Type: application/problem+json).
All examples on this page are real staging responses
(api-stage.syncra.money) produced by real requests.
Error body format (Error Shape)
Every non-2xx response is translated from gRPC into the unified
problem+json format. A real response (GET /deals?page_token=garbage-token):
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
X-Trace-Id: 4f7c1a9b-8e2d{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"invalid page_token\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}Response structure (RFC 9457)
type(string, URI): the error type identifier. For all transcoded p2p-engine responses it isabout:blank(the type is described bystatus+title).title(string): the standard HTTP status text (Bad Request,Unauthorized,Not Found,Too Many Requests).status(number): the HTTP status code.detail(string): a nested JSON of gRPC details — a string like{"code":<grpc_code>,"message":"<text>","details":[]}. Themessageinsidedetailcarries the human-readable description;codeis the numeric gRPC code (3 =INVALID_ARGUMENT, 5 =NOT_FOUND, 9 =FAILED_PRECONDITION, 14/16 =UNAUTHENTICATED, 8 =RESOURCE_EXHAUSTED). For programmatic handling, parsedetailas JSON.instance(string): the identifier of the upstream service that handled the request (/api/v1/p2p-engine), not the original request path.
Trace identifier: the X-Trace-Id header
The problem+json body has no trace fields (no trace_id / traceId
extensions). The trace identifier is transmitted in the
X-Trace-Id header, which every response carries (both
successful and failed):
- give the exact
X-Trace-Idvalue when contacting support — support uses it to find the request in the logs; - for browser clients, the header is exposed via CORS
(
Access-Control-Expose-Headers), that is, it is readable from JavaScript (response.headers.get('X-Trace-Id')); without the exposure, browser JS cannot see custom headers.
Additionally, every successful response carries the X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset headers (Unix seconds
of the window reset).
There is no separate top-level JSON code field in the error body.
The numeric gRPC code is nested inside detail (see above). For
programmatic handling, use status and detail.
HTTP code mapping
A wrong request state → 400 Bad Request (not 409). All
FAILED_PRECONDITION classes — insufficient balance, exceeding the
daily limit, a repeated appeal, retrying a non-retryable callback, an
unconfigured webhook — are returned as 400. The only 409 Conflict in
API V2 is the concurrent whitelist-pass reservation by two deals (see
Passes).
API V2 maps the backend's gRPC status codes to standard HTTP codes:
| HTTP code | gRPC code (detail.code) | Typical situation |
|---|---|---|
| 400 | 3 INVALID_ARGUMENT | Parameter validation error (invalid amount format, missing fields, a broken page_token, an empty/unknown payment_method_id, a method of another currency). |
| 400 | 9 FAILED_PRECONDITION | Wrong state: insufficient funds, the daily limit, an active appeal, no webhook configured on the account, retry unavailable. |
| 401 | 16 UNAUTHENTICATED | Missing signature headers, a wrong token, a broken/expired signature. |
| 403 | 7 PERMISSION_DENIED | Another merchant's resource (a deal/appeal/callback of another merchant), Kill-Switch, blocking. |
| 404 | 5 NOT_FOUND | Resource not found (a non-existent deal_id, appeal_id, callback_id, route). |
| 409 | 10 ABORTED | Concurrent whitelist-pass reservation (the only 409 path; a retry is safe). |
| 429 | 8 RESOURCE_EXHAUSTED | Token rate limit; the manual callback retry cooldown (60 s); velocity protection. |
| 500 | 13 INTERNAL | An unexpected internal failure. Try again later. |
| 503 | 14 UNAVAILABLE | No free trader for matching (detail: "...no matching trader available, retry later"). Retry later with backoff. The V1 legacy envelope answers the same cause (an empty cascade on PayIn creation) with HTTP 503 and failures[].id = "UNAVAILABLE" and the same text — retryable. |
Live examples by code
400 — validation (INVALID_ARGUMENT)
An empty payment_method_id (a mandatory field since 2026-08-24):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3, \"message\":\"payment_method_id is required (UUID or slug from GET /payment-methods)\", \"details\":[]}",
"instance": "/api/v1/p2p-engine"
}A broken page_token in the deal listing:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"invalid page_token\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}An unknown payment method reference (PayIn payment_method_id / PayOut
payment_method; slug and UUID resolve identically) — detail lists the
allowed slugs of active methods:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"payment_method_id: not found: requested \\\"sbp_try\\\", allowed slugs: sbp_rub, card_rub, mobcom_rub\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}A method of another currency (PayOut: the reference resolves, but the method serves a currency other than the deal's):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"merchant_api: payment method currency does not match the deal currency: method \\\"card_rub\\\" serves another currency\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}Deal limit violation on PayIn creation (minimum/maximum —
INVALID_ARGUMENT):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"deal amount is below the merchant minimum: 50000 is below the configured minimum 100000\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}A missing mandatory field (an appeal reason, a message body, a payout
wallet_id):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":3,\"message\":\"reason is required\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}The payout bank is not derivable (a PayOut on a banking method: no
target_bank, and the TCMB code from the IBAN does not resolve in the
reference — see
Payout: bank derivation). The deal
is NOT created and the escrow hold is NOT taken — fix the request and
repeat (the idempotency_key is not consumed):
{
"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"
}400 — wrong state (FAILED_PRECONDITION)
The daily deal volume limit (see Merchant limits):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":9,\"message\":\"merchant daily deal volume limit exceeded: today's volume 793921 + requested 150000 exceeds the configured daily cap 100000\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}A repeated appeal on a deal that already has an active one — continue the existing thread:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":9,\"message\":\"deal 359fb026-6dd7-4f7a-bfa7-aa54a5afb6cb already has an active appeal (aa1fac74-33b3-41ce-ae47-445bc3dcd37d, status OPEN) — continue the existing thread\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}A manual retry of a callback that is not in an error state (only ERROR
/ RETRY are allowed):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":9,\"message\":\"callback is not in a retryable state (only ERROR and RETRY): status SUCCESS\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}400 — webhook configuration (FAILED_PRECONDITION)
PayIn/Payouts are rejected until the merchant has a webhook configured:
| Condition | detail (message) |
|---|---|
| Webhook URL not set | merchant webhook url is not configured |
| Webhook secret not initialized | merchant webhook secret is missing |
Configure the webhook in the dashboard (webhook_url in the
Merchant profile) or in the
admin panel.
401 — authentication (UNAUTHENTICATED)
401 can be returned for the following reasons (live responses):
- No HMAC headers at all (or no JWT for dashboard endpoints):
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "{\"code\":16,\"message\":\"missing authorization header\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}- A wrong token, a wrong signature, or an expired
X-Timestamp— a single response (replay window: a signature older than 5 minutes or further than 60 seconds in the future is rejected — the total tolerance is |Δt| ≤ 6 minutes; check your NTP synchronization and that the signature is computed over"{t}.{raw_body}"):
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "{\"code\":16,\"message\":\"invalid merchant credentials\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}The merchant_id field in the request body under HMAC authentication is
advisory: the actual scope is always taken from the signed token
(claims-win). An invalid format or someone else's merchant_id in the
request does not cause an error — the value is silently ignored and
the call runs as the merchant who owns the token. The exception is
requests with cabinet-JWT authentication (the merchant dashboard): there
a merchant_id mismatch is rejected with 403 Forbidden.
403 — another merchant's resource (PERMISSION_DENIED)
Accessing a deal_id / appeal_id / callback_id that belongs to
another merchant (IDOR protection; a non-existent ID → 404; a deal of
another merchant in your tenant → 403 on the GET /deals/{id} read —
DIRECT deal payment details are visible only to the owner; canceling
another merchant's deal (cancel) answers 404 without disclosing its
existence; 403 on another merchant's appeal/callback):
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "{\"code\":7,\"message\":\"cannot access another merchant's resources\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}403 is also returned when the merchant is blocked or the Kill-Switch is
enabled.
404 — not found (NOT_FOUND)
A non-existent deal_id:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "{\"code\":5,\"message\":\"deal not found\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}429 — rate limit and cooldown (RESOURCE_EXHAUSTED)
- Token request limit — a per-merchant counter (10,000/min by
default, configurable; the staging sandbox has a wider window). The
window is a calendar UTC minute; the
X-RateLimit-*headers on every response show the remaining budget:
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": "{\"code\":8,\"message\":\"merchant rate limit exceeded (limit: 10000/min)\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}- Webhook manual retry cooldown — at most one retry per callback per
60 seconds (no
Retry-Afteris sent; the pause is named in thedetailtext — see Callbacks: manual retry):
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": "{\"code\":8,\"message\":\"last manual retry 12s ago (cooldown 1m0s)\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}- Velocity protection (antifraud): the PayIn request limit per
client_idexceeded (at most 10 per hour) — see Antifraud.
Antifraud errors
These errors occur when protection mechanisms trigger (see Antifraud):
| HTTP code | Code (semantics) | Error description |
|---|---|---|
| 429 | CLIENT_RATE_LIMITED | Velocity protection triggered: the client exceeded the PayIn request limit (at most 10 per hour per client_id). |
| 403 | CUSTOMER_BLOCKED | The client is blocked (IsBlocked = true and the block has not expired). Payment creation and checkout access are denied. |
Validation errors (INVALID_ARGUMENT) — summary
This code is returned when request parameters do not match the gateway's business rules. Examples:
- The
amountvalue is negative or zero. - The
currencyvalue is in the wrong format or not supported. - The mandatory
idempotency_keyfield is missing or empty (the key format is not validated — see Idempotency). - The payment details in
target_requisitecontain an invalid format (letters in a card number, a wrong phone format for SBP). - An invalid cryptocurrency wallet address is passed for a USDT withdrawal.
- A broken/expired pagination
page_token(invalid page_token). - Wrong
date_from/date_toin turnover (not RFC3339, a window > 92 days,date_toearlier thandate_from). - The deal amount is outside the merchant's own limits (min/max — see Merchant limits).
- The payout bank is not derivable: a PayOut on a banking method
(
BANK_TRANSFER) withouttarget_bank, when the TCMB code from the IBAN does not resolve in the reference (target_bank: bank not derivable from IBAN — pass target_bank (see GET /merchant/banks); neither the deal nor the hold is created — see Payout).
Retry matrix: when retrying a request is safe
Not every error can be retried safely. Use this matrix to avoid duplicates and aggravating limits:
| Request type | When a retry is safe | When a retry is NOT safe |
|---|---|---|
GET (any) | Always safe. GET requests are idempotent by nature. | — |
POST with idempotency_key | Safe: a retry with the same key returns the original result without creating a duplicate. | — |
POST without idempotency_key | Unsafe. The request may have gone through on the backend; a retry creates a duplicate. | Avoid: always pass an idempotency_key (see Idempotency). |
Behavior by specific code
| Error code | Retry? | Strategy |
|---|---|---|
UNAUTHENTICATED (401) | No | Do not retry without fixing the signature/token. |
INVALID_ARGUMENT (400) | No | Do not retry — fix the request body. |
FAILED_PRECONDITION (400) | No | Top up the balance / remove the precondition / continue the existing appeal. Including a repeated notify-payment on a deal that already received "I paid" (status PAYMENT_NOTIFIED): do not retry — the deal state is valid; wait for the webhook/operator resolution. |
PERMISSION_DENIED / CUSTOMER_BLOCKED (403) | No | The condition will not change on retry. |
NOT_FOUND (404) | No | The resource will not appear on retry. |
RATE_LIMIT / RESOURCE_EXHAUSTED (429) | Yes, with backoff | Limit/window; retry after the window resets (X-RateLimit-Reset) or with exponential backoff. |
INTERNAL (500) | Yes, with backoff | A transient failure; retry with exponential backoff. |
UNAVAILABLE (503) | Yes, with backoff | No free trader; retry with exponential backoff. In the V1 legacy envelope the same cause arrives as HTTP 503, failures[].id = "UNAVAILABLE" — retryable, retry with the same backoff. |
For 429, 500, and 503, use exponential backoff with jitter (for
example, 1 s → 2 s → 4 s → 8 s). For a manual webhook retry, wait until
the 60-second cooldown expires.
Related materials
- Antifraud — velocity protection, client blocking, segmentation.
- Idempotency — why a retry is
only safe with an
idempotency_key. - Authentication — computing
the
X-Signaturesignature. - Callbacks — the automatic webhook retry schedule and the manual retry.