S
docs.syncra.money
Operations

Disputes and appeals (Appeals)

Resolving payment disputes: creating appeals, chatting with the arbitrator, and the full Appeal Service contract in Syncra V2

Disputes and appeals (Appeals)

Disputed situations can arise in P2P acquiring: for example, a client claims the transfer was made, but the P2P trader reports that nothing arrived on their card.

To resolve such conflicts, Syncra V2 provides the Appeal Service: the merchant opens an appeal on a deal, attaches evidence files, and communicates with the platform arbitrator in the dispute chat.

All endpoints in this section run under the merchant's HMAC pair; appeal creation and the chat are also available from the cabinet (a cabinet-JWT session, the same contract — the initiator is resolved from authentication).

The RDR/chargeback flow was decommissioned on 2026-09-28 (DEPRECATED / REMOVED). Card disputes — chargebacks, Visa VROL / Ethoca RDR, auto-refunds — are not supported by the platform. The operator endpoints /api/v1/p2p/disputes* (webhook ingest, manual create/read/listing, representment, resolve) and the RDR-rule CRUD forever answer 400 FAILED_PRECONDITION "RDR refund contour decommissioned"; the flow's tables are deleted. An external webhook, should one arrive anyway, receives an honest reject (format validation and tenant scope run before the refusal) — see the Decommissioned RDR flow section below. Disputed deals are handled only through the appeal flow (this page). The deal webhook flow (deal.*) is unchanged.


Dispute resolution workflow

Загрузка диаграммы...
  1. Initiating a dispute. The merchant creates an appeal, stating the reason and attaching evidence files (UUIDs of pre-uploaded files).
  2. Arbitration. Syncra arbitrators verify the authenticity of the evidence, cross-check the transfer time and payment details, and issue a decision.

Appeal statuses

The canonical lifecycle is domain.AppealStatus (5 values). The full table with the V1 mapping is in the Status mapping reference.

V2 statusTerminal?Description
OPENNoThe appeal is created, awaiting arbitrator assignment.
IN_PROGRESSNoThe arbitrator is reviewing the dispute, the chat is active.
REOPENEDNoThe appeal was reopened for further investigation.
SATISFIEDYesThe dispute is resolved in favor of the initiator (the merchant).
REJECTEDYesThe dispute is rejected — the evidence is insufficient.

The resolve outcome for the deal (2026-09-08): SATISFIED finishes a live deal as COMPLETED; REJECTED moves the deal through a refund into CANCELLED (terminal) — escrow holds are returned in full (order-level and all per-match holds). The deal's final status arrives as a separate webhook (deal.completed / deal.cancelled).

V2 uses only these 5 canonical values. The legacy names IN_REVIEW, RESOLVED_MERCHANT, RESOLVED_TRADER, CLOSED, REFUNDED do not exist in V2 — they are emulated only on the V1 compat layer for migrating merchants.


Crediting an overdue payment on resolution (Settle-as-Received)

A typical dispute case: the client transferred less than declared (partial) or transferred late — the deal managed to expire into EXPIRED while received_amount > 0. The money is at the trader's, the deal is dead. Resurrect mode is forbidden: EXPIRED is terminal and never changes. Instead, the operator closes the dispute by creating a settlement — a new deal that credits the amount actually received:

POST /api/v1/p2p/appeals/{appeal_id}/settlement

This is a cabinet operator action (the arbitration panel). The body: received_amount_override (optional, for a manual amount correction — then reason is required), reason, idempotency_key (UUID). The full semantics are described in PayIn → Late payment settlement.

Properties of resolving via a settlement:

  • the appeal resolves as SATISFIED, and resolution_note carries a reference to the settlement deal (parent_deal_id → the expired original, origin_appeal_id → the appeal);
  • one appeal → at most one settlement (a UNIQUE constraint at the database level): a repeated call returns the already-created record (replay), not an error;
  • the settlement walks the full standard path (escrow → verify → complete); the rate is a new one, taken at the resolve moment;
  • the settlement emits the regular deal.created … deal.completed webhooks (V1 and V2); the original stays EXPIRED forever with its own received_amount.

Refusals: the parent is not EXPIRED or the appeal is already closed → 409; received_amount = 0 without an override → 400; an override without reason → 400.


