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.

7 min readUpdated 6 August 2026public, api, privacy, licence, scraping

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 lastSeenAt as the trader's exact lastLogin timestamp. The offer permalink deliberately does not: it deletes lastLogin and 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/location reads profile.location.country across the whole user table, 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:

  1. Refuse the public routes at the reverse proxy. The most honest of the three, because it happens before the application. Deny or auth_request the thirteen paths above in your nginx server 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.

  2. 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/admin only. It filters by country, not by whether the caller has an account. See Geo restrictions.

  3. 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")"
done

A 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.