Creating and pricing offers
What a maker fills in on a P2P offer, which currency each amount field is in, how much escrow is taken and when, the eight offer statuses, and every rule that will reject a publish.
An offer is a public financial commitment. Publishing one puts a price on a board that strangers can act on, and — for a SELL offer — locks the maker's crypto behind it immediately. Everything the offer form asks for exists because one of those two facts requires it.
Users create offers at /p2p/offer/new. Admins can edit any offer from
/admin/p2p/offer.
BUY and SELL are the maker's intent, not the taker's
This trips up everyone once, including the code — it is worth stating plainly.
| Offer type | The maker is the… | The taker is the… | Whose funds are escrowed | When |
|---|---|---|---|---|
| SELL | seller | buyer | the maker's | at offer creation, the whole advertised total |
| BUY | buyer | seller | the taker's | at trade initiation, the trade amount only |
So a visitor who wants to buy crypto is shown SELL offers. The market
board takes a side parameter named for the viewer precisely so this stops
being ambiguous.
The fields
Currency and wallet type
currency is what is being traded (BTC, USDT, EUR…). walletType decides which
of the platform's balance stores it comes from:
walletType |
Currency list drawn from | Requires |
|---|---|---|
FIAT |
active fiat currencies | core |
SPOT |
active exchange currencies | core, with an exchange connected |
ECO |
active ecosystem tokens | the Ecosystem addon |
If Ecosystem is not installed, an ECO offer is refused at creation — there is
no token list to validate against and no wallet to settle into.
For a BUY offer the pair is checked explicitly at creation, because a BUY offer never touches a wallet at that point and nothing else would catch an unsettleable pair.
Amounts — and the currency trap
amountConfig.total is the size of the offer, in the crypto or fiat being
traded — 500 USDT, 0.05 BTC. amountConfig.min and amountConfig.max are the
per-trade limits, expressed in the price currency (USD, EUR, GBP…).
The backend converts them with finalPrice before comparing. A maker who types
min: 50 on a BTC offer is saying "no trade smaller than 50 dollars", not "50
bitcoin". Getting this backwards in a support answer will cost somebody a
publish.
Publish-time rules on amounts:
totalmust be greater than 0.minmay not exceedmax.minandmax, once converted to the traded currency, may not exceedtotal.minmust be at or abovep2pMinimumTradeAmount;maxmay not exceedp2pMaximumTradeAmount. Both are platform settings, both are enforced server-side, and both are configured in US dollars — the server converts them into the offer's pricing currency before comparing, so a $100,000 ceiling permits roughly ₦136,500,000 rather than ₦100,000. An offer priced in a currency with no exchange rate on file is refused while either bound is set, because the bound cannot be applied.- The converted
minmust clear the per-currency dust floor fromp2pMinimumTradeAmounts(defaults include BTC 0.00005, ETH 0.001, DOGE 10, XRP 5, SOL 0.01).
Price
Two models.
FIXED — value is the price, and must be greater than 0. finalPrice
equals it.
MARGIN — value is a percentage offset from the market price, and may be
negative. At creation it must be between -50 % and +50 %. If a
marketPrice is supplied it must be greater than 0.
finalPrice must always be greater than 0, whichever model is used. This is
checked before any escrow is taken:
An offer published with finalPrice: 0 used to lock the seller's funds and then
refuse every taker, because trade initiation requires a positive price. The
escrow was stranded with no way to trade out of it. The price validation now
runs first, so a bad price costs a 400 and nothing else.
priceConfig.currency (surfaced as priceCurrency on the row) is the money the
price is quoted in — USD unless the maker says otherwise. It is also the
currency min and max are read in.
Trade settings
| Field | Meaning |
|---|---|
autoCancel |
The payment window in minutes. 0 means never auto-cancel. Between 5 and 1440 when edited. Omitted, the platform default (p2pDefaultPaymentWindow) applies. |
kycRequired |
Only KYC-verified takers may open a trade. Fails open when the platform has no KYC programme at all. |
visibility |
PUBLIC or PRIVATE. A PRIVATE offer is excluded from every public listing and is reachable only by direct link. |
termsOfTrade |
Required to publish. Up to 1000 characters. Shown to the taker before they commit and copied onto the trade. |
additionalNotes |
Optional, up to 500 characters. |
Location and restrictions
locationSettings.country is required to publish, and must be a valid
ISO 3166-1 alpha-2 code. Region and city are optional free text.
The restricted-countries list is stored on the offer and shown to takers. Trade initiation does not check it, and this is deliberate.
Enforcing it needs a trustworthy country for the taker, and the platform does
not have one. The only stored value is user.profile.location.country —
self-declared, never validated, null for most accounts, editable by the taker at
any moment; the platform's own geo policy ships with trustProfileCountry: false. An approved KYC application has no fixed country key and is found by
scanning application JSON for keys matching /country|nationality/i. A CDN
header is a VPN-defeatable guess about a connection.
The choice was between a gate that passes almost everyone while the maker believes it blocks a country, and one that wrongly refuses legitimate takers on a heuristic. Both are worse than saying so. If a maker asks you why their restriction "isn't working": it is displayed, not applied.
Requirements on the taker
All optional, all enforced at trade initiation, all returning 403 with a message naming the shortfall.
| Requirement | Checked against |
|---|---|
minCompletedTrades |
count of the taker's COMPLETED trades, either side |
minSuccessRate |
completed ÷ (completed + cancelled + expired), as a percentage |
minAccountAge |
days since the taker's account was created (0–365) |
verifiedOnly |
user.emailVerified — not KYC |
trustedOnly |
at least one COMPLETED trade between the taker and this maker, in either direction |
verifiedOnly reads the same column the market board publishes as the trader's
"verified" badge. If it meant identity verification, the badge on the board and
the filter claiming to select for it would be two different promises — and the
weaker bar would become unexpressible, since kycRequired already covers
identity.
Payment methods
At least one is required to publish. A maker may attach global methods created by an admin and their own private methods; attaching someone else's private method is refused. See Payment methods.
Escrow at publish
Not the minimum, not the first trade's worth — amountConfig.total, in full,
moved out of the maker's spendable balance into inOrder in the same
transaction that writes the offer row. If the balance is short, the publish is
refused with the exact numbers.
The amount held is recorded on the offer as escrowAmount, so every later
release knows what was actually taken rather than re-deriving it from a total
that shrinks as trades consume the offer.
A BUY offer holds nothing at publish. Its collateral is the taker's, and it is taken per trade.
A draft holds nothing either, and that is a hard structural guarantee rather than a flag: the draft path in the code contains no call to the escrow or wallet services at all. Holding funds for an offer that is listed nowhere would be a silent freeze on money the maker can still see and can no longer spend.
Statuses
DRAFT ──────────► PENDING_APPROVAL ──► ACTIVE ◄──► PAUSED
│ │ │ │
└──► CANCELLED └──► REJECTED ├──► COMPLETED
├──► CANCELLED
└──► EXPIRED| Status | Escrow held (SELL) | Visible on the board |
|---|---|---|
DRAFT |
no | no |
PENDING_APPROVAL |
yes | no |
ACTIVE |
yes | yes, if PUBLIC |
PAUSED |
no — released | no |
COMPLETED / CANCELLED / REJECTED / EXPIRED |
no | no |
A new offer lands in PENDING_APPROVAL unless p2pAutoApproveOffers is on, in
which case it goes straight to ACTIVE.
PENDING_APPROVAL keeps its escrow deliberately, so approval never has to find
the funds a second time — an approval that could fail on insufficient balance
would be an approval queue that randomly rejects.
Editing a published offer
An edit republishes price, limits and available total. It is therefore gated by the same KYC feature as creation, and it re-submits the offer for approval unless auto-approve is on or the maker explicitly names a status.
Refusals you will be asked about:
- 422, "Cannot edit offer while there are active trades." Any trade against
the offer in
PENDING,PAYMENT_SENTorDISPUTEDblocks the edit outright. The maker must wait, or cancel. - 422, "Cannot edit offer in
<status>status." OnlyDRAFT,PENDING_APPROVAL,ACTIVEandPAUSEDare editable. - 422, "Cannot activate a draft offer." A draft is incomplete by definition
and cannot be flipped to
ACTIVEthrough the edit route; it has to go through publish. - 422, "Cannot turn a
<status>offer back into a draft." Unpublishing is not a transition. Pausing is how an offer leaves the board. - 400, "Insufficient balance to collateralize this offer." Raising the total on a SELL offer takes the difference as a fresh hold.
Escrow is recomputed from the merged amounts and the resulting status on every SELL write. Raising the total takes more; lowering it releases the attributed amount and re-holds what is still required; pausing releases everything; resuming takes it back.
amountConfig.originalTotal is the ceiling a cancelled trade may restore the
offer's advertised total back up to. It is seeded at the first trade and moved
by the same delta whenever the total is edited — otherwise raising the total
would leave the cap behind and permanently swallow the difference on the next
cancellation.
Pausing, deleting and expiry
Pause takes the offer off the board and releases its escrow to the maker's spendable balance. Resuming re-takes it, and can fail if the money has been spent in the meantime.
Delete is refused while any trade against the offer is PENDING,
PAYMENT_SENT or DISPUTED. Otherwise it soft-deletes the row and releases
whatever escrow is still attributed to it.
Automatic expiry runs in the one-minute cron. An offer is expired when it is
ACTIVE, has not been updated for 30 days, and has a remaining total of
zero or less. Its escrow is released and the owner is notified. An offer with
capacity left is never auto-expired, however old it is.
What a taker sees
The market board (/p2p/market) lists only ACTIVE, PUBLIC offers, filtered
by the viewer's side, currency, fiat, amount, payment methods and country, and
sorted by best / price / trades / speed / newest. Each row carries the
counterparty's trust facts — completed trades, completion rate, typical release
speed, verified badge, and whether the viewer has traded with them before.
Views are counted when a trade is initiated, not on page load, so the number reflects serious interest rather than the maker refreshing their own offer.
Makers also have a forecast endpoint available before publishing, which answers where a proposed price lands against the current board, roughly how many traders could pass the offer's requirements, and how long comparable offers waited for their first trade. Every figure is nullable — an absent forecast is correct where an invented one would not be.