PearPear
API IntegrationAccess Management

OAuth 2.0

Delegated access with OAuth 2.0, from registering a client to running the authorization-code flow with PKCE and holding a grant the user can revoke.

OAuth 2.0 is how a third-party app or an agent acts on a Pear user's behalf without ever holding that user's credentials. The user signs in and approves access on a Pear-hosted page. Your app receives an access token bound to a grant, and the user can cut that grant off at any time.

This is the transport the Orchard MCP uses to connect agents.

When to use it

Pick the transport that matches whose account you act on:

TransportWhose accountWhose credentialsUse it for
API keyYoursYoursYour own server, your own bot. No end user.
Session loginYour user'sYour user's, held by youAn app where your users sign in to Pear through you.
OAuth 2.0Someone else'sNever yoursA client, agent, or integration other people connect to their own Pear account.

Use OAuth when the person granting access is not you and does not sign in through you. If you are writing a bot that trades your own account, an API key is simpler and needs no browser. See Authentication for both alternatives.

The flow is the standard authorization code grant with PKCE. Two facts shape every integration:

  • PKCE with S256 is mandatory. An authorize request without a code challenge is refused.
  • Clients are public. There is no client secret. token_endpoint_auth_methods_supported is none, and nothing at the token endpoint proves who you are except the PKCE verifier.

The protocol endpoints

None of these five appear in the API Specification. The generated reference is built from the annotated gateway surface, and the OAuth protocol endpoints are excluded from it deliberately: the discovery document is the contract, and a client is expected to read the endpoints from there at runtime rather than from a page. This page documents them by hand.

All five sit on the same host as the rest of the gateway:

https://pro-gateway.pearprotocol.io
EndpointPurposeAuth
GET /.well-known/oauth-authorization-serverDiscovery.None
POST /registerDynamic client registration (RFC 7591).None
GET /authorizeStarts a flow. Redirects the browser to login and consent.None
POST /tokenExchanges a code for tokens, and refreshes them.None
POST /revokeRevokes one grant, by its refresh token (RFC 7009).The token itself

None of them takes a session, an API key, or a bearer token.

Discovery

curl "https://pro-gateway.pearprotocol.io/.well-known/oauth-authorization-server"

The document is static and advertises exactly ten fields:

{
  "issuer": "https://pro-gateway.pearprotocol.io",
  "authorization_endpoint": "https://pro-gateway.pearprotocol.io/authorize",
  "token_endpoint": "https://pro-gateway.pearprotocol.io/token",
  "registration_endpoint": "https://pro-gateway.pearprotocol.io/register",
  "revocation_endpoint": "https://pro-gateway.pearprotocol.io/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["mcp:read", "mcp:write"]
}
FieldWhat it tells you
issuerThe authorization server's identity. Every other URL is built from it, so all five endpoints share this origin.
authorization_endpointWhere to send the browser.
token_endpointWhere to exchange a code and where to refresh.
registration_endpointWhere to register a client. Its presence is what tells you dynamic registration is open.
revocation_endpointWhere a client hands its own access back.
response_types_supportedcode only. There is no implicit flow and no hybrid flow.
grant_types_supportedauthorization_code and refresh_token only. No client credentials, no password grant, no device code.
code_challenge_methods_supportedS256 only. plain is refused.
token_endpoint_auth_methods_supportednone. Clients are public; there is no secret to send.
scopes_supportedThe scopes this deployment recognizes. See Scopes.

Read the endpoints from this document rather than hardcoding them. There is no introspection_endpoint: access tokens are signed JWTs that each resource server verifies locally.

Register a client

Registration is open and unauthenticated. Anyone can register a client, so registration proves nothing on its own; the user's consent is what grants access.

curl -X POST "https://pro-gateway.pearprotocol.io/register" \
  -H "content-type: application/json" \
  -d '{
    "redirect_uris": ["https://client.example.com/oauth/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "client_name": "Example Agent"
  }'

The fields are snake_case, because RFC 7591 is the wire contract here rather than Pear's usual convention:

