The dispute desk

The only NFT screen that pays money out of your platform — its six states, seven types, four priorities, five write routes, and the two resolutions that credit a buyer's SPOT wallet for real.

7 min readUpdated 6 August 2026disputes, refunds, escrow, moderation, sla

/admin/nft/dispute is the only screen in this addon that can move money on its own, and it is not in the navigation. Everything else in the NFT admin area changes a row's status; two of the seven resolutions here credit a customer's wallet inside the same database transaction that closes the case.

Resolving a dispute as REFUND or PARTIAL_REFUND credits the buyer's SPOT wallet, in the sale's currency, under the idempotency key dispute_refund_<disputeId>. It is not a paper outcome and there is no approval step behind it — the money is in the customer's balance the moment the call returns.

Grant edit.nft.dispute to the people you would trust with a withdrawal approval, and to nobody else.

Getting to it

Three doors, all landing on the same screen:

  • The URL, typed: /admin/nft/dispute.
  • The Open disputes queue on the moderation dashboard (/admin/nft), which links here from its tile, its rail and its preview list.
  • The operations inbox in the admin header, where the queue is called NFT Disputes.

Two permissions govern it, and they are not the same key:

Key Opens
access.nft.dispute The page, the dispute list and the message thread — read only
edit.nft.dispute Every action: status, priority, assignment, messages, resolution

An admin with access.nft.dispute alone can read a case and cannot touch it.

Work the queue through the API

GET /api/admin/nft/dispute answers the platform's standard list envelope — { items, pagination } — and the screen assigns that whole object to the list it then iterates. A successful fetch therefore produces no rows: the render fails on the envelope instead of walking items. Because the detail pane, the message thread and the Resolve dialog all open from a row click, they are unreachable from the screen as well.

Three smaller gaps sit behind the same wall, worth knowing so you do not chase them:

  • Four of the six statistics tiles (Pending, Investigating, Critical, Avg resolution) read keys that GET /api/admin/nft/dispute/stats does not return. Only Total disputes and Resolved carry a real figure.
  • The search box sends search=, which the list endpoint ignores — it filters on status, priority, disputeType and assignedToId only.
  • Export and the assignment control are not wired to anything.

Until that is fixed the desk is an API surface. What you have instead:

  1. Find the work on the moderation dashboard. Its open-dispute preview returns up to 8 rows with the dispute id, title, type, status, priority, whether it is assigned, its age and the reporter's name — and the count beside it is exact, so source.truncated tells you when the queue is deeper than the preview.

  2. Read the case with GET /api/admin/nft/dispute (filter by status or priority) and GET /api/admin/nft/dispute/{id}/messages.

  3. Act with the five write routes below.

The six states

Status Means Counted as open?
PENDING Raised, nobody has looked Yes
INVESTIGATING Picked up. Stamps investigatedAt the first time it is set Yes
AWAITING_RESPONSE Waiting on a party, not on you Yes
ESCALATED The automated path gave up. Stamps escalatedAt Yes
RESOLVED Closed with an outcome. Stamps resolvedAt and resolvedById No
REJECTED Closed with no outcome No

PUT .../status writes those timestamps for you, and setting RESOLVED from the status route stamps resolvedAt/resolvedById without recording a resolution type or paying anything out. Use the resolve route when there is an outcome; use the status route to move a case through the queue.

The NFT moderation dashboard and GET /api/admin/nft/dispute/stats both count the four non-terminal states above. The platform-wide operations inbox counts PENDING, INVESTIGATING and ESCALATED — it leaves out AWAITING_RESPONSE, on the reasoning that a case waiting on a customer is not waiting on you.

So the header badge can read lower than the NFT dashboard tile. Neither is wrong; they answer different questions.

The seven types

Type Raised when
FAKE_NFT The item is not what it claims to be
COPYRIGHT_INFRINGEMENT The artwork is somebody else's
SCAM Deliberate fraud
NOT_RECEIVED Paid, no asset
WRONG_ITEM Delivered, but not the one bought
UNAUTHORIZED_SALE Sold by somebody who had no right to
OTHER Everything else

One of them your platform writes by itself. When the expireOffers sweep cannot release a buyer's escrow on an accepted offer that was never transferred, it stamps nft_offer.flaggedAt and opens a dispute of type NOT_RECEIVED at priority HIGH, titled "Unconfirmed NFT sale needs manual resolution", with the reporter set to the buyer and the respondent to the seller. The metadata carries the offer id, the amount that could not be released, its currency and the reason.

Those are the cases where a customer's funds are locked and they hold no NFT. Work them first — When money or an NFT is stuck is the recovery.

Priorities and the clock

LOW, MEDIUM, HIGH, CRITICAL, defaulting to MEDIUM. HIGH and CRITICAL are what the dashboard and the stats endpoint count as urgent while the case is not RESOLVED or REJECTED.

The dispute SLA is 24 hours — the tightest budget on the platform, tighter than a withdrawal's seven days. That is deliberate: escrow is held while a dispute runs, so both sides are frozen out of their own money until somebody rules. The same 24 hours drives the age colouring on the NFT dashboard and in the operations inbox.

