S
docs.syncra.money
Reference

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 is about:blank (the type is described by status + 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":[]}. The message inside detail carries the human-readable description; code is the numeric gRPC code (3 = INVALID_ARGUMENT, 5 = NOT_FOUND, 9 = FAILED_PRECONDITION, 14/16 = UNAUTHENTICATED, 8 = RESOURCE_EXHAUSTED). For programmatic handling, parse detail as 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-Id value 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 codegRPC code (detail.code)Typical situation
4003 INVALID_ARGUMENTParameter validation error (invalid amount format, missing fields, a broken page_token, an empty/unknown payment_method_id, a method of another currency).
4009 FAILED_PRECONDITIONWrong state: insufficient funds, the daily limit, an active appeal, no webhook configured on the account, retry unavailable.
40116 UNAUTHENTICATEDMissing signature headers, a wrong token, a broken/expired signature.
4037 PERMISSION_DENIEDAnother merchant's resource (a deal/appeal/callback of another merchant), Kill-Switch, blocking.
4045 NOT_FOUNDResource not found (a non-existent deal_id, appeal_id, callback_id, route).
40910 ABORTEDConcurrent whitelist-pass reservation (the only 409 path; a retry is safe).
4298 RESOURCE_EXHAUSTEDToken rate limit; the manual callback retry cooldown (60 s); velocity protection.
50013 INTERNALAn unexpected internal failure. Try again later.
50314 UNAVAILABLENo 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:

Conditiondetail (message)
Webhook URL not setmerchant webhook url is not configured
Webhook secret not initializedmerchant 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-After is sent; the pause is named in the detail text — 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_id exceeded (at most 10 per hour) — see Antifraud.

Antifraud errors

These errors occur when protection mechanisms trigger (see Antifraud):

HTTP codeCode (semantics)Error description
429CLIENT_RATE_LIMITEDVelocity protection triggered: the client exceeded the PayIn request limit (at most 10 per hour per client_id).
403CUSTOMER_BLOCKEDThe 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 amount value is negative or zero.
  • The currency value is in the wrong format or not supported.
  • The mandatory idempotency_key field is missing or empty (the key format is not validated — see Idempotency).
  • The payment details in target_requisite contain 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_to in turnover (not RFC3339, a window > 92 days, date_to earlier than date_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) without target_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 typeWhen a retry is safeWhen a retry is NOT safe
GET (any)Always safe. GET requests are idempotent by nature.—
POST with idempotency_keySafe: a retry with the same key returns the original result without creating a duplicate.—
POST without idempotency_keyUnsafe. 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 codeRetry?Strategy
UNAUTHENTICATED (401)NoDo not retry without fixing the signature/token.
INVALID_ARGUMENT (400)NoDo not retry — fix the request body.
FAILED_PRECONDITION (400)NoTop 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)NoThe condition will not change on retry.
NOT_FOUND (404)NoThe resource will not appear on retry.
RATE_LIMIT / RESOURCE_EXHAUSTED (429)Yes, with backoffLimit/window; retry after the window resets (X-RateLimit-Reset) or with exponential backoff.
INTERNAL (500)Yes, with backoffA transient failure; retry with exponential backoff.
UNAVAILABLE (503)Yes, with backoffNo 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.


  • Antifraud — velocity protection, client blocking, segmentation.
  • Idempotency — why a retry is only safe with an idempotency_key.
  • Authentication — computing the X-Signature signature.
  • Callbacks — the automatic webhook retry schedule and the manual retry.

On this page