PearPear
API IntegrationAccess Management

Authentication

Authenticate to the V3 gateway with an API key, wallet or email login, or OAuth, and scope requests to a trade account.

The V3 gateway accepts three authentication transports. Pick the one that matches your integration:

TransportHeaderBest for
API keyx-api-key: <key>Trusted server-side integrations and bots, no user session.
Bearer tokenAuthorization: Bearer <access-token>Apps where a user signs in (wallet or email).
OAuth 2.0authorization-code flowDelegated access where a user grants your app permission, how the Orchard MCP connects agents.

Every account-scoped request also sends the trade-account header:

x-trade-account-id: <trade-account-id>

This selects which connected exchange account the call reads from or trades on (see Trade Accounts).

Routes that accept only one transport

Most routes take any of the three. These do not:

RouteAcceptsEverything else gets
/api-keys (all methods)Session only403 Auth method '<method>' is not permitted on this route
PATCH /users/profile/display-nameSession onlyThe same 403
PATCH /users/preferencesSession onlyThe same 403
GET /auth/api-key/meAPI key only401
Account and session routes under /auth, and /oauth2/grantsBearer or session cookie403 OAuth grant tokens cannot access first-party account endpoints

"Session" here means a first-party sign-in: a bearer access token from a login, or the session cookies.

API key (server-side)

For a server-side integration, mint an API key once and send it on every request.

Key management is session-only. GET, POST, and DELETE on /api-keys all need a first-party sign-in. An API key cannot mint or revoke another API key, and neither can an OAuth grant. Both get a 403.

  1. Sign in as a user (wallet or email, below) to get a session.
  2. Create a key with POST /api-keys:
{
  "label": "trading-bot",
  "scope": "read_write",
  "expiresAt": "2027-01-15T09:30:00.000Z"
}

It returns:

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "label": "trading-bot",
  "scope": "read_write",
  "key": "pear_sk_a1b2c3…"
}
FieldRule
labelRequired. Trimmed, 1 to 100 characters.
scoperead (the default) or read_write. Only read_write can trade.
expiresAtOptional. ISO-8601 datetime carrying a zone: Z, or an explicit offset such as +01:00. A local time with no zone is a 400. Omit the field for a key that never expires.

The key is returned once, at creation. It is pear_sk_ followed by 40 random characters. Store it in a secret manager: only its first 12 characters are kept, as the keyPrefix you see in listings.

  1. Send it on each request:
curl "https://pro-gateway.pearprotocol.io/positions" \
  -H "x-api-key: $PEAR_API_KEY" \
  -H "x-trade-account-id: $TRADE_ACCOUNT_ID"

Check a key's identity any time with GET /auth/api-key/me, which only an API key may call. An unknown, expired, or revoked key returns 401.

GET /api-keys returns a bare array, not a wrapped object:

[
  {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "label": "trading-bot",
    "scope": "read_write",
    "keyPrefix": "pear_sk_a1b2",
    "lastUsedAt": "2026-01-02T10:00:00.000Z",
    "expiresAt": "2027-01-15T09:30:00.000Z",
    "revokedAt": null,
    "createdAt": "2026-01-01T00:00:00.000Z"
  }
]

Revoke one with DELETE /api-keys/{id}.

Wallet login

Wallet ownership is the identity, no password.

  1. Request a nonce with POST /auth/nonce:
{ "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" }

It returns the message to sign:

{
  "nonce": "8f14e45f…",
  "message": "app.pearprotocol.io wants you to sign in with your Ethereum account:\n0x1f9840a85d5af5bf1d1762f925bdaddc4201f984\n\nSign in to PEAR Protocol.\n\nURI: https://app.pearprotocol.io\nVersion: 1\nChain ID: 1\nNonce: 8f14e45f…",
  "expiresAt": 1767225600
}

message is a SIWE block. expiresAt is a Unix timestamp in seconds, five minutes out. The domain and URI follow the request's Origin when it is an allowlisted frontend, and fall back to Pear's own otherwise.

  1. Sign the returned message verbatim. Do not rebuild it from the nonce; the signature is checked against the exact text.
  2. Log in with POST /auth/login, sending the nonce back with the signature:
{
  "method": "wallet",
  "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
  "signature": "0x…",
  "nonce": "8f14e45f…"
}

It returns the session:

{
  "accessToken": "eyJhbGciOi…",
  "refreshToken": "eyJhbGciOi…",
  "user": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "role": "basic",
    "providerId": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
    "loginMethod": "evm_wallet"
  }
}

The request discriminator is method: "wallet", but the session reports loginMethod: "evm_wallet". The two are different vocabularies and both are correct. loginMethod is one of evm_wallet, email, x, or oauth. providerId is the identity behind the session — the lowercased wallet address here, the lowercased email for an email login — and it is null for an OAuth-issued session.

Send the access token as Authorization: Bearer <access-token>, or rely on the session cookie in session mode.

Two more things about logging in:

  • Send nonce back. When you do, the server looks up that exact challenge, so a second nonce requested for the same address does not invalidate the first. The field is optional today only for older clients, and it will become required. A caller that omits it falls back to "the newest live challenge for this address", which a concurrent request can steal.
  • POST /auth/login refuses an already-authenticated caller. A live bearer token or session cookie on the request returns 403 Already authenticated. The same applies to POST /auth/register/emailpass, POST /auth/password/forgot, POST /auth/password/reset, and both X sign-in endpoints. Log out first, or send the login request without credentials.

