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.

7 min readUpdated 3 August 2026offers, pricing, escrow, limits

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:

  • total must be greater than 0.
  • min may not exceed max.
  • min and max, once converted to the traded currency, may not exceed total.
  • min must be at or above p2pMinimumTradeAmount; max may not exceed p2pMaximumTradeAmount. 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 min must clear the per-currency dust floor from p2pMinimumTradeAmounts (defaults include BTC 0.00005, ETH 0.001, DOGE 10, XRP 5, SOL 0.01).

Price

Two models.

FIXEDvalue is the price, and must be greater than 0. finalPrice equals it.

MARGINvalue 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.emailVerifiednot 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_SENT or DISPUTED blocks the edit outright. The maker must wait, or cancel.
  • 422, "Cannot edit offer in <status> status." Only DRAFT, PENDING_APPROVAL, ACTIVE and PAUSED are editable.
  • 422, "Cannot activate a draft offer." A draft is incomplete by definition and cannot be flipped to ACTIVE through 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.