Skip to main content

Disputes

A dispute is a case attached to one transaction, with a message thread between the two parties and an arbitrator. Cases are visible only to their participants and to admins.

The case object​

{
"id": "0a1b2c3d-4e5f-4061-8273-849506a7b8c9",
"referenceId": "9d4f7c2e-1b3a-4f5e-8c7d-2a1b3c4d5e6f",
"raisedByUserId": "1f2e3d4c-5b6a-4790-8123-456789abcdef",
"counterpartyUserId": "7c1e5b90-2d84-4a1f-9e3c-5d6b7a8c9f01",
"status": "OPEN",
"reason": "Goods never arrived",
"resolutionOutcome": null,
"resolutionReason": null,
"resolvedByUserId": null,
"createdAt": "2026-09-16T14:20:00.0000000Z",
"resolvedAt": null,
"messages": []
}

status is OPEN or RESOLVED. messages is populated when reading a single case, and omitted from lists.

List cases​

GET /api/v1/Dispute​

Scope: core:disputes:read

ParameterDefaultNotes
allfalseAdmin only. Lists every case. Ignored for other callers
statusOPEN or RESOLVED. Applies to the admin listing
page1
pageSize20Maximum 100

Non-admins always get their own cases, whatever all is set to, so the flag cannot be used to probe for admin rights.

{ "data": { "cases": [ { "id": "...", "status": "OPEN" } ], "totalCount": 3 } }

Read a case​

GET /api/v1/Dispute/{caseId}​

Scope: core:disputes:read. Includes the first page of the thread.

Callers who are not a party to the case receive 404, not 403. The API does not confirm that someone else's case exists.

Messages​

GET /api/v1/Dispute/{caseId}/messages​

Scope: core:disputes:read. Paginated with page and pageSize.

{
"data": {
"messages": [
{
"id": "5f6a7b8c-9d0e-4f12-8345-67890abcdef1",
"disputeCaseId": "0a1b2c3d-4e5f-4061-8273-849506a7b8c9",
"senderUserId": "1f2e3d4c-5b6a-4790-8123-456789abcdef",
"isSystemMessage": false,
"body": "The package was never delivered.",
"createdAt": "2026-09-16T14:22:10.0000000Z"
}
],
"totalCount": 12
}
}

senderUserId is null when isSystemMessage is true. System messages record events such as the case opening or closing.

POST /api/v1/Dispute/{caseId}/messages​

Scope: core:disputes:message

{ "body": "Tracking shows it was returned to sender." }

Returns the created message. Posting to a resolved case fails: the thread is read-only once closed.

Resolve​

POST /api/v1/Dispute/{caseId}/resolve​

Scope: core:disputes:resolve. Arbitrator action. Requires step-up verification.

{ "releaseToRecipient": false, "resolutionReason": "No proof of delivery" }
FieldMeaning
releaseToRecipient: trueThe recipient keeps the money
releaseToRecipient: falseThe sender is refunded by a reversing transaction

A refund appends a reversing entry rather than deleting anything, so the ledger keeps the full history. The response carries the reversing transaction's reference.

The same decision can be applied directly to a transaction with POST /api/v1/Transaction/dispute/resolve, which takes referenceId, releaseToRecipient and resolutionReason. Prefer the case endpoint when a case exists, since it also closes the thread.

Live updates​

A SignalR hub pushes case activity so a thread does not need polling.

wss://api.obsidianpay.bz/hubs/disputes

Authenticate the connection with the same bearer token. Then join the cases you are viewing:

DirectionNamePayload
Client to serverJoinCasecaseId
Client to serverLeaveCasecaseId
Server to clientMessagePostedThe new message
Server to clientCaseOpenedA case raised against you
Server to clientCaseResolvedThe case, now read-only

JoinCase runs the same participant check as the REST endpoints, so it cannot be used to listen to a case you are not part of.

Keep the REST endpoints as the source of truth on load and reconnect. Treat the hub as an accelerator, not a replacement.