The five write routes

All five require edit.nft.dispute.

Moves a dispute between the six states
Sets LOW, MEDIUM, HIGH or CRITICAL
Assigns to an admin by user id, or null to unassign
Posts into the thread; isInternal keeps it admin-only
Closes the case with an outcome — and pays, for two of them

And the two reads, on access.nft.dispute:

The queue, filterable by status, priority, type and assignee
The chronological message thread

Assignment validates the user id and 400s on an unknown one, but it does not check that the assignee is an admin or holds the dispute permission — any user id is accepted.

Resolving

POST .../resolve takes resolutionType (required), a free-text resolution and an optional numeric refundAmount. It sets the status to RESOLVED, stamps resolvedAt and resolvedById, and posts a system message into the thread for the audit trail.

Resolution What the platform does
REFUND Credits the buyer's SPOT wallet with the full original sale price, or with refundAmount if you supply one
PARTIAL_REFUND Credits the buyer's SPOT wallet with the refundAmount you supply — required
CANCEL_SALE Records the outcome. Nothing else
REMOVE_LISTING Records the outcome. Nothing else
BAN_USER Records the outcome. Nothing else
WARNING Records the outcome. Nothing else
NO_ACTION Records the outcome

Only the two refund types have a side effect. CANCEL_SALE cancels no sale, REMOVE_LISTING removes no listing, and BAN_USER bans nobody — the resolve route writes the dispute row and stops. The confirmation the interface shows for BAN_USER ("this action requires additional approval") describes a workflow that does not exist in this build.

If you resolve a case as REMOVE_LISTING or BAN_USER, go and do it: the listing on /admin/nft/listing, the user in the core admin panel.

Where the refund goes

The buyer is resolved in this order:

  1. The most recent nft_sale row for the dispute's listingId — its buyerId, currency and price.
  2. Failing that, the nft_listing row itself, for the currency and price. The buyer is still unknown at this point.
  3. Failing that, the dispute's reporter, on the reasoning that the party who complains about a purchase is almost always the buyer.

The wallet is created if the buyer does not have a SPOT wallet in that currency yet.

If the buyer, the amount or the currency cannot be established, the route throws a 400 — "Cannot issue refund: buyer could not be determined for this dispute", "...refund amount is missing or invalid" or "...refund currency could not be determined" — and the surrounding transaction rolls back. The dispute is not resolved either. You get an error rather than a closed case with no payment, which is the right way round, but it means a dispute with no linked sale or listing cannot be refunded from here at all.

For those, pay the customer through the core wallet tools and close the case as NO_ACTION with the payment referenced in the resolution text.

PARTIAL_REFUND is stored as REFUND

The nft_dispute.resolutionType column is an enum of six values and PARTIAL_REFUND is not one of them. The route stores REFUND in the column and puts the real answer in the row's metadata as resolutionTypeRaw.

Two consequences:

  • Reporting off resolutionType alone counts every partial refund as a full one. Read metadata.resolutionTypeRaw to separate them.
  • refundAmount on the row is what the caller sent, not what was paid. The route writes that column only when the request carried a number, and it never stamps the amount it worked out for itself. So a REFUND resolved with no refundAmount — the common case above, the one that credits the full original sale price off the nft_sale or nft_listing row — closes with nft_dispute.refundAmount left null; send 0 with it and the column reads 0 while the buyer is still credited the full price. Only PARTIAL_REFUND, where the amount is mandatory, leaves a row figure that matches the payment. Reconcile against the wallet credit instead: it carries the real amount, the dispute id in referenceId, and the idempotency key dispute_refund_<disputeId>.

Statistics

Four counts for the dispute board

Returns total, open (the four non-terminal states), resolved (status RESOLVED only — REJECTED is not counted here) and urgent (HIGH or CRITICAL, not yet RESOLVED or REJECTED). Four counting queries, no page maths.

The inflow-versus-resolution series is not on this endpoint — it comes from the moderation dashboard, GET /api/admin/nft/dashboard, as disputeFlow. It buckets two different columns over the same window:

  • opened — disputes grouped by createdAt.
  • resolved — disputes grouped by resolvedAt.

timeRange picks the window: 24h gives 24 hourly buckets, 7d gives 7 daily ones, 30d gives 30. Buckets are cut in UTC, not your server's timezone, and the last bucket is the current hour or day so far. It answers one question — is this queue draining or filling — and it is the only queue on that dashboard that can answer it, because it is the only one whose completion is stamped on the row.

A resolved line that never rises while opened climbs is the shape to act on; the counts on this page tell you how far behind you already are.

  • The admin panel — the moderation dashboard and its five queues.
  • When money or an NFT is stuck — flagged escrow, blocked settlements, and the sweep that opens these disputes.
  • Permissionsaccess.nft.dispute versus edit.nft.dispute.
  • SettingsnftTransferConfirmGraceHours, the deadline whose expiry creates the NOT_RECEIVED cases.