Both wallet login and email registration also accept an optional accessCode, up to 64 characters. It only affects the role given to a new user; it is ignored for an account that already exists.

Email login

Register with POST /auth/register/emailpass, which sends a verification email. Verify with POST /auth/verify-email, then log in with method: "email":

{ "method": "email", "email": "trader@example.com", "password": "…" }

Registration returns 204 whether or not it did anything, so the endpoint cannot be used to probe which addresses exist. An address that already has a verified owner, or an active pending claim, gets no email. Drive your UI from GET /auth/session/me after verification, not from the status code.

Password reset runs POST /auth/password/forgotPOST /auth/password/reset.

Sign in with X

An X account that a user has already linked can also sign in. The flow never creates an account, so it only works after the link exists. See Account Linking.

More than one way in

A Pear account can hold a wallet, an email login, and an X account at once. Adding one to an existing account is Account Linking; it also covers changing a password and resending a verification email.

OAuth 2.0 (delegated)

For agents and third-party apps acting on a user's behalf, use the OAuth 2.0 authorization-code flow with PKCE. The user signs in and approves access on a Pear-hosted page; your app never sees their credentials.

OAuth 2.0 is the full reference. It covers registering a client, the flow end to end, hosting the consent screen, and what a grant cannot do. What follows here is the summary.

Start from the discovery document, which names the current endpoints:

EndpointPurpose
GET /.well-known/oauth-authorization-serverDiscovery. Read the other endpoints from here rather than hardcoding them.
POST /registerDynamic client registration.
GET /authorizeStarts the flow. Redirects the browser to Pear's login and consent page.
POST /tokenExchanges the code for tokens, and refreshes them.
POST /revokeRevokes one token.

The server advertises what it supports, and the list is short:

MetadataValue
response_types_supportedcode
grant_types_supportedauthorization_code, refresh_token
code_challenge_methods_supportedS256
token_endpoint_auth_methods_supportednone

PKCE with S256 is the only code-challenge method, and clients are public: there is no client secret.

Scopes

Two scopes exist, and a client requests both by default:

ScopeGrants
mcp:readReads. The gateway does not enforce it: GET, HEAD, and OPTIONS pass for any grant.
mcp:writeEvery other method. This is the scope the gateway actually checks.

Every mutating request needs mcp:write. A grant without it gets 403 OAuth grant does not have write access on any method other than GET, HEAD, or OPTIONS. This is the same boundary a read API key sits behind, and it is why reading a trade account's credentials is a POST.

A user may hold 10 active grants at once. The eleventh consent is refused; revoking a connected agent frees a slot.

Reviewing and revoking grants

EndpointPurpose
GET /oauth2/grantsList the clients the user has authorized: id, name, scopes, connectedAt, newest first.
DELETE /oauth2/grants/{id}Revoke one grant. Returns 204.

Both need a first-party sign-in. An OAuth grant token cannot call either one, so an agent can neither list nor revoke its own grant; only the user can, from a session. An agent that wants to hand back its own access uses POST /revoke instead.

The consent screen itself is driven by the OAuth Consent endpoints.

This is what the Orchard MCP uses, see the Pear MCP integration guide for the complete flow.

Refresh & logout

ActionEndpoint
Refresh a cookie sessionPOST /auth/session/refresh
Refresh bearer tokensPOST /auth/session/refresh/token
Read the current userGET /auth/session/me
Log outPOST /auth/session/logout

The two refresh endpoints do not return the same thing. Pick the one that matches how you hold the session:

/auth/session/refresh/auth/session/refresh/token
Reads the refresh token fromThe _pear_refresh_token cookieThe request body, { "refreshToken": "…" }
ReturnsaccessToken, tokenType, expiresInaccessToken, refreshToken, tokenType, expiresIn
Sets new cookiesYesYes

The cookie route never hands back a refresh token; the new one is in the Set-Cookie response. Both rotate the whole session: the old session is revoked and a new one is minted, so the previous refresh token stops working the moment either call succeeds. Refresh once, and use the result.

Logging out

Logout only revokes a session when the refresh cookie is present. It reads _pear_refresh_token and nothing else. A bearer-only client gets 204 and no revocation, and its session stays live until the refresh token expires. To end a bearer session for certain, send the request with the cookie, or let the refresh token lapse.

Cookies and lifetimes

Session mode uses two cookies, both httpOnly, both on path /, and both secure with sameSite=none in production:

CookieHoldsLifetime
_pear_access_tokenThe access tokenA browser session. No max-age is set.
_pear_refresh_tokenThe refresh tokenThe deployment's refresh-token lifetime, in days.

Token lifetimes are deployment configuration, not a fixed contract. Read expiresIn off a refresh response, in seconds, rather than assuming a number.

The TypeScript SDK refreshes a session for you on a 401 when auth.type is session.

See Trade Accounts for how to connect an exchange and scope requests, and Client ID for the separate code that attributes the trades your integration routes.

On this page