A withdrawal is stuck
What PROCESSING means, why the reconciler refuses to refund without evidence, the four checks that resolve a frozen payout, and the rule you must not override.
A customer says their withdrawal has not arrived. The row is not COMPLETED and
it is not REJECTED. This is the most common money incident on the platform, and
most of it resolves itself if you know what to look at.
The wallet debit happens when the customer submits the request, not when you
approve it. So a PENDING row and a PROCESSING row have both already reduced
the customer's balance. The question is never "should I take the money" — it is
"did the payout leave, and if not, do I return it".
Which is why the reconciler will not refund on absence of evidence, and neither should you. Refunding a payout that actually went out pays the customer twice, and that money is gone.
Three payout paths behind one queue
/admin/finance/withdraw/log lists every WITHDRAW transaction regardless of
rail, but Approve does something completely different depending on the wallet the
row belongs to.
| Wallet type | What Approve does | Where it actually settles |
|---|---|---|
SPOT |
Atomically claims PENDING → PROCESSING, then calls the exchange's withdraw API. Auto-refunds if the exchange rejects it |
Your exchange provider |
FIAT |
Bookkeeping only. Flips the status and books the platform fee — you already paid by bank | Outside the platform |
ECO |
Refused, with an explanation | The ecosystem withdrawal queue, on-chain |
The ECO refusal is deliberate. An ecosystem payout is broadcast by the
platform's own withdrawal queue, which owns the nonce and the transaction.
Marking one complete here would say "paid" while nothing left the chain. If an
ecosystem withdrawal is stuck, the row is not the problem — the queue or the RPC
is, and the recovery path is the ecosystemWithdrawRecon job plus the boot-time
sweep, not this screen.
FIAT has one more refusal: if the row carries a transfiOrderId in its metadata,
or its referenceId begins OR-, a payout provider is already executing it and
approval answers 409 with the provider reference. The provider's webhook or
the payout reconciler sets that row's status. Do not force it.
The status ladder
| Status | Balance | What it means |
|---|---|---|
PENDING |
Debited | Waiting for a human, or for auto-approval to pick it up |
PROCESSING |
Debited | Claimed. The payout was requested, or was about to be, and the outcome is not known here |
COMPLETED |
Debited | Paid |
FAILED / CANCELLED |
Refunded | The payout did not happen and the money went back |
REJECTED |
Refunded | An operator refused it, with a reason the customer was emailed |
TIMEOUT |
Debited | Aged out. Still refundable |
Whether a row starts at PENDING or goes straight to PROCESSING is decided by
Admin → System → Settings → Wallet → Auto-Approve Withdrawals. Off (the
default and the safe setting) means every spot withdrawal waits for you. See the
Settings reference.
The queue offers both buttons on PENDING and PROCESSING rows, but the
approval handlers require PENDING and answer Transaction is not pending
otherwise. Reject accepts PENDING, PROCESSING and TIMEOUT, and refunds
in every case — spot through a credit, ecosystem through an ecosystem refund.
That is the control that un-freezes a stranded payout, and it is why a rejection
reason is mandatory: it is emailed to the customer and stored on the transaction.
The four checks
-
Filter the queue to
PROCESSING, not justPENDING./admin/finance/withdraw/logopens filtered toPENDING, oldest first — which hides exactly the rows that need a human. Change the status filter toPROCESSINGand sort oldest first. Anything more than a few minutes old is a payout whose outcome nobody has confirmed. The Age column is measured against the withdrawal SLA of 7 days, so it will not turn red for a week; judge aPROCESSINGrow on minutes, not on its SLA chip. -
Read the row's metadata for the reconciler's own notes.
Open the row and expand Raw request metadata (in the view dialog) or read the metadata block on
/admin/finance/withdraw/log/[id]. Two keys are written by the reconciler and nothing else:reconcileReviewCount— how many times the reconciler has looked at this row and refused to decide it. Any non-zero value means the platform has already escalated it and is deliberately not touching it.reconcileNextCheckAt— when it will look again. The backoff doubles from one hour up to a ceiling of 24 hours, so a stubborn row stops burning your exchange's rate limit.
Also read
referenceId. It is the fork the whole recovery hangs on: set means the exchange accepted a payout; null means the process may have crashed before or after requesting one, and the two look identical from here. -
Check the Super Admin's in-app notifications.
Each escalation sends one high-priority in-app notification titled "Withdrawal Requires Manual Review", under the idempotency key
spot_withdrawal_review_<transactionId>— so one alert per stuck withdrawal, however many times it is re-examined. It carries the transaction id, the currency and the reason the reconciler refused to act. It goes to a single Super Admin account. If nobody watches that account's notifications, nothing else in the product will tell you.The same line is in the backend log as
MANUAL REVIEW REQUIRED: …. -
Confirm the reconciler is actually running.
System → System Monitoring → Scheduled Tasks (
/admin/system/cron), jobreconcileSpotWithdrawals, five-minute cadence. A dead scheduler is the most common reason rows sit: the website serves pages perfectly while nothing reconciles. Check the heartbeat banner as well as the job's own last-run time.Two other things silently skip a run: the exchange provider being unavailable (
startExchange()returns nothing when no provider is active, the credentials are missing or the IP is banned), and a backlog larger than the run's cap — 200 rows per pass, scanning at most 2,000 to fill it. Both are reported in the job's live log.
What the reconciler does, so you know what is already handled
It selects PROCESSING WITHDRAW rows on SPOT wallets — ecosystem rows are
excluded by that join, because they are also PROCESSING/WITHDRAW with a null
referenceId and used to be refunded into a spot wallet while the coins were
already on chain.
If referenceId is set — the exchange accepted a payout. It reads the
exchange's withdrawal list and converges: completed/ok → COMPLETED,
failed → FAILED with a refund, cancelled → CANCELLED with a refund,
pending → left as PROCESSING on purpose. If the payout is simply not in the
page the exchange returned, it is not refunded — exchange history is
paginated and lookback-limited, and absence from one page is not evidence. Past
24 hours old, it is escalated instead.
If referenceId is null — the process may have crashed on either side of the
send. Under 24 hours old it is left alone. Past that, it scans the exchange's
withdrawal history from an hour before the row was created, up to 1,000 records,
and looks for anything matching this row's destination address or its amount
within 2%.
An automatic refund happens only when all three are true:
referenceIdis null, and- the row is older than 24 hours, and
- a successful scan of the exchange's withdrawal history shows no plausible match.
Any other outcome — a candidate match, a failed exchange query, an unusable
response, or a dispatched payout missing from a paginated page — leaves the row
PROCESSING and raises the manual-review alert. Those are all cases where the
payout may have gone out.
If you reject such a row by hand, you are asserting the payout did not happen.
Before you do, prove it from the exchange's own dashboard — find the withdrawal
by address and amount in the window around the row's createdAt. If you cannot
find it there, you have the evidence the reconciler could not get. If you can,
the correct action is to complete the row, not to refund it.
Why five minutes matters
The reconciler ignores any row younger than five minutes
(MIN_AGE_MS). That window exists because the request handler may still be
mid-flight — the wallet is debited, the row is claimed, and exchange.withdraw()
has not returned yet. A row that has been PROCESSING for ninety seconds is not
stuck, it is working.
Apply the same rule by hand. Do not touch a row younger than five minutes; a second decision against an in-flight payout is how a customer gets paid twice.
Deciding in bulk
Bulk approve and bulk reject send one request naming every selected id, and each id is decided independently against the same handlers the single-row path uses. This is partial-success by design:
- The response names which ids failed and why —
Already COMPLETED,Transaction is not pending, an exchange error message. - The ones that succeeded have already moved money. Re-running the whole batch is not a retry, it is a second attempt at rows that are now in a different state.
- The request only fails outright when nothing at all succeeded.
Fix the named failures individually. A rejection reason is required and must be at least 3 characters — the Confirm button stays disabled until you type one, and the endpoint refuses it anyway.
Related
- The admin panel — where the queues live and the daily routine that keeps them clear.
- Customer wallets and balance adjustments — the balance ledger, which is how you prove a refund landed exactly once.
- A customer paid and the balance did not move — the same problem in the other direction.