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:
| Transport | Header | Best for |
|---|---|---|
| API key | x-api-key: <key> | Trusted server-side integrations and bots, no user session. |
| Bearer token | Authorization: Bearer <access-token> | Apps where a user signs in (wallet or email). |
| OAuth 2.0 | authorization-code flow | Delegated 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:
| Route | Accepts | Everything else gets |
|---|---|---|
/api-keys (all methods) | Session only | 403 Auth method '<method>' is not permitted on this route |
PATCH /users/profile/display-name | Session only | The same 403 |
PATCH /users/preferences | Session only | The same 403 |
GET /auth/api-key/me | API key only | 401 |
Account and session routes under /auth, and /oauth2/grants | Bearer or session cookie | 403 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.
- Sign in as a user (wallet or email, below) to get a session.
- 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…"
}| Field | Rule |
|---|---|
label | Required. Trimmed, 1 to 100 characters. |
scope | read (the default) or read_write. Only read_write can trade. |
expiresAt | Optional. 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.
- 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.
- 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.
- Sign the returned
messageverbatim. Do not rebuild it from the nonce; the signature is checked against the exact text. - 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
nonceback. 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/loginrefuses an already-authenticated caller. A live bearer token or session cookie on the request returns403 Already authenticated. The same applies toPOST /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/forgot → POST /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:
| Endpoint | Purpose |
|---|---|
GET /.well-known/oauth-authorization-server | Discovery. Read the other endpoints from here rather than hardcoding them. |
POST /register | Dynamic client registration. |
GET /authorize | Starts the flow. Redirects the browser to Pear's login and consent page. |
POST /token | Exchanges the code for tokens, and refreshes them. |
POST /revoke | Revokes one token. |
The server advertises what it supports, and the list is short:
| Metadata | Value |
|---|---|
response_types_supported | code |
grant_types_supported | authorization_code, refresh_token |
code_challenge_methods_supported | S256 |
token_endpoint_auth_methods_supported | none |
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:
| Scope | Grants |
|---|---|
mcp:read | Reads. The gateway does not enforce it: GET, HEAD, and OPTIONS pass for any grant. |
mcp:write | Every 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
| Endpoint | Purpose |
|---|---|
GET /oauth2/grants | List 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
| Action | Endpoint |
|---|---|
| Refresh a cookie session | POST /auth/session/refresh |
| Refresh bearer tokens | POST /auth/session/refresh/token |
| Read the current user | GET /auth/session/me |
| Log out | POST /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 from | The _pear_refresh_token cookie | The request body, { "refreshToken": "…" } |
| Returns | accessToken, tokenType, expiresIn | accessToken, refreshToken, tokenType, expiresIn |
| Sets new cookies | Yes | Yes |
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:
| Cookie | Holds | Lifetime |
|---|---|---|
_pear_access_token | The access token | A browser session. No max-age is set. |
_pear_refresh_token | The refresh token | The 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.