FieldRule
redirect_urisRequired. At least one. Each must be https, or http only for a loopback host: localhost, 127.0.0.1, or ::1. Anything else is a 400.
token_endpoint_auth_methodnone is the only accepted value, and the default.
grant_typesAny subset of authorization_code and refresh_token. Defaults to both. A client that omits refresh_token here cannot refresh later.
response_types["code"], and the default.
client_nameOptional. The name the user sees on the consent screen and in their connected-agents list. Omit it and both fall back to the client id.

The response is the registered client:

{
  "client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "redirect_uris": ["https://client.example.com/oauth/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "client_name": "Example Agent",
  "client_id_issued_at": 1767225600
}

client_id_issued_at is a Unix timestamp in seconds. There is no client_secret, and no client_secret_expires_at.

A registration cannot be read, changed, or deleted afterwards. No registration access token is issued and no management endpoints exist. To change a redirect URI, register a new client.

redirect_uris are matched as exact strings when a flow runs. A URI that differs from the registered one by a port, a trailing slash, or a query string is a different URI and is refused. Register every callback you will use.

Authorize

Send the browser to /authorize with the query parameters below. This is a top-level navigation, not a fetch.

ParameterRequiredRule
response_typeYescode.
client_idYesFrom registration.
redirect_uriYesAn exact match of one registered URI.
code_challengeYesBase64url SHA-256 of your verifier.
code_challenge_methodYesS256. Omitting it means plain, which is refused.
scopeNoSpace-delimited. Omitted means no scope, which yields a grant that cannot write.
stateNoOpaque. Echoed back on the callback. Send one and check it.

On success the response is a 302 to a Pear-hosted login and consent page. Your app plays no part until the user finishes there.

On failure /authorize answers with JSON, and never redirects to your callback. RFC 6749 allows an authorization server to report some failures by redirecting to the client with error parameters. Pear does not, because the failure does not say which validation stage threw, and redirecting on the wrong one would send a code-flow error to a URI that was never properly validated. The trade is deliberate:

{
  "status": 400,
  "message": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed: The authorization server requires public clients to use PKCE RFC-7636",
  "error": "invalid_request",
  "error_description": "The authorization server requires public clients to use PKCE RFC-7636"
}

Every OAuth failure on /authorize and /token uses this shape. Read error for the machine-readable code and error_description for the specific cause; message is the two joined.

Two consequences for your client:

  • A broken authorize request strands the user on a JSON page. They never return to your callback, and your state never comes back. Time out the pending authorization transaction on your side; do not wait for a callback that will not arrive.
  • The only error your callback ever receives is a denial. If the user presses cancel, the consent step redirects to your redirect_uri with error=access_denied. Every other failure is JSON on Pear's origin.
FailureStatuserror
Unknown client_id401invalid_client
redirect_uri not registered, or not an exact match401invalid_client
code_challenge missing400invalid_request
code_challenge_method not S256400invalid_request
A scope outside scopes_supported400invalid_scope

Token

POST /token serves both grant types. Send the parameters as application/x-www-form-urlencoded.

Authorization code:

ParameterRule
grant_typeauthorization_code.
codeFrom the callback. Single use, and valid for 60 seconds.
redirect_uriThe same value you sent to /authorize.
client_idYour client id. There is no secret.
code_verifierThe verifier whose challenge started the flow. 43 to 128 characters from A–Z a–z 0–9 - . _ ~.

Refresh:

ParameterRule
grant_typerefresh_token.
refresh_tokenThe current refresh token.
client_idThe client the grant was issued to.
scopeOptional. May only narrow the granted set; a scope outside it is refused. Omit it to keep what was granted.

Both return the same body:

{
  "token_type": "Bearer",
  "expires_in": 900,
  "access_token": "eyJhbGciOi…",
  "refresh_token": "…",
  "scope": "mcp:read mcp:write"
}

The access token is an ordinary Pear access token. Send it as Authorization: Bearer <access-token> on gateway requests, exactly like a session token, together with x-trade-account-id on account-scoped routes.