Creating an appeal

POST /api/v1/p2p/merchant/appeals

Request parameters (JSON Body)

FieldTypeRequiredDescription
deal_idstring(UUID)YesThe UUID of the deal the dispute is opened on.
reasonstringYesThe reason for opening the dispute — at least 10 characters (after trimming whitespace; shorter → 400 reason is required (at least 10 characters)). The arbitrator reads this text — write it substantively.
file_idsstring[]NoAn array of UUIDs of pre-uploaded evidence files.

The initiator is resolved from authentication (HMAC claims or a cabinet JWT) — the merchant_id/tenant_id body fields are unnecessary and advisory. The deal must belong to the calling merchant: another merchant's → 403, a nonexistent one → 404. On a deal in a status that allows disputes (after the escrow lock), the FSM moves the deal to APPEALED and sends the deal.appealed webhook.

Request example

{
  "deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
  "reason": "The client transferred 2,500.00 RUB, but the payment was not credited within 30 minutes",
  "file_ids": ["f3a1b2c3-4d5e-6f7a-8b9c-0d1e2f3a4b5c"]
}

Response example (200 OK)

{
  "appeal": {
    "appeal_id": "a82d77a0-0d3a-4422-b91c-7cb052a21def",
    "deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
    "status": "OPEN",
    "reason": "The client transferred 2,500.00 RUB, but the payment was not credited within 30 minutes",
    "resolution_note": "",
    "created_at": "2026-07-12T17:25:00Z",
    "resolved_at": "",
    "updated_at": "2026-07-12T17:25:00Z"
  }
}

Creation errors (live staging responses)

400 INVALID_ARGUMENT — reason shorter than 10 characters:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "{\"code\":3,\"message\":\"reason is required (at least 10 characters)\",\"details\":[]}",
  "instance": "/api/v1/p2p-engine"
}

400 FAILED_PRECONDITION — the deal already has an active appeal (no duplicate threads: a repeat is forbidden while the appeal is OPEN, IN_PROGRESS, or REOPENED; the terminal SATISFIED/REJECTED do not block a new one):

{
  "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"
}

404 NOT_FOUND — a nonexistent deal_id (deal not found); 403 PERMISSION_DENIED — another merchant's deal (cannot access another merchant's resources).


Retrieving appeal information

GET /api/v1/p2p/merchant/appeals/{appeal_id}

Response example (200 OK, live staging response)

{
  "appeal": {
    "appeal_id": "aa1fac74-33b3-41ce-ae47-445bc3dcd37d",
    "deal_id": "359fb026-6dd7-4f7a-bfa7-aa54a5afb6cb",
    "status": "OPEN",
    "reason": "E2E merchant appeals full-cycle test: payer claims transfer completed but escrow never confirmed",
    "resolution_note": "",
    "created_at": "2026-08-23T19:04:44Z",
    "resolved_at": "",
    "updated_at": "2026-08-23T19:04:44Z"
  }
}

Another merchant's appeal → 403, a nonexistent one → 404 (appeal not found).


Appeal listing

GET /api/v1/p2p/merchant/appeals

Query parameters

ParameterTypeDescription
statusstringFilter by canonical status: OPEN, IN_PROGRESS, REOPENED, SATISFIED, REJECTED.
page_sizeint32Rows per page (1–100, default 20).
page_tokenstringAn opaque keyset cursor from the previous response. An empty string = the first page.

Response example (200 OK)

{
  "data": [
    {
      "appeal_id": "aa1fac74-33b3-41ce-ae47-445bc3dcd37d",
      "deal_id": "359fb026-6dd7-4f7a-bfa7-aa54a5afb6cb",
      "status": "OPEN",
      "reason": "E2E merchant appeals full-cycle test: payer claims transfer completed but escrow never confirmed",
      "resolution_note": "",
      "created_at": "2026-08-23T19:04:44Z",
      "resolved_at": "",
      "updated_at": "2026-08-23T19:04:44Z"
    }
  ],
  "next_page_token": ""
}

In the current release the listing is paginated per AIP-158: a keyset cursor (page_size 1–100 defaulting to 20, next_page_token empty = the last page, a malformed non-empty page_token → 400 invalid page_token). The listing is scoped by owner: appeals on other merchants' deals never appear in the response.


