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 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.
Field
Type
Required
Meaning
name
string
yes
1-80 characters. Shown to users on the consent screen.
redirect_uris
string[]
yes
1-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.
scopes
string[]
yes
At least one of read, orders, triggers: the most a user can grant your app. Ask for less at authorize time when you can.
logo_url
string
no
https:// 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."
}
Route
Who
What
GET /v1/partner-apps
app owner
Your apps, disabled ones included.
POST /v1/partner-apps
app owner
Register an app. 201 with client_secret, shown once.
POST /v1/partner-apps/{client_id}/rotate-secret
app owner
New client_secret, shown once. The old secret keeps working for 24 hours (previous_secret_expires_at).
DELETE /v1/partner-apps/{client_id}
app owner
Disable the app for good; 404 unknown_app when it is not an active app of yours.
GET /v1/connections
user
Apps connected to your account: client_id, app_name, verified, scopes, connected_at, updated_at.
DELETE /v1/connections/{client_id}
user
Disconnect an app; 404 connection_not_found when there is no live connection.
GET /v1/oauth/consent · POST /v1/oauth/authorize
consent screen
Used by the consent screen itself; partners never call them.
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_… |
| |-------------------------------------------------->|
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.
Redirect the browser to the consent screen with the parameters below and code_challenge = BASE64URL(SHA256(code_verifier)), no padding.
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.
On Allow the browser returns to your redirect_uri with code and your state. Check state against the session before anything else.
Within 60 seconds, exchange the code on your server for tokens at POST /v1/oauth/token, sending the verifier and your client credentials.
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.
GET /oauth/authorize in the user’s browser, with these query parameters:
Field
Type
Required
Meaning
client_id
string
yes
Your app’s cp_… id. Unknown or disabled apps are refused on the screen.
redirect_uri
string
yes
One of the app’s registered URIs, character for character. Anything else is refused on the screen and never redirected to.
scope
string
yes
Space 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_challenge
string
yes
BASE64URL(SHA256(code_verifier)) without = padding: exactly 43 characters of A-Z a-z 0-9 - _.
code_challenge_method
S256
yes
Only S256. plain is refused.
state
string
no
Returned 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_type
code
no
Not needed: the code flow is the only one. OAuth libraries that send response_type=code work.
import crypto from 'node:crypto';
exportconst CLIENT_ID = 'cp_xxxx';
exportconst REDIRECT_URI = 'https://partner.example/callback';
/** Returns the consent URL; keeps verifier and state in the session. */
export function startConnect(session) {
const verifier = crypto.randomBytes(48).toString('base64url'); // 64 charsconst challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
session.pkceVerifier = verifier;
session.oauthState = crypto.randomBytes(24).toString('base64url');
const url = new URL('/oauth/authorize');
url.search = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'read orders',
state: session.oauthState,
code_challenge: challenge,
code_challenge_method: 'S256'
}).toString();
return url.toString();
}
Scope
Consent screen
Allows
read
Portfolio, orders, triggers, GTT and the live stream
GET /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
orders
Place, modify, cancel and convert orders and GTT
POST / PATCH / DELETE /v1/orders and /v1/orders/bracket, POST / PATCH / DELETE /v1/gtt, POST /v1/positions/convert
triggers
Create, change and remove stop-loss / target triggers
POST / 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.
oc_…, 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.
state
Allow, Deny
Your value, when you sent one.
error=access_denied
Deny
The user refused. It is the only error sent to your redirect_uri, without error_description.
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.
Field
Type
Required
Meaning
grant_type
authorization_code
yes
Exchange a code.
code
string
yes
The oc_… code from the callback. Used up by the first attempt, even a failed one.
redirect_uri
string
yes
Exactly the redirect_uri of the authorize request.
code_verifier
string
yes
The verifier behind code_challenge.
client_id
string
no
When not using HTTP Basic.
client_secret
string
no
When 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>
import os, secrets
import requests
TOKEN_URL = "https://cirrus.pocketful.in/v1/oauth/token"
AUTH = (os.environ["PARTNER_CLIENT_ID"], os.environ["PARTNER_CLIENT_SECRET"])
def finish_connect(session: dict, query: dict) -> dict:
"""Handles GET https://partner.example/callback?code=…&state=…"""
expected = session.pop("oauth_state", None)
if not expected or not secrets.compare_digest(query.get("state", ""), expected):
raise PermissionError("state does not match: start again")
if "error" in query: # access_denied: the user said no
raise PermissionError(query["error"])
reply = requests.post(TOKEN_URL, auth=AUTH, timeout=10, data={
"grant_type": "authorization_code",
"code": query["code"],
"redirect_uri": "https://partner.example/callback",
"code_verifier": session.pop("pkce_verifier"),
})
body = reply.json()
if reply.status_code != 200:
raise RuntimeError(f"{body['error']}: {body['error_description']}")
return body # store refresh_token encrypted, per user
import crypto from 'node:crypto';
exportconst TOKEN_URL = 'https://cirrus.pocketful.in/v1/oauth/token';
exportconst basic = Buffer.from(
`${process.env.PARTNER_CLIENT_ID}:${process.env.PARTNER_CLIENT_SECRET}`
).toString('base64');
/** Handles GET https://partner.example/callback?code=…&state=… */
export async function finishConnect(session, query) {
const expected = session.oauthState ?? '';
delete session.oauthState;
const given = String(query.state ?? '');
if (!expected || given.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
throw new Error('state does not match: start again');
}
if (query.error) throw new Error(query.error); // access_deniedconst reply = await fetch(TOKEN_URL, {
method: 'POST',
headers: { Authorization: `Basic ${basic}` },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: query.code,
redirect_uri: 'https://partner.example/callback',
code_verifier: session.pkceVerifier
})
});
delete session.pkceVerifier;
const body = await reply.json();
if (!reply.ok) throw new Error(`${body.error}: ${body.error_description}`);
return body; // store refresh_token encrypted, per user
}
Field
Type
Required
Meaning
access_token
string
yes
pa_… bearer token for the API.
token_type
"Bearer"
yes
Always Bearer.
expires_in
integer
yes
Seconds the access token is valid: 86400 (1 day).
refresh_token
string
yes
pr_…, valid 90 days from issue, single use. Every reply carries a new one.
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.
def refresh(refresh_token: str) -> dict:
"""Save the new refresh token first: reuse after 30 s disconnects the user."""
reply = requests.post("https://cirrus.pocketful.in/v1/oauth/token", auth=AUTH, timeout=10, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
})
body = reply.json()
if reply.status_code == 400 and body["error"] == "invalid_grant":
raise PermissionError("connection ended: ask the user to connect again")
reply.raise_for_status()
return body # save body["refresh_token"] before using the new access token
/** Save the new refresh token first: reuse after 30 s disconnects the user. */
export async function refresh(refreshToken) {
const reply = await fetch('https://cirrus.pocketful.in/v1/oauth/token', {
method: 'POST',
headers: { Authorization: `Basic ${basic}` },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken })
});
const body = await reply.json();
if (reply.status === 400 && body.error === 'invalid_grant') {
throw new Error('connection ended: ask the user to connect again');
}
if (!reply.ok) throw new Error(`${body.error}: ${body.error_description}`);
return body; // save body.refresh_token before using the new access token
}
Unreadable body, or code, redirect_uri, code_verifier or refresh_token missing.
unsupported_grant_type
400
grant_type is neither authorization_code nor refresh_token.
invalid_client
401
No client credentials, unknown or disabled app, or wrong secret. With HTTP Basic the reply carries a WWW-Authenticate: Basic header.
invalid_grant
400
Code 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_unavailable
503
A 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"
}
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.
Users see every connected app on API access (or GET /v1/connections) and can disconnect any of them at any time.
Event
Tokens
Then
User disconnects
all dead
On 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 s
all dead
Same as a disconnect.
Password change or log out everywhere
all dead
Every app the user connected, as if each were disconnected. The user connects again.
App disabled
all dead
For every user of the app, within about 5 s; the token endpoint answers invalid_client.
Access token expires
that one
After 1 day. Refresh.
Refresh token expires
no new ones
90 days after it was issued if never used. Connect again.
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.