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:
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.
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.
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.
MARGIN offers only. Note the offer edit route uses the same range, but the standalone price validator used elsewhere uses ±10 %.
Both are mandatory to publish and neither is required for a draft. If a maker says "it saved fine yesterday", they saved a draft.
Their list is empty. Seed the global list at /admin/p2p/payment-method, or
have them create a personal method.
They are attaching a method that has been deleted, or one belonging to another user. Only global methods and their own are attachable.
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.
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.
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.
You have set a platform trade limit and the offer's pricing currency has 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.
"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.
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. Nobody 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.
Similarly, a seller cannot cancel a PENDING trade — only the buyer can. The
seller pauses their offer instead.
"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 |