What P2P serves without a login
The thirteen P2P endpoints an anonymous caller can read, what each one discloses, why the kill switches do not stop them, and what running a private marketplace would actually take.
Settings says the three kill switches persuade the browser rather than the API. This page is the part that sentence implies and never states: which endpoints are genuinely open, and what each of them hands out.
You need it for two decisions. First, whether this platform can run a private marketplace — a book only your signed-in customers can see. Second, what to say when a maker asks whether their offer, their terms and their trading record are visible to people who have not signed up.
The short answers are no, not without a code change and yes, all of it.
How a route becomes public
The router decides in one line, in handler/Routes.ts:
if (!metadata.requiresAuth) {
// licence gate -> baseline rate limit -> optional user -> handler
}It is a falsy test, not an equality test. A route that declares
requiresAuth: false is public, and so is a route that never mentions
requiresAuth at all. There is no default of true applied anywhere.
That matters because it means the public surface is larger than a grep for
requiresAuth: false suggests. Nine P2P routes declare it. Four more are public
because nobody wrote the line.
The nine that declare it
All GET. All licensed (see below). All answer an anonymous curl.
| Endpoint | Returns | optionalAuth |
|---|---|---|
GET /api/p2p/market/board |
the tradable offers with a same-side price reference and each counterparty's trust record | yes |
GET /api/p2p/market/traders |
the same market as a list of people — one card per counterparty, with both prices they quote | yes |
GET /api/p2p/market/picks |
a short list of suggested counterparties, each labelled with why it was picked | yes |
GET /api/p2p/market/locale |
best-effort country and fiat currency for the caller | yes |
GET /api/p2p/offer/{id} |
one offer in full, with the maker's computed record | yes |
GET /api/p2p/market/stats |
aggregate marketplace statistics | no |
GET /api/p2p/market/top |
top cryptocurrencies by P2P trade volume | no |
GET /api/p2p/cryptocurrencies |
currencies with at least one active public offer | no |
GET /api/p2p/landing |
the landing payload: stats, top cryptos, featured offers, top traders, payment methods | no |
The four that are public by omission
Same behaviour, no declaration. They are worth knowing about individually because three of them return more than the curated market endpoints do.
| Endpoint | Returns |
|---|---|
GET /api/p2p/offer |
the paginated, filterable offer list — ACTIVE and publicly-visible offers only |
GET /api/p2p/offer/popularity |
offers ranked by completed trades and review averages |
GET /api/p2p/market/highlight |
the five newest ACTIVE public offers |
GET /api/p2p/location |
the distinct countries extracted from user profile locations |
GET /api/p2p/market/highlight, GET /api/p2p/offer,
GET /api/p2p/offer/popularity and GET /api/p2p/offer/{id} return the
p2p_offers row with no attribute list and no serializer in front of it — the
first three literally as SELECT o.* or an unrestricted findAll. That row
carries adminNotes, activityLog, systemTags, escrowAmount and views
alongside the public fields.
Moderating offers says an internal note is never shown to users, and on every screen that is true. It is not true of the API. Write admin notes on the assumption that anybody who can request the offer can read them, and put anything genuinely sensitive in the trade's internal note instead — that endpoint requires authentication.
What optionalAuth does
Five of the public routes also set optionalAuth: true. It is not an
authentication requirement; it is permission for a public route to know who is
asking.
Without it, a public route never runs authenticate, so data.user reaches the
handler as undefined even for a caller with a valid session — and every
"public, personalised when signed in" branch runs its signed-out half for
everybody. On the market that was visible: nothing knew whose offers were whose,
so the picks shelf could recommend a maker their own offer, which 404s when
taken.
With it, one read resolves the caller from the access token or, past the
15-minute token expiry, from the sessionId cookie. It never refuses — a
missing, expired or revoked token simply leaves the caller anonymous and the
request proceeds.
What it changes on each route:
| Route | Signed in, you get |
|---|---|
| board | your own offers marked isOwn and given a rank on the board, plus ownCount / tradableCount / ownBestRank, and counterparties you have completed a trade with flagged. includeOwn=0 drops them instead |
| traders | your own offers hidden — this lens is a list of people to deal with, not a price ranking — and prior counterparties flagged |
| picks | the board with includeOwn=0, because every pick is a trade you must be able to open |
| locale | your profile's country and currency preferred over the IP guess |
| offer detail | your own DRAFT offer becomes readable; to everyone else it answers "not found" |
What these endpoints disclose about your users
Nothing here returns an email address — that was fixed deliberately on the listing, the board and the offer permalink. What it does return, to anyone:
- Full name and avatar of every offer maker and every trader on the board.
- Presence. The board and the traders lens return
lastSeenAtas the trader's exactlastLogintimestamp. The offer permalink deliberately does not: it deleteslastLoginand emits a floored age instead, so a consumer can learn "between three and four hours ago" and no more. The two are inconsistent, and the board is the permissive one. - A trading record. Completed trades, completion rate, average release time in seconds, review average and review count — see Reputation.
- Country list.
GET /api/p2p/locationreadsprofile.location.countryacross the wholeusertable, not just P2P participants.
An offer id is enough
GET /api/p2p/offer/{id} has exactly one visibility rule: a DRAFT is answered
as "Offer not found" unless you are its author. There is no status filter and no
PRIVATE filter on this route.
So anyone holding an id can read the offer's price model, its limits, its
written terms, its payment methods, its taker requirements and its maker —
including offers that are PAUSED, REJECTED, CANCELLED or EXPIRED, and
including offers whose maker set visibility: PRIVATE precisely so they would
not appear on the board. PRIVATE means "excluded from listings", not
"protected". Creating offers describes the setting from
the maker's side; make sure your own support copy does not describe it as
private in a stronger sense than that.
The switches do not gate any of this
p2pEnabled, p2pMaintenanceMode and p2pAllowGuestBrowsing are read by five
frontend client components and by no backend route. Grepping the backend for
them returns a single comment.
And on most pages they do less than "hide the UI":
| Page | With the switch off |
|---|---|
/p2p |
banner above the page; the landing content still renders |
/p2p/market |
banner above the board; the board still renders and still fetches |
/p2p/offer/{id} |
banner, and the trade opener is replaced with an explanatory block; the offer still renders |
/p2p/offer/new |
genuine early return — the composer is replaced (this one also honours p2pAllowNewOffers) |
/p2p/trade/{id} |
genuine early return on p2pEnabled — but its maintenance branch reads the core isMaintenanceMode, not p2pMaintenanceMode |
p2pAllowGuestBrowsing in particular adds a "feature restricted" banner for a
signed-out visitor on the market page and then draws the market underneath it.
It does not gate the fetch, and the endpoint it calls would answer regardless.
To actually take P2P offline, switch the extension row off at
/admin/system/extension — and read
the drain procedure first, because doing it with
live offers strands their escrow behind gated routes.
The licence gate does cover them
This is the one gate that is genuinely in front of the public routes.
licenseEnforcementGate runs on the public branch of the router, before the
handler, and the extension map binds the whole /api/p2p and /api/admin/p2p
prefixes to the p2p extension.
| Condition | Answer |
|---|---|
| Core licence not activated, expired or revoked | 403 with the licence message |
Core valid, p2p extension licence invalid |
403 with {"message":"This feature requires a valid license for p2p. Please activate your license to continue.","statusCode":403,"licenseRequired":true,"productId":…,"productType":"extension","productName":"p2p"} — the gate names the failure internally as an "Extension license required" error, but the responder never serialises that: no reply on the wire carries an error or an extension key, so match on licenseRequired and productName |
| Security manager not initialised yet (boot grace) | request passes |
| The licence check itself throws | request passes — p2p fails open |
That last row is worth internalising. Fail-open is the platform's long-standing behaviour for every addon except the two newest, so a transient database or filesystem error during licence verification serves the market board rather than refusing it. An unlicensed install is closed; an install whose licence cannot be checked is open.
This is also why step 4 of the install verification
is a plain curl against the market board: a licence or forbidden error there
means the licence has not validated, not that authentication is missing.
Rate limits: what actually applies
The platform's baseline per-IP limiter (RATE_LIMIT, default 100 requests per
RATE_LIMIT_EXPIRE seconds, default 60) returns early for any method that is
not POST, PUT, PATCH or DELETE. Every public P2P route is a GET, so
the limiter never meters one.
Nor does any of them declare a per-route limiter. The p2pSearch bucket — 120
requests per minute, which the Install
table lists as "Search / trade history" — is attached to
GET /api/p2p/trade/history, and that route requires a session. It is not a
public limit.
Your whole order book, with every maker's name and trading record, is therefore scrapeable at whatever rate your server will answer. If that matters to you, the limit has to come from your reverse proxy — see Nginx.
Running a private marketplace
There is no supported way to do it today. A setting that made the P2P API signed-in-only would have to flip these routes onto the authenticated branch of the router, and no such setting or gate exists. Faking it in the settings screen was rejected for the same reason the Security tab was removed: a switch that claims to lock the door when it only takes the sign down is worse than no switch.
Three things you can actually do, none of them equivalent:
-
Refuse the public routes at the reverse proxy. The most honest of the three, because it happens before the application. Deny or
auth_requestthe thirteen paths above in yournginxserver block and let the authenticated routes through. Test the customer pages afterwards — the market page, the landing page and the offer permalink all fetch these endpoints, so blocking them blocks your own signed-in customers' pages too unless the rule distinguishes on a session cookie. See Nginx. -
Use geographic restriction, if the requirement is territorial. The geo gate is installed with
app.use()ahead of every route and evaluates synchronously before the licence gate, so it does stop anonymous callers — it is the only platform control in front of these routes that does. Its admin bypass exempts/api/adminonly. It filters by country, not by whether the caller has an account. See Geo restrictions. -
Accept that the book is public and shape what is in it. Makers can set an offer to
PRIVATE, which keeps it out of every listing and leaves it reachable by direct link. That is a real reduction in discoverability and it is not confidentiality.
Whichever you choose, write it down. "Is our order book public?" is a question that gets asked once, by somebody senior, usually after it has already been answered by a screenshot.
Checking your own install
# Every one of these should answer with data, signed out, on a licensed install.
for p in \
"/api/p2p/market/board?side=buy&limit=1" \
"/api/p2p/market/traders?limit=1" \
"/api/p2p/market/picks" \
"/api/p2p/market/locale" \
"/api/p2p/market/stats" \
"/api/p2p/market/top" \
"/api/p2p/market/highlight" \
"/api/p2p/cryptocurrencies" \
"/api/p2p/landing" \
"/api/p2p/location" \
"/api/p2p/offer?perPage=1" \
"/api/p2p/offer/popularity?limit=1"
do
printf '%-46s %s\n' "$p" \
"$(curl -s -o /dev/null -w '%{http_code}' "https://YOUR_HOST$p")"
doneA 403 on all of them means the licence has not validated. A 200 on all of
them is the documented, expected state — it is not a misconfiguration you can
correct from the admin panel.
The thirteenth, GET /api/p2p/offer/{id}, needs a real offer id; take one from
the board response and confirm for yourself what comes back.