Troubleshooting
Stuck escrow, offers that will not publish, trades that never expire, disputes that move no money, and the settings that appear to do nothing — with the queries to prove each one.
Sorted by how often it lands in a support queue. Every item names the check that proves it, because most P2P complaints look identical from the outside — "my money is gone" — and are five different faults underneath.
Start here: is the cron running?
More than half of everything on this page is one cause.
pm2 list # the cron app must be onlineEvery deadline in P2P belongs to a job that runs every minute: payment windows, escrow return, the 24-hour stale-payment safety net, offer expiry. With the cron process down, nothing expires, escrow never comes back on its own, and nothing in the UI says so.
Confirm the job itself is registered on the scheduled task screen, under the
p2p category. If the extension was enabled after the last restart, it will not
be there — the cron registry is built at boot.
A single manual run is available from the admin trades screen. If pressing it fixes a backlog, you have found your problem; the button is a diagnostic, not a substitute.
"My balance is locked and I have no open trades"
Funds sitting in inOrder with nothing visible to explain them. Four causes, in
order of likelihood.
-
They have a live SELL offer. A published SELL offer holds its entire advertised total, not the amount of any trade. So does an offer sitting in
PENDING_APPROVALwaiting for an admin. This is the answer roughly nine times out of ten.SELECT id, type, status, currency, escrowAmount, JSON_EXTRACT(amountConfig, '$.total') AS remaining FROM p2p_offers WHERE userId = 'USER_ID' AND deletedAt IS NULL AND status IN ('ACTIVE','PENDING_APPROVAL');Pausing or deleting the offer releases it.
-
A trade is still holding it. Anything in
PENDING,PAYMENT_SENTorDISPUTEDwithescrowStatus = 'HELD'.SELECT id, status, escrowStatus, escrowAmount, currency, createdAt FROM p2p_trades WHERE sellerId = 'USER_ID' AND escrowStatus = 'HELD' AND deletedAt IS NULL; -
The cron is down and an expired trade never expired. See above.
-
A genuinely stranded escrow. The trade is terminal but the hold was never returned:
SELECT id, status, escrowStatus, escrowAmount FROM p2p_trades WHERE escrowStatus = 'HELD' AND status IN ('COMPLETED','CANCELLED','EXPIRED');This should return nothing. If it does, the backend log has the reason — search for
P2P_ESCROWaround that trade id. The two shapes that produce it are a missing seller wallet and a ledger shortfall, and both are logged loudly rather than swallowed.
The escrow authority clamps every settlement to the wallet's real inOrder.
Editing that column out from under a held trade makes the eventual settlement
release less than it should, or nothing, and the trade then goes terminal with
the discrepancy baked in. Release the offer or settle the trade through the
product, and take the ledger correction separately if one is still needed.
"I cannot publish my offer"
Every refusal is a 400 or 422 with a specific message. Match it:
"Insufficient balance. Available: X, Required: Y"
A SELL offer needs its whole total available. Note that wallet.balance is
already net of every existing hold — a maker with other live offers has that
money in inOrder, not in balance. GET /api/p2p/wallet/balances is the
read-only form of the same question.
"Total amount must be greater than 0" / "Minimum amount cannot exceed total amount"
Almost always the currency trap. total is in the traded currency;
min and max are in the pricing currency and are converted with
finalPrice before comparison. A min of 50 on a BTC offer means fifty dollars,
not fifty bitcoin — but if finalPrice is wrong, the conversion is wrong and
this is the error you get.
"Final price must be greater than 0"
A MARGIN offer whose market price never resolved, or a FIXED offer with an empty price field. This check runs before any escrow is taken, deliberately — an offer published at zero would lock the funds and then refuse every taker.
"Price margin must be between -50% and +50%"
MARGIN offers only. Note the offer edit route uses the same range, but the standalone price validator used elsewhere uses ±10 %.
"Trade terms are required" / "Location country is required"
Both are mandatory to publish and neither is required for a draft. If a maker says "it saved fine yesterday", they saved a draft.
"At least one payment method is required"
Their list is empty. Seed the global list at /admin/p2p/payment-method, or
have them create a personal method.
"Invalid payment method IDs: …"
They are attaching a method that has been deleted, or one belonging to another user. Only global methods and their own are attachable.
"This offer's smallest/largest trade … is below/above what the platform will settle"
p2pMinimumTradeAmount or p2pMaximumTradeAmount, or a per-currency floor from
p2pMinimumTradeAmounts. All three are server-enforced. The message states the
bound in the offer's own currency and, when they differ, the dollar figure it
came from — so "the ceiling is 136,500,000 NGN (the platform's 100,000 USD
ceiling at today's rate)" is one setting, not two.
"Enter the payment reference your own bank or app gave you"
p2pRequirePaymentReference is on. It is on by default, and it is the right
default for bank transfers. If your traders pay by a method that gives them no
reference to quote — cash, some wallet transfers, several mobile-money flows —
turn it off at /admin/p2p/settings; the field then reads (optional) and the
confirm endpoint accepts a blank one.
"You already have N trades open, which is the most this platform allows at once"
p2pMaxActiveTrades. It counts only the trades that trader opened themselves,
in PENDING or awaiting-payment; trades taken against their own offers do not
count, and neither does a disputed one. Raise the setting, or have them finish
or cancel one. Set it to 0 to remove the limit entirely.
"There is no exchange rate on file for X"
You have set a platform trade limit and the offer's pricing currency is an enabled national currency with no usable rate. The bound is dollars, so it cannot be applied without one. Set the rate in Finance → Currencies, or clear both bounds if you do not want platform-wide limits at all.
You get this message even when a coin shares the code. An enabled fiat row wins the tie and ends the lookup whatever its rate, so an EGP you are running is refused as the pound and never priced as Eigenpie — the rate you type in is the only thing that clears it. A row you have switched off wins nothing, and that is a different answer entirely: see the switched-off entry below.
"There is no market price for X on this platform right now"
The same bound, but the offer is priced in a cryptocurrency. A spot currency is priced from the exchange listing's own price column, which the price job refreshes about every two minutes, and a token from its ecosystem market. This message means the currency is enabled but neither has a price for it yet — a spot currency the exchange lists but the job has not priced, or a token with no market. A freshly listed coin gets it until the job's next tick; USDT never does, because it counts as 1 before its listing is priced. Setting a fiat rate does nothing here, and neither does switching a national currency of the same name on: a listed code is a coin for this purpose, and this message wins over the switched-off one below for exactly that reason. Have the maker price the offer in a currency with a live rate, or clear both bounds.
"X is currently switched off under Finance → Currencies"
The same bound again, and this is the last thing the conversion says before it gives up. Nothing priced the code — no enabled national currency row, no exchange listing, no ecosystem market — but a national currency row for it does exist with its switch off. It is the "not a currency this platform knows" case below, answered with better advice for the person most likely to be reading it: an operator who turned that currency off this morning and is now looking at an offer that will not save.
Switch the currency back on under Finance → Currencies, or have the maker price the offer in one you are still running.
It never fires for a code something else can price, and that is the half to
carry into a support answer. Switching a national currency off does not refuse
the offers priced in it — it stops that row counting as a currency at all, so a
code your exchange also lists is priced as the coin from then on. Switch the leu
off while Ronin is listed and RON-priced offers are bounded against Ronin's
market price, silently, including the offers a maker published as leu. The offer
row stores the code and not which table the maker meant, so nothing can tell the
two apart after the fact. Before switching off one of the ten codes that are
both — BOB, BTN, CAD, EGP, ERN, GEL, MNT, RON, SCR and SOS — check priceCurrency
on the live offers first; the reconciliation queries at the foot of this page are
the shape to start from.
The switch is what decides membership everywhere — the composer's Priced in
list, the publish check and this conversion all read the same status column —
and it has to be, because the currency table is seeded with a row for every ISO
code and 139 of the 161 arrive switched off. Were an untouched seed row allowed
to claim its code, all ten coins above would be unpublishable on any install
that lists them.
"X is not a currency this platform knows" / "X is not a currency this platform can price an offer in"
The first is the bounds check: the offer's pricing currency has no row at all in the fiat table — not even a switched-off one, which gets the message above instead — and no enabled row in the spot exchange or ecosystem token listings, so nothing can convert the bound. The second is the publish check that runs before it, on every publish and on any edit that changes the price currency — never on a draft save, through either route: the code is not one the platform can price at all. Both point at the same fix — pick a currency from the composer's Priced in list, which is built from the same three tables. USD always passes, even with the USD row disabled.
"My offer disappeared after I edited it"
It went back to PENDING_APPROVAL. Any edit that does not explicitly name a
status re-submits the offer for review, because a changed price and changed
limits are a changed public commitment.
Turn on p2pAutoApproveOffers if you would rather moderate reactively — but
remember that off is the stronger control, and that a pending SELL offer is
holding the maker's money while it waits.
"I cannot edit my offer"
422, "Cannot edit offer while there are active trades." A trade against it
is PENDING, PAYMENT_SENT or DISPUTED. The terms cannot change under a live
counterparty. They wait, or cancel.
422, "Cannot edit offer in <status> status." Only DRAFT,
PENDING_APPROVAL, ACTIVE and PAUSED are editable. REJECTED, CANCELLED,
COMPLETED and EXPIRED are terminal — post a new offer.
"The trade expired instantly" / "It never expires"
Both are the payment window, resolved in this order:
offer.tradeSettings.autoCancel, when it is a finite number ≥ 0 —0means never expire and stops hereoffer.tradeSettings.paymentWindow, the legacy aliasp2pDefaultPaymentWindow- 30 minutes
So an offer created with autoCancel: 0 produces trades that never auto-cancel,
which is a legitimate configuration and looks like a bug. Check the offer, not
the setting.
Nothing happens at the window itself, either. The sweep ends an unpaid
PENDING trade only a further 24 hours after the window closes, so "the
countdown ran out an hour ago and the trade is still open" is working as built,
not the stalled cron at the top of this page. In that day the buyer keeps the
confirm button — a late payment is still a payment — and the seller, refused
while the window was open, can now cancel on demand. On a never-expiring offer
that seller door never opens at all: only the buyer can end the trade.
If it expired far sooner than the countdown said, the countdown and the cron disagreed — that used to happen when the window was re-derived in four places with different fallbacks. It is now one definition; a recurrence means something is reading a stale offer.
Also check p2pAutoCancelUnpaidTrades. Off means nothing expires at all.
"The buyer paid but I cannot cancel"
Correct, and deliberate — for the two traders. Neither of them can cancel a
PAYMENT_SENT trade:
- the buyer cancelling would lose the money they just sent;
- the seller cancelling would keep the fiat and get the crypto back.
Both parties are directed to open a dispute. A seller who genuinely did not
receive payment files PAYMENT_NOT_RECEIVED and an admin rules. You are not
blocked in the same way: the admin cancel on the trade screen (edit.p2p.trade)
accepts PENDING, PAYMENT_SENT and DISPUTED and settles the escrow, with or
without a dispute — so the line to give a trader is that neither of them can
cancel it, not that nobody can.
A seller cannot cancel a PENDING trade while its payment window is still
open — 403, and the message points them at the trade chat, the buyer being the
only party who can end it until the deadline. It used to tell them to pause the
offer, which does not work: the offer route refuses any edit while a trade is
running against it. Once the window has passed that door opens and the seller can cancel on demand, which is
the counterpart to the buyer still being able to confirm late. On an offer whose
autoCancel is 0 the window never passes, so there only the buyer can.
"I want to dispute a completed trade"
Not possible, by design. COMPLETED is terminal.
Reopening a settled trade puts it back on the escrow money path, and the second payout comes out of whatever else the seller happens to be holding — in practice another offer's escrow, which is a different customer's money.
Handle it as a support case. If a correction is warranted, make it through the platform's own wallet tooling and record what you did in the trade's admin notes so the two records agree.
"The dispute was resolved but nobody was paid"
The response says so — either "this trade's escrow had already been settled" or
"no escrow was held for this trade". Check escrowStatus on the trade:
| Value | What happened before you ruled |
|---|---|
RELEASED |
already paid out — the seller released, or another admin resolved it |
REFUNDED |
already returned — the expiry cron, a cancellation, or another ruling |
NONE |
nothing was ever held |
The ruling is still recorded, which is right — an open dispute is a promise that a decision is coming. Any correction happens outside P2P. See Resolving a dispute.
"I resolved it wrongly and want to change it"
You get 409: "This dispute has already been resolved and its escrow settled. Re-open it before recording a different outcome."
The money is not at risk — the escrow authority settles a trade at most once — but a silent overwrite made the audit trail show the last ruling rather than the one the funds actually followed. Change the dispute's status first, then record the new outcome, and note that no further escrow will move.
"I cannot resolve this dispute at all"
403, "You are a participant in this trade." Your admin account is the buyer or the seller. Another admin has to rule.
400, "A resolution outcome is required when marking a dispute RESOLVED."
Closing a case without an outcome used to leave the trade DISPUTED with its
escrow stranded and nothing left to settle it.
"The offer says restricted countries and people from there are still trading"
Working as built. The restriction list is stated on the offer and shown to takers; nothing enforces it.
The platform has no trustworthy per-user country to check against — the profile field is self-declared and untrusted by the platform's own geo policy, KYC applications have no fixed country key, and a CDN header is a VPN-defeatable guess about a connection. A gate that quietly passes almost everyone while the maker believes it blocks a country is worse than an honest label.
"I turned P2P off and the API still answers"
p2pEnabled, p2pMaintenanceMode, p2pAllowNewOffers and
p2pAllowGuestBrowsing are read by the browser only. No P2P API route consults
them. They stop a customer; they do not stop a script.
To actually take P2P offline, switch the extension row off at
/admin/system/extension — but read the drain procedure in
Install first, because turning it off with live
offers strands their escrow behind gated routes.
"The escrow fee is not what the settings page says"
Three possibilities:
-
A legacy
p2psettings blob exists and itsEscrowFeeRatewins overp2pEscrowFeeRate.SELECT `key`, `value` FROM settings WHERE `key` = 'p2p'; -
The buyer holds Super Admin and is exempt from the fee entirely — the fee is taken out of the buyer's proceeds, so charging it to the account it would be paid to is a circular no-op. Such a trade records a fee of zero and quotes none on the trade screen. A seller holding Super Admin is an ordinary counterparty for this fee and their trades book normally. They did not use to, which meant a house-sold trade quoted a fee on the trade screen and then credited the buyer the gross — see Revenue & fees.
-
The fee was capped. It can never exceed the buyer's proceeds, and the
0.0001floor only applies while it stays under 5 % of the trade amount.
The fee is also computed and stored at trade initiation, so a rate change does not affect trades already open.
"Trade actions return 503"
Service temporarily unavailable. Please retry. Redis is unreachable.
Trade initiation, release, confirm and cancel take distributed locks. Rather than proceeding without one and risking a double settlement, they fail closed.
redis-cli ping # expects PONG"409: Trade initiation already in progress for this offer"
Two takers hit the same offer within the same 30-second lock window, or a previous request died holding it. Retrying after half a minute is the correct advice.
"409: You already have an active trade on this offer"
One PENDING or PAYMENT_SENT trade per offer per user. Previously expired,
cancelled or completed trades do not block. The message names the existing trade
id — send them to it.
"403 with a requirement message"
The maker set taker requirements and this taker misses one. The message names the bar and the shortfall: completed trades, success rate, account age, verified email, prior counterparty, or KYC.
Two that get misread:
- "only accepts verified accounts" means email-verified, not KYC. Verifying an email address fixes it.
- "limited to people its maker has already traded with" needs at least one completed trade between the two accounts, in either direction.
"All P2P routes fail with a licence error"
The licence has not validated. It gates the whole /api/p2p and
/api/admin/p2p prefix, including the public market endpoints — which is a
useful test, because a licence failure and an ordinary auth failure look very
different when you probe an unauthenticated route:
curl -s "https://YOUR_HOST/api/p2p/market/board?limit=1"Check the box has outbound HTTPS to the update host and re-activate the purchase
code at /admin/system/extension. There is a 72-hour grace period when the host
is unreachable, so a blocked egress rule takes three days to become visible.
"The admin section is missing"
Either the extension row is off, or the role lacks the permission keys. Both are silent.
SELECT productId, name, status FROM extensions WHERE productId = '44593497';The twelve keys are listed on Install and in the reference.
"ECO offers cannot be created"
walletType: ECO requires the Ecosystem addon. Without
it there is no token list to validate the currency against, and the offer is
refused at creation.
If Ecosystem is installed and the currency still fails, check that the token is active in the ecosystem token list.
"The dashboard's recent activity is all reputation entries"
Fixed, but worth recognising in an older install. The hourly reputation job used to write one log row per active user per hour regardless of whether the score moved, and the recent-activity panel takes the five newest rows with no type filter — so every hour those five were reputation noise hiding the trades and disputes the panel exists to show. It now writes only when a score actually changes.
Useful reconciliation queries
-- Total held per user, per currency, according to P2P
SELECT sellerId, currency, SUM(escrowAmount) AS tradeEscrow
FROM p2p_trades
WHERE escrowStatus = 'HELD' AND deletedAt IS NULL
GROUP BY sellerId, currency;
-- Plus what offers claim to hold
SELECT userId, currency, SUM(escrowAmount) AS offerEscrow
FROM p2p_offers
WHERE escrowAmount > 0 AND deletedAt IS NULL
GROUP BY userId, currency;
-- Terminal trades that still hold escrow: should be empty
SELECT id, status, escrowStatus, escrowAmount, currency
FROM p2p_trades
WHERE escrowStatus = 'HELD'
AND status IN ('COMPLETED','CANCELLED','EXPIRED');
-- Open disputes, oldest first
SELECT d.id, d.tradeId, d.priority, d.status, d.filedOn, t.status AS tradeStatus
FROM p2p_disputes d
JOIN p2p_trades t ON t.id = d.tradeId
WHERE d.status <> 'RESOLVED'
ORDER BY d.filedOn ASC;The offer figure and the trade figure both draw on the same wallet inOrder,
and a SELL offer's collateral covers the trades opened against it — so the two
sums are not additive. Read them as "what P2P believes it is holding for this
user", and compare the larger of the two against the wallet.
Where to look in the logs
The backend log tags P2P work by module. Grep for these around an incident:
| Tag | Covers |
|---|---|
P2P_ESCROW |
every settlement, every hold, every release — start here for money |
P2P_TRADE |
initiation, confirmation, release |
P2P_CANCEL |
user cancellations and capacity restoration |
P2P_DISPUTE |
dispute creation and resolution |
P2P_RESOLVE |
admin trade resolution |
P2P_TIMEOUT |
the auto-dispute safety net |
P2P_OFFER |
offer create, update, delete |
P2P_FEES |
fee calculation and capping |
ADMIN_P2P |
every admin action |