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.

8 min readUpdated 6 August 2026withdrawals, runbook, incident, reconciliation, payouts

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

  1. Filter the queue to PROCESSING, not just PENDING.

    /admin/finance/withdraw/log opens filtered to PENDING, oldest first — which hides exactly the rows that need a human. Change the status filter to PROCESSING and 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 a PROCESSING row on minutes, not on its SLA chip.

  2. 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.

  3. 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: ….

  4. Confirm the reconciler is actually running.

    System → System Monitoring → Scheduled Tasks (/admin/system/cron), job reconcileSpotWithdrawals, 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/okCOMPLETED, failedFAILED with a refund, cancelledCANCELLED 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:

  1. referenceId is null, and
  2. the row is older than 24 hours, and
  3. 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.