Skip to content
Cirrus home
Sign in

Guide

OAuth for partner apps

Build an app other Cirrus users connect to their accounts: authorization code with PKCE, tokens, refresh and revocation.

Build an app that other Cirrus users connect to their own accounts. Each user approves your app once; you get tokens that act for that user within the scopes they granted, and never see their password or API keys.

  • For a product serving many users (a trading tool, journal, analytics or copy-trading service): each user connects their own account through a consent screen.
  • For your own scripts and bots, use an API key instead (see Authentication): no redirect, no consent screen, no token refresh.
  • The only grant is authorization code with PKCE (RFC 6749 + RFC 7636, S256). There is no client credentials, implicit or password grant: every token acts for one user who approved it.

Register your app #

  • Register on API access in the app (signed in, Partner apps), or with POST /v1/partner-apps from your own signed-in app session. API keys and partner tokens cannot manage apps (403); an impersonated session cannot register, rotate, disable or disconnect.
  • The reply carries client_id (cp_ + 20 characters) and client_secret (cps_ + 48 characters). The secret is shown once and stored only as a keyed hash: store it in your server’s secret store right away. Lost or leaked? Rotate it (below): the new secret is shown once and the old one keeps working for 24 hours.
  • At most 10 active apps per user. Apps cannot be edited: to change the name, redirect URIs or scopes, register a new app (and disable the old one).
  • New apps are not verified. Verification is set by the platform operator only; the consent screen shows the app as Verified or Not verified, and warns users about unverified apps.
  • Rotate the secret with Rotate secret on API access or POST /v1/partner-apps/{client_id}/rotate-secret. For 24 hours both secrets authenticate, so update every server before the old one stops; connected users are not affected.
  • Disabling (DELETE /v1/partner-apps/{client_id}, or Disable on API access) is permanent: every token of every user of the app stops working within about 5 seconds, and the token endpoint answers invalid_client.
FieldTypeRequiredMeaning
namestringyes1-80 characters. Shown to users on the consent screen.
redirect_urisstring[]yes1-10 URIs, matched exactly (no prefixes, no wildcards). https://, or http://localhost / http://127.0.0.1 (any port) for development. No #fragment, no whitespace, at most 512 characters each.
scopesstring[]yesAt least one of read, orders, triggers: the most a user can grant your app. Ask for less at authorize time when you can.
logo_urlstringnohttps:// image shown on the consent screen.
{
  "client_id": "cp_8fK2mQ7xL0aZ4tR9wY1b",
  "owner": "you@example.com",
  "name": "Trade Journal",
  "verified": false,
  "logo_url": null,
  "redirect_uris": ["https://partner.example/callback"],
  "scopes": ["read", "orders"],
  "created_at": "2026-09-28T09:00:00Z",
  "disabled": false,
  "client_secret": "cps_…",
  "note": "Store the client secret now: it is not shown again."
}
RouteWhoWhat
GET /v1/partner-appsapp ownerYour apps, disabled ones included.
POST /v1/partner-appsapp ownerRegister an app. 201 with client_secret, shown once.
POST /v1/partner-apps/{client_id}/rotate-secretapp ownerNew client_secret, shown once. The old secret keeps working for 24 hours (previous_secret_expires_at).
DELETE /v1/partner-apps/{client_id}app ownerDisable the app for good; 404 unknown_app when it is not an active app of yours.
GET /v1/connectionsuserApps connected to your account: client_id, app_name, verified, scopes, connected_at, updated_at.
DELETE /v1/connections/{client_id}userDisconnect an app; 404 connection_not_found when there is no live connection.
GET /v1/oauth/consent · POST /v1/oauth/authorizeconsent screenUsed by the consent screen itself; partners never call them.

The flow #

User's browser         Your server              Consent screen             API
      |                      |                        |                          |
      | 1. "Connect"         |                        |                          |
      |--------------------->|                        |                          |
      |                      | make verifier, state;  |                          |
      |                      | keep them server-side  |                          |
      | 2. 302 to /oauth/authorize?client_id&redirect_uri&scope&state&code_challenge
      |<---------------------|                        |                          |
      | 3. sign in, review the consent screen, Allow  |                          |
      |---------------------------------------------->| POST /v1/oauth/authorize |
      |                      |                        |------------------------->|
      | 4. 302 to redirect_uri?code=oc_…&state=…      |<-------------------------|
      |<----------------------------------------------|                          |
      |--------------------->| 5. POST /v1/oauth/token (code, code_verifier,     |
      |                      |    client_id + client_secret)                     |
      |                      |-------------------------------------------------->|
      |                      | 6. access_token pa_… (1 day), refresh_token pr_…  |
      |                      |<--------------------------------------------------|
      |                      | 7. API calls: Authorization: Bearer pa_…          |
      |                      |-------------------------------------------------->|
  1. Create a PKCE code_verifier (43-128 characters from A-Z a-z 0-9 - . _ ~) and a random state. Keep both in the user’s server-side session.
  2. Redirect the browser to the consent screen with the parameters below and code_challenge = BASE64URL(SHA256(code_verifier)), no padding.
  3. The user signs in if needed (they come back to the consent screen afterwards), sees your app’s name, logo, verified badge and the scopes you asked for, and chooses Allow or Deny.
  4. On Allow the browser returns to your redirect_uri with code and your state. Check state against the session before anything else.
  5. Within 60 seconds, exchange the code on your server for tokens at POST /v1/oauth/token, sending the verifier and your client credentials.
  6. Call the API with the access token. Refresh it before or when it expires (1 day); ask the user to connect again if the refresh is refused.

