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.

12 min readUpdated 1 September 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

  1. 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:

  • 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. 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 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 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 priceCurrencyWalletTypeFIAT, 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.

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.

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

  1. Owner, type and currency
  2. Status decides which doors the case screen draws
  3. 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_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.
  • 400, "<code> is not a currency this platform can price an offer in." The edit changed priceConfig.currency to 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.