Appeal chat

Within an appeal, the merchant can exchange text messages with the platform arbitrator. Also available from the cabinet (JWT).

Sending a chat message

POST /api/v1/p2p/merchant/appeals/{appeal_id}/messages

Request parameters

FieldTypeRequiredDescription
bodystringYesMessage text (non-empty — an empty body is rejected with 400 body is required).
file_idsstring[]NoUUIDs of uploaded evidence files.

Request example

{
  "body": "Here is the bank statement confirming the debit.",
  "file_ids": []
}

Response example (200 OK, live staging response)

{
  "message": {
    "message_id": "a7b598e0-2074-4873-91ee-954ec588bcfc",
    "appeal_id": "aa1fac74-33b3-41ce-ae47-445bc3dcd37d",
    "sender_role": "MERCHANT",
    "body": "Here is the bank statement confirming the debit.",
    "file_ids": [],
    "created_at": "2026-08-23T21:48:24Z"
  }
}

sender_role is resolved from authentication (MERCHANT for the API/cabinet, ADMIN — arbitrator messages, TRADER — the trader's).

Retrieving message history

GET /api/v1/p2p/merchant/appeals/{appeal_id}/messages

Response example (200 OK)

{
  "data": [
    {
      "message_id": "b33d77a0-0d3a-4422-b91c-7cb052a21aaa",
      "appeal_id": "a82d77a0-0d3a-4422-b91c-7cb052a21def",
      "sender_role": "MERCHANT",
      "body": "Here is the bank statement.",
      "file_ids": [],
      "created_at": "2026-07-12T17:27:00Z"
    },
    {
      "message_id": "b33d77a0-0d3a-4422-b91c-7cb052a21bbb",
      "appeal_id": "a82d77a0-0d3a-4422-b91c-7cb052a21def",
      "sender_role": "ADMIN",
      "body": "Evidence received. Starting the review.",
      "file_ids": [],
      "created_at": "2026-07-12T17:27:05Z"
    }
  ],
  "next_page_token": ""
}

Decommissioned RDR flow (deprecated 2026-09-28)

Until 2026-09-28 the platform contained a separate card-dispute flow (Visa VROL / Ethoca): an ingested dispute was matched against an RDR rule, moved the deal to REFUND_PENDING, and launched a refund saga. The flow existed outside the P2P money model (saga money never moved within orders) and was decommissioned by the owner's decision. What changed:

Former surfaceCurrent behavior
POST /api/v1/p2p/disputes/webhook (VROL/Ethoca ingest)400 FAILED_PRECONDITION "RDR refund contour decommissioned: dispute webhook ingest is no longer processed". The format (required tenant_id, external_ref, order_id, a positive amount) and tenant scope are validated before the refusal.
POST /api/v1/p2p/disputes (manual creation), GET /api/v1/p2p/disputes[...] (read/listing)400 FAILED_PRECONDITION with the same refusal.
POST /api/v1/p2p/disputes/{id}/represent, POST /api/v1/p2p/disputes/{id}/resolve400 FAILED_PRECONDITION.
RDR-rule CRUD (dispute-rules)400 FAILED_PRECONDITION.
The p2p_disputes, p2p_dispute_rules, p2p_dispute_evidence tablesDeleted (migration 000054) — no flow data exists.

The HTTP code 400 is the grpc-gateway equivalent of gRPC FailedPrecondition: the request is valid and understood, but the addressed subsystem has been dismantled.

If an external dispute webhook still arrives (the provider retries delivery), the platform does not stay silent: the reject is accompanied by an ERROR log and an operator alert p2p.dispute.rejected_decommissioned (Telegram ERROR channel; a 30-minute dedup by tenant + external_ref — repeated sender retries within the window do not create an alert storm) so that the operators ask the provider to stop sending.

Integrator migration: remove the dispute-API calls — disputes are not supported by the platform. Disputed deals go through the appeal flow (this page). The deal webhook flow is unchanged: the deal.refund_pending → deal.cancelled pair and V1 16 → 12 work as documented (the transit semantics of REFUND_PENDING); a rejected appeal, a third-party flag, or a reconciliation (recon) rule moves a deal into the transit — RDR rules no longer exist as a trigger source.

On this page