Send the user to the consent screen #

GET /oauth/authorize in the user’s browser, with these query parameters:

FieldTypeRequiredMeaning
client_idstringyesYour app’s cp_… id. Unknown or disabled apps are refused on the screen.
redirect_uristringyesOne of the app’s registered URIs, character for character. Anything else is refused on the screen and never redirected to.
scopestringyesSpace separated (read orders), lower case. Each must be one the app registered; unknown scopes and scopes the app may not request are refused (invalid_scope). Repeats are ignored.
code_challengestringyesBASE64URL(SHA256(code_verifier)) without = padding: exactly 43 characters of A-Z a-z 0-9 - _.
code_challenge_methodS256yesOnly S256. plain is refused.
statestringnoReturned unchanged (percent-encoded) with the code or the denial. Optional for the server, but always send a fresh random value and check it: it is your CSRF protection.
response_typecodenoNot needed: the code flow is the only one. OAuth libraries that send response_type=code work.
/oauth/authorize?client_id=cp_xxxx&redirect_uri=https%3A%2F%2Fpartner.example%2Fcallback&scope=read%20orders&state=<random>&code_challenge=<BASE64URL(SHA256(verifier))>&code_challenge_method=S256
import base64, hashlib, secrets
from urllib.parse import urlencode

CLIENT_ID = "cp_xxxx"
REDIRECT_URI = "https://partner.example/callback"

