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
- Total up for trade — the traded currency
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, USDT…).
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. The conversion uses the rate of the table the price currency lives in, fiat first: the fiat table for an enabled national currency, the exchange listing's own price column for a spot currency, the ecosystem market's last trade for a token. An offer priced in a currency whose table has no rate for it — an enabled national currency with no rate on file, a spot currency the price job has not priced yet, a token with no market — is refused while either bound is set, because the bound cannot be applied. The refusal names the currency and says which kind of rate is missing, and an enabled national currency is never priced off a coin that shares its code: an enabled fiat row wins the tie and ends the lookup whatever its rate, so an EGP you are running with no rate on file is refused with the advice to set its rate under Finance → Currencies rather than priced as Eigenpie. A row you have switched off wins nothing and decides nothing: the code is priced as the coin where a listing carries it, and refused by name — "… is currently switched off under Finance → Currencies" — only where none does. USDT never waits on the price job; it counts as one dollar even before its listing is priced.- 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 currency
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.
It may be any currency the platform has enabled: a fiat currency, a spot
exchange currency, or an ecosystem token. The composer fills its Priced in
list from GET /api/p2p/offer/price-currencies, which lists enabled fiat
first, alphabetically, then crypto with USDT and USDC at the front. Each group
appears only where the platform can settle it: fiat when fiat wallets are on,
spot when spot wallets are on and an exchange is connected, tokens when the
Ecosystem addon is installed and enabled — the
extension row switched on, not merely present.
A code that appears in more than one table is offered once, under the wallet type the server would assign it — fiat first, then spot, then ecosystem — and a group hidden by its wallet switch still claims its codes. So with fiat wallets off, a code that is both a national currency and a spot ticker is not offered at all rather than reappearing as the coin; with spot wallets off, USDT is not offered through the ecosystem listing. The list never shows a code under a wallet type the publish check would disagree with, because the rate endpoint would then be asked to price it from the wrong table.
A hidden group is not the same thing as a switched-off row, and the
difference is the answer to "I disabled RON and it is still in the list". Every
one of the three tables is read with status = true, so a national currency you
have switched off claims nothing: with fiat wallets on and the RON row off, RON
is offered in the crypto group as Ronin, publish accepts it as SPOT, and the
platform bounds price it from Ronin's market. That is the same answer end to
end, which is the point — but it also means switching a currency off re-prices
the offers already quoted in that code. See
P2P settings before disabling one of the ten codes that
are both.
The list used to be the fiat deposit list. An install with no enabled fiat currency — typically one with no FX rate provider — therefore had an empty Priced in box and could not publish anything; pricing in a stablecoin is how such a platform trades at all.
Publishing validates the code against the same three tables. A price currency
the platform does not know is refused with 400, "<code> is not a currency
this platform can price an offer in. Pick one from the Priced in list." USD
always passes, even when the USD row is disabled, because the platform prices
USD at 1 without reading the table. Saving a draft never validates the price
currency — not through the create route and not through the edit route —
because a draft is listed nowhere and prices nothing; publishing does. An edit
that is not a draft save re-validates the price currency only when it changes,
so an operator disabling a currency does not brick edits of the offers already
priced in it.
GET /api/p2p/offer/{id} returns priceCurrencyWalletType — FIAT, SPOT,
ECO, or null when the offer has no price currency, when the platform no
longer knows it, or when the lookup itself failed — beside assetIsFiat,
because the rate endpoint needs the wallet type of both legs and a client
should not have to guess it. The permalink never fails over that lookup; a
null there costs the client a market rate, not the page.
A MARGIN price needs a market rate on both legs. The rate between the traded asset and the price currency exists only when the platform can price each side — a fiat with a rate on file, a spot currency the exchange has priced, a token with an ecosystem market. When either side has no price the composer reports the market rate as unavailable and the maker must price the offer FIXED. Nothing is guessed: an asset with no ecosystem market priced in USDT has no market rate, and the form says so rather than inventing one.
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.
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
- Owner, type and currency
- Status decides which doors the case screen draws
- Open a row for the offer case and the admin edit
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.
- 400, "
<code>is not a currency this platform can price an offer in." The edit changedpriceConfig.currencyto a code that is in none of the fiat, spot or ecosystem tables with status on. Leaving the currency as it was is never refused for this reason, and neither is a draft save, whatever it changed.
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.