expires_in is seconds and is deployment configuration. Read it; do not assume a number.

Refreshing rotates the refresh token in place. The old one stops working the moment the call succeeds, and the new one carries a fresh expiry. The grant keeps its identity across the whole chain, so the id the user sees in their connected-agents list never changes and one revocation kills every token the chain ever issued.

FailureStatuserrorerror_description
Unknown client_id401invalid_client
code unknown, expired, or already redeemed400invalid_grantThe authorization code is invalid or has expired
Two requests race for one code400invalid_grantThe authorization code has already been redeemed
code_verifier does not match the challenge400invalid_grantFailed to verify code challenge.
code_verifier outside the RFC 7636 character set or length400invalid_requestCode verifier must follow the specifications of RFC-7636
redirect_uri different from the authorize one400invalid_requestInvalid redirect URI
redirect_uri missing400invalid_requestCheck the `redirect_uri` parameter
refresh_token unknown, or already rotated away400invalid_grantThe refresh token is invalid or has expired
refresh_token presented by a different client400invalid_requestToken is not linked to client
The grant's refresh window has closed400invalid_requestToken has expired
The grant was revoked400invalid_requestToken has been revoked

A wrong verifier does not burn the code: the check runs before the code is claimed, so a client that retries with the right verifier inside the 60-second window still succeeds.

Revoke

POST /revoke is how a client hands its own access back — on disconnect, on uninstall, or when a user removes the integration from your side.

curl -X POST "https://pro-gateway.pearprotocol.io/revoke" \
  -H "content-type: application/json" \
  -d '{ "token": "<the refresh token>" }'
FieldRule
tokenRequired. The refresh token.
token_type_hintOptional, and ignored. The token is always resolved as a refresh token.

It returns 200 with an empty body.

Only a refresh token revokes anything. The endpoint resolves the presented value against stored refresh tokens and nothing else. Send an access token and you get the same 200, having revoked nothing. RFC 7009 makes an unknown token a success, so the status code cannot tell you which happened.

A successful revoke kills the whole grant at once: the refresh token can never mint again, and the current access token is refused on its next request rather than lingering until it expires.

A worked flow, end to end

This is the whole authorization-code-with-PKCE flow, once, with real values.

1. Mint a verifier and derive its challenge. Keep the verifier server-side, or in memory, alongside the state for this one attempt.

const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);                                             // 43 chars
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));                           // 43 chars

32 random bytes encode to 43 base64url characters, which satisfies the 43-to-128 rule. Hash the ASCII text of the verifier, not the raw bytes it was encoded from.

For the rest of this walkthrough:

verifier  = M2Rk8vQ1yZ7pLxNfA0bC5tHwJ9sYnE3uKoPrTgVdWiU
challenge = Oe2KG8_VRFJ90ROiM0MHMOzNfOlBkzX6te9dyL1-6BE

2. Send the browser to /authorize. URL-encode the scope string; : and the separating space both need it.

https://pro-gateway.pearprotocol.io/authorize
  ?response_type=code
  &client_id=3fa85f64-5717-4562-b3fc-2c963f66afa6
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback
  &scope=mcp%3Aread%20mcp%3Awrite
  &state=8f14e45fceea167a
  &code_challenge=Oe2KG8_VRFJ90ROiM0MHMOzNfOlBkzX6te9dyL1-6BE
  &code_challenge_method=S256

3. The user signs in and approves. Pear serves this. Your app is not involved, and never sees the wallet signature or the password.

4. Your callback receives the code.

https://client.example.com/oauth/callback?code=eXaMpLe…&state=8f14e45fceea167a

Compare state against the value you stored. Stop if they differ. The code is valid for 60 seconds, so exchange it immediately.

If the user pressed cancel instead:

https://client.example.com/oauth/callback?error=access_denied&error_description=User+denied+the+authorization+request&state=8f14e45fceea167a

5. Exchange the code.