def start_connect(session: dict) -> str:
    """Returns the consent URL; keeps verifier and state in the session."""
    verifier = secrets.token_urlsafe(64)  # 86 chars from A-Z a-z 0-9 - _
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    session["pkce_verifier"] = verifier
    session["oauth_state"] = secrets.token_urlsafe(32)
    return "/oauth/authorize?" + urlencode({
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "read orders",
        "state": session["oauth_state"],
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })
ScopeConsent screenAllows
readPortfolio, orders, triggers, GTT and the live streamGET /v1/accounts, GET /v1/instruments (and .csv), GET /v1/portfolio/*, POST /v1/portfolio/refresh, /v1/stream, GET /v1/gtt, GET /v1/triggers, POST /v1/margins/orders
ordersPlace, modify, cancel and convert orders and GTTPOST / PATCH / DELETE /v1/orders and /v1/orders/bracket, POST / PATCH / DELETE /v1/gtt, POST /v1/positions/convert
triggersCreate, change and remove stop-loss / target triggersPOST / PATCH / DELETE /v1/triggers
  • Problems with the request (missing parameter, unknown or disabled app, unregistered redirect_uri, bad scope, bad PKCE) are shown to the user on the consent screen. They are never sent to your redirect_uri, because it is not trusted until it matched.
  • Scopes do not imply each other: ask for read as well as orders if you need to see what you placed.
  • Connecting again (same user, same app) replaces the connection’s scopes with the new ones once the new code is exchanged. Tokens issued before keep the scopes they were issued with.

Handle the callback #

QueryWhenMeaning
codeAllowoc_…, single use, valid 60 s, bound to your client_id, this redirect_uri, the scopes and the challenge. Added with & when your URI already has a query.
stateAllow, DenyYour value, when you sent one.
error=access_deniedDenyThe user refused. It is the only error sent to your redirect_uri, without error_description.
https://partner.example/callback?code=oc_xxxx&state=<your state>
https://partner.example/callback?error=access_denied&state=<your state>

Exchange the code for tokens #

  • POST /v1/oauth/token on the API host, from your server only. Body form-encoded (application/x-www-form-urlencoded, as RFC 6749 says) or JSON (application/json).
  • Client authentication: HTTP Basic (Authorization: Basic base64(client_id:client_secret)), or client_id and client_secret in the body. Basic wins when both are sent.
  • Replies are plain RFC 6749 JSON, not the usual status / data / error envelope, with Cache-Control: no-store.
  • grant_type is authorization_code or refresh_token; anything else is unsupported_grant_type.
  • Too many failed attempts from one IP (wrong secrets, bad codes): 429 with a Retry-After header, in seconds. Wait that long before trying again.
FieldTypeRequiredMeaning
grant_typeauthorization_codeyesExchange a code.
codestringyesThe oc_… code from the callback. Used up by the first attempt, even a failed one.
redirect_uristringyesExactly the redirect_uri of the authorize request.
code_verifierstringyesThe verifier behind code_challenge.
client_idstringnoWhen not using HTTP Basic.
client_secretstringnoWhen not using HTTP Basic.
curl -X POST https://cirrus.pocketful.in/v1/oauth/token \
  -u "$PARTNER_CLIENT_ID:$PARTNER_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=oc_xxxx \
  --data-urlencode redirect_uri=https://partner.example/callback \
  -d code_verifier=<verifier from the session>
FieldTypeRequiredMeaning
access_tokenstringyespa_… bearer token for the API.
token_type"Bearer"yesAlways Bearer.
expires_inintegeryesSeconds the access token is valid: 86400 (1 day).
refresh_tokenstringyespr_…, valid 90 days from issue, single use. Every reply carries a new one.
scopestringyesGranted scopes, space separated (read orders).
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "pa_…",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "pr_…",
  "scope": "read orders"
}
CredentialLifetimeNotes
oc_… code60 sSingle use; the first exchange attempt uses it up.
pa_… access token1 dayStays valid after a refresh until it expires, unless the connection is revoked.
pr_… refresh token90 daysSingle use (a repeat within 30 s gets the same reply); each refresh returns a new 90-day one, so an app in use stays connected.

Refresh tokens #

  • Refresh tokens rotate: store the new refresh_token from every reply before using the new access token.
  • Grace window: presenting the same refresh token again within 30 seconds of its first use (a retry after a lost reply, or two workers at once) returns the same new token pair, not an error.
  • Reusing a refresh token later than that revokes the whole connection (all its tokens): it is treated as stolen.
  • A refused refresh (invalid_grant: expired, used, or the user disconnected) means the user has to connect again through the consent screen.
FieldTypeRequiredMeaning
grant_typerefresh_tokenyesGet a new token pair.
refresh_tokenstringyesThe latest pr_… you received.
client_idstringnoWhen not using HTTP Basic.
client_secretstringnoWhen not using HTTP Basic.
curl -X POST https://cirrus.pocketful.in/v1/oauth/token \
  -u "$PARTNER_CLIENT_ID:$PARTNER_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=pr_xxxx

Token errors #

errorHTTPWhen
invalid_request400Unreadable body, or code, redirect_uri, code_verifier or refresh_token missing.
unsupported_grant_type400grant_type is neither authorization_code nor refresh_token.
invalid_client401No client credentials, unknown or disabled app, or wrong secret. With HTTP Basic the reply carries a WWW-Authenticate: Basic header.
invalid_grant400Code invalid, expired or already used; code issued to another app or redirect_uri; code_verifier does not match; refresh token invalid, reused after the 30 s grace (the connection is then revoked) or expired; the user disconnected the app.
temporarily_unavailable503A store is briefly down, or partner access is off on this server. Nothing was issued; retry.
HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store

{
  "error": "invalid_grant",
  "error_description": "code_verifier does not match"
}

Call the API #

  • Send Authorization: Bearer pa_… on every request. The stream accepts the same token (header on the upgrade, or {"type": "auth", "token": "pa_…"}).
  • A partner token reaches exactly what its scopes allow (see Scopes) for the one user who connected it. App-session routes (API keys, partner apps, connections, webhooks, signal URLs, shared data) answer 403 forbidden. GET /v1/user/profile works with any scope and shows auth.kind: "partner", your client_id and app name.
  • Rate limit: 20 requests a second per app and user by default, counted separately for each connected user; over it, 429 rate_limited with the wait in the message. Order actions also share the per-broker-account budget.
  • An expired, revoked or unknown token answers 401 unauthorized. Repeated failures from one IP get 429 for a minute.
curl "https://cirrus.pocketful.in/v1/portfolio/positions" \
  -H "Authorization: Bearer pa_xxxx"

Connections and revocation #

Users see every connected app on API access (or GET /v1/connections) and can disconnect any of them at any time.

EventTokensThen
User disconnectsall deadOn API access or DELETE /v1/connections/{client_id}: every access token stops within about 5 s (open streams close within 10 s) and refresh tokens are deleted. Send the user through consent again.
Refresh token reused after 30 sall deadSame as a disconnect.
Password change or log out everywhereall deadEvery app the user connected, as if each were disconnected. The user connects again.
App disabledall deadFor every user of the app, within about 5 s; the token endpoint answers invalid_client.
Access token expiresthat oneAfter 1 day. Refresh.
Refresh token expiresno new ones90 days after it was issued if never used. Connect again.

Security checklist #

  • Keep client_secret, access and refresh tokens on your server (secret store, encrypted at rest). Never ship the secret in a browser or mobile app, never log tokens.
  • Always send a fresh random state and compare it (constant time) before exchanging the code; drop the callback when it does not match.
  • PKCE is mandatory: make a new verifier per authorization, keep it server-side, send it only to the token endpoint.
  • Register exact redirect URIs over HTTPS; keep http://localhost ones in a separate development app.
  • Ask for the fewest scopes; the user sees them. Place orders only on the user’s explicit instruction.
  • Save the new refresh token from every reply; only a repeat within 30 seconds is forgiven, later reuse disconnects the user.
  • Rotate the client secret if it may have leaked, and remove the old one from your servers within the 24-hour overlap.

In the reference