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
- Initiating a dispute. The merchant creates an appeal, stating the reason and attaching evidence files (UUIDs of pre-uploaded files).
- 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 status | Terminal? | Description |
|---|---|---|
OPEN | No | The appeal is created, awaiting arbitrator assignment. |
IN_PROGRESS | No | The arbitrator is reviewing the dispute, the chat is active. |
REOPENED | No | The appeal was reopened for further investigation. |
SATISFIED | Yes | The dispute is resolved in favor of the initiator (the merchant). |
REJECTED | Yes | The 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}/settlementThis 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, andresolution_notecarries 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.completedwebhooks (V1 and V2); the original staysEXPIREDforever with its ownreceived_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/appealsRequest parameters (JSON Body)
| Field | Type | Required | Description |
|---|---|---|---|
deal_id | string(UUID) | Yes | The UUID of the deal the dispute is opened on. |
reason | string | Yes | The 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_ids | string[] | No | An 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/appealsQuery parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by canonical status: OPEN, IN_PROGRESS, REOPENED, SATISFIED, REJECTED. |
page_size | int32 | Rows per page (1–100, default 20). |
page_token | string | An 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}/messagesRequest parameters
| Field | Type | Required | Description |
|---|---|---|---|
body | string | Yes | Message text (non-empty — an empty body is rejected with 400 body is required). |
file_ids | string[] | No | UUIDs 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}/messagesResponse 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 surface | Current 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}/resolve | 400 FAILED_PRECONDITION. |
| RDR-rule CRUD (dispute-rules) | 400 FAILED_PRECONDITION. |
The p2p_disputes, p2p_dispute_rules, p2p_dispute_evidence tables | Deleted (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.