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.
/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/statsdoes not return. Only Total disputes and Resolved carry a real figure. - The search box sends
search=, which the list endpoint ignores — it filters onstatus,priority,disputeTypeandassignedToIdonly. - Export and the assignment control are not wired to anything.
Until that is fixed the desk is an API surface. What you have instead:
-
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.truncatedtells you when the queue is deeper than the preview. -
Read the case with
GET /api/admin/nft/dispute(filter by status or priority) andGET /api/admin/nft/dispute/{id}/messages. -
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.
And the two reads, on access.nft.dispute:
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:
- The most recent
nft_salerow for the dispute'slistingId— itsbuyerId,currencyandprice. - Failing that, the
nft_listingrow itself, for the currency and price. The buyer is still unknown at this point. - 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
resolutionTypealone counts every partial refund as a full one. Readmetadata.resolutionTypeRawto separate them. refundAmounton 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 aREFUNDresolved with norefundAmount— the common case above, the one that credits the full original sale price off thenft_saleornft_listingrow — closes withnft_dispute.refundAmountleft null; send0with it and the column reads0while the buyer is still credited the full price. OnlyPARTIAL_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 inreferenceId, and the idempotency keydispute_refund_<disputeId>.
Statistics
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 bycreatedAt.resolved— disputes grouped byresolvedAt.
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.
Related
- 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.
- Permissions —
access.nft.disputeversusedit.nft.dispute. - Settings —
nftTransferConfirmGraceHours, the deadline whose expiry creates theNOT_RECEIVEDcases.