curl -X POST "https://pro-gateway.pearprotocol.io/token" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=eXaMpLe…" \
  --data-urlencode "redirect_uri=https://client.example.com/oauth/callback" \
  --data-urlencode "client_id=3fa85f64-5717-4562-b3fc-2c963f66afa6" \
  --data-urlencode "code_verifier=M2Rk8vQ1yZ7pLxNfA0bC5tHwJ9sYnE3uKoPrTgVdWiU"
{
  "token_type": "Bearer",
  "expires_in": 900,
  "access_token": "eyJhbGciOi…",
  "refresh_token": "…",
  "scope": "mcp:read mcp:write"
}

6. Call the gateway.

curl "https://pro-gateway.pearprotocol.io/positions" \
  -H "Authorization: Bearer eyJhbGciOi…" \
  -H "x-trade-account-id: $TRADE_ACCOUNT_ID"

Store the refresh token as a secret. It is the whole grant: anyone holding it can mint access tokens until the user revokes.

Skip this section if you are writing a client. A client sends the browser to /authorize and waits for its callback. Everything below is the API behind the login-and-consent page, and it exists so that page can be built. Pear hosts one already.

/authorize stores the validated request under an opaque uid and redirects to /interaction/{uid} on the consent page. The uid lives for 10 minutes and is what the four endpoints below act on.

EndpointSDKPurpose
GET /interaction/{uid}sdk.auth.getInteractionDetailsRead what the page must render.
POST /interaction/{uid}/loginsdk.auth.submitInteractionLoginSign the user in and bind them to this interaction.
POST /interaction/{uid}/confirmsdk.auth.confirmInteractionApprove, and mint the code.
POST /interaction/{uid}/abortsdk.auth.abortInteractionDeny.

Reading the interaction

{
  "uid": "Xr9tKm2QpLvNbC4hFwYs3A",
  "prompt": { "name": "login", "details": {} },
  "params": {
    "client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "redirect_uri": "https://client.example.com/oauth/callback",
    "state": "8f14e45fceea167a",
    "scope": "mcp:read mcp:write"
  },
  "client": {
    "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "clientName": "Example Agent",
    "redirectUris": ["https://client.example.com/oauth/callback"]
  }
}

prompt.name drives the page: login before anyone has signed in, consent after. params.scope is what the client asked for, and is what the consent screen offers the user.

GET is readable without a session while prompt.name is login — the page has to render the sign-in step before a session can exist. The moment a login binds an account, the interaction belongs to that account and only its own first-party session may read it. A leaked uid therefore cannot observe someone else's signed-in interaction. A uid that expired or was already finished is a 404 Interaction not found or expired.

Login, confirm, abort

POST /interaction/{uid}/login takes the same body as POST /auth/loginmethod: "wallet" with a signed nonce, or method: "email" with a password — and verifies the credential for real. The account comes from that verification and never from the request, so this step cannot bind an arbitrary account to the interaction.

It does not return a token pair. A normal login hands back accessToken and refreshToken; this one returns returnTo and nothing else, and delivers the session as Set-Cookie. Send the following requests with credentials so the cookie rides along.

confirm and abort both require the first-party session that logged in. Two checks run:

  1. The request must carry a first-party access token — in practice the session cookie the login step set, though a bearer token works. A missing or invalid one is a 401. An OAuth grant token is a 403 — see What a grant cannot do.
  2. The signed-in account must be the account bound to this interaction, or the answer is 403 Session does not match this interaction.

So a leaked uid cannot mint a code for a victim's account, and cannot cancel a victim's in-flight authorization either.

confirm takes the scopes the user actually ticked:

{ "scopes": ["mcp:read"] }

The granted set can only narrow what the client requested. Submitted scopes are intersected with the requested ones, so a consent screen can never widen a grant beyond the authorize request, and an empty or fully mismatched array yields a grant with no scopes at all rather than a wide one.

All three return { returnTo } as JSON, not a 303.

{ "returnTo": "https://client.example.com/oauth/callback?code=eXaMpLe…&state=8f14e45fceea167a" }

The reason is the browser, not the protocol: a cross-origin fetch cannot read the Location header of a redirect it receives, so a consent page on a different origin from the gateway would get an opaque response it can do nothing with. Returning the URL in the body lets the page perform the top-level navigation itself. Set window.location to returnTo and do not try to follow the response.

Each step returns a different target:

StepreturnTo points at
loginThe same interaction page. Re-read the interaction; prompt.name is now consent.
confirmYour redirect_uri, carrying code and state.
abortYour redirect_uri, carrying error=access_denied, error_description, and state.
FailureStatusMessage
confirm before any login400Interaction is not awaiting consent
A different account confirms or aborts403Session does not match this interaction
The uid expired or was already finished404Interaction not found or expired
The user is at the grant cap403You have reached the maximum of 10 connected agents. Revoke one before connecting another.

Scopes

Two scopes exist, and a client normally asks for both:

ScopeWhat it means at the gateway
mcp:readThe read half of a grant.
mcp:writeRequired for every mutating request.

The authorization server treats scopes as opaque strings. It validates a requested scope against the recognized list and assigns it no meaning. Each resource server interprets them, so what a scope grants depends on what you are calling: the gateway and the Orchard MCP apply the same two names to their own surfaces.

At the gateway the rule is one line: GET, HEAD, and OPTIONS pass for any grant; every other method needs mcp:write, or the request is 403 OAuth grant does not have write access. This is the same boundary a read API key sits behind.

Consent decides the set once, and it never grows. A refresh re-mints exactly the scopes persisted on the grant, so a grant that was consented read-only stays read-only for its whole life. To widen it, the user runs the flow again and approves the wider set.

Limits

A user may hold 10 active grants. Active means not revoked and not expired. The cap is checked at consent, so the eleventh approval is refused with a 403 and no code is issued. The user frees a slot by revoking a connected agent.

A grant expires when its refresh window closes. That window slides forward on every refresh, so a client that refreshes stays connected and one that goes quiet eventually drops off the user's list on its own.

What a grant cannot do

An OAuth grant token is not a session. Three places refuse it outright:

SurfaceWhat happens
Account and session routes under /auth403 OAuth grant tokens cannot access first-party account endpoints
GET and DELETE /oauth2/grantsThe same 403
The realtime WebSocketThe handshake succeeds but the connection stays anonymous. Public channels work; the private user-event stream is never delivered.

The second one is the important one. An agent cannot list or revoke its own grant, because grant management refuses grant tokens by design. Only the user, from a first-party sign-in, can revoke. An agent that wants to hand its own access back uses POST /revoke with its refresh token instead.

One capability points the other way, and is deliberate:

A grant with mcp:write can read decrypted venue credentials. POST /trade-accounts/{id}/credentials returns the connected exchange account's own keys, and a delegated agent may call it. Some venue endpoints — funding payment history among them — will not answer without the account's own credentials, so an agent that cannot read them cannot do the job the user connected it for.

What narrows this is the method. The route is a POST, so the write-scope rule applies and a read-only grant is turned away. Treat write scope as equivalent to full control of the connected exchange account, and ask for it only when your integration trades.

Reviewing and revoking grants

This is the user's side, and it needs a first-party sign-in.

EndpointSDKPurpose
GET /oauth2/grantssdk.auth.listOauthGrantsList the clients this user has authorized.
DELETE /oauth2/grants/{id}sdk.auth.revokeOauthGrantRevoke one grant. Returns 204.
{
  "grants": [
    {
      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "name": "Example Agent",
      "scopes": ["mcp:read", "mcp:write"],
      "connectedAt": "2026-01-02T10:00:00.000Z"
    }
  ]
}

Only active grants are listed, newest first. name is the client_name from registration, falling back to the client id when the client registered without one. connectedAt is when consent happened, and it survives every refresh, so it always reads as the date the user connected the agent.

id is the grant, not the client. A user who connected the same agent twice sees two rows and revokes them independently.

Revocation takes effect on the agent's next request, not when its access token expires. A grant id that is unknown, already revoked, or belongs to another user all answer 404 OAuth grant not found — the same answer either way, so the endpoint never confirms that another user holds an id.

On this page