Authorization code flow
The full round trip: send the user to Circa, get a code back, exchange it for tokens on your server, then call the API.
Circa implements the standard OAuth 2.0 authorization code grant, with one Circa-specific twist: the user picks which teams to grant, and every subsequent API call must name one of them with an x-team-id header. The four steps below are the whole integration.
1. redirect → https://app.circa.ai/oauth/authorize?client_id=…
2. consent → user picks teams, approves
3. callback → https://your.app/callback?code=circa_code_…&state=…
4. exchange → POST https://api.circa.ai/public/oauth/token
5. call → Authorization: Bearer circa_oat_… + x-team-id: …Step 1 — Redirect the user to Circa
Build an authorize URL and send the user's browser to it (a full navigation or a popup — not an iframe, and not a server-side fetch).
https://app.circa.ai/oauth/authorize
?client_id=circa_cid_Vb3nT7yQ0sKp2mLxD9fA6cHu
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fintegrations%2Fcirca%2Fcallback
&response_type=code
&scope=profile%3Aread%20teams%3Aread%20materials%3Aread
&state=f8Kd2p9QzR1aQuery parameters
| Parameter | Type | Description |
|---|---|---|
| client_idrequired | string | Your app's public identifier, circa_cid_…, from the Developer portal. |
| redirect_urirequired | url | Must be an exact match for one of the URIs registered on the app, URL-encoded. Circa validates this (together with client_id) before showing the user anything, so a mismatch renders an error page rather than redirecting. |
| response_type | string | Always code. It defaults to code when omitted and any other value is rejected — Circa issues opaque tokens from the token endpoint only, so there is no implicit flow. Send it anyway; it costs nothing and it is what every OAuth client library expects. |
| scoperequired | string | Space-delimited list of scopes, e.g. materials:read processes:read. Must be a subset of the scopes registered on the app, and must resolve to at least one scope — an empty or unknown scope list is rejected with invalid_scope. Request the minimum you need — every extra line on the consent screen costs you approvals. |
| state | string | An unguessable value you generate per attempt and store against the user's session. Circa returns it untouched, on both success and denial. The protocol does not force you to send one and Circa does not reject a request without it — send it anyway, and verify it before doing anything with the response. |
| code_challenge | string | PKCE. Base64url of the SHA-256 hash of a random code_verifier you keep server-side. Recommended for every client, required in practice for any client that cannot keep a secret. |
| code_challenge_method | string | S256 or plain; anything else is rejected. It defaults to plain when you send a code_challenge without it, so always send S256 explicitly. Sending this parameter without a code_challenge is an invalid_request. |
stateis your CSRF defence. Generate a fresh random value for every authorization attempt, store it in the user's session (or in a signed, short-lived cookie), and on the callback compare it to what came back beforeyou exchange the code. If it is missing or does not match, abandon the request — someone is trying to graft their Circa account onto your user's session. Never reuse a state value.Adding PKCE
PKCE binds the authorization code to the client that started the flow, so a stolen code is useless on its own. Generate a random code_verifier, derive the challenge, send the challenge in step 1, and send the verifier in step 3.
PKCE is opt-in: Circa requires a code_verifier at the token endpoint only when the authorization request carried a code_challenge. If you did not send one, omitting the verifier is fine — but then the code is protected by your client secret alone. Both S256 and plain are accepted; plain exists for constrained clients and offers no protection against a challenge that leaks alongside the code, so use S256.
import crypto from "node:crypto";
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
// Persist { state, codeVerifier } against this user's session.
const url = new URL("https://app.circa.ai/oauth/authorize");
url.searchParams.set("client_id", process.env.CIRCA_CLIENT_ID);
url.searchParams.set("redirect_uri", process.env.CIRCA_REDIRECT_URI);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "profile:read teams:read materials:read");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
res.redirect(url.toString());Step 2 — The user consents and picks teams
Circa shows a consent screen with your app's logo and name, the list of scopes you asked for in plain language, and a selector listing every team the user is an active member of. They tick the teams you may access. Credit-spending scopes (materials:pull, processes:pull) are called out separately, because approving them lets you debit the selected teams' credit balances.
If the user is logged out, they are sent through sign-in first and returned to the consent screen. If your app has not been reviewed, the screen carries an unverified warning.
On approval
Circa redirects the browser to your registered redirect_uri with a single-use code and your state:
https://app.example.com/integrations/circa/callback
?code=circa_code_7yQ0sKp2mLxD9fA6cHu8Zw5nT3vJb1R4
&state=f8Kd2p9QzR1aOn denial
If the user clicks cancel you get an error back on the same redirect URI, still carrying your state. Treat it as a normal outcome, not a failure — show the user a way to try again.
https://app.example.com/integrations/circa/callback
?error=access_denied
&error_description=The%20user%20denied%20the%20authorization%20request.
&state=f8Kd2p9QzR1aStep 3 — Exchange the code for tokens
/public/oauth/tokenThis call happens server to server, never from the browser. The body is application/x-www-form-urlencoded; application/json is accepted too, with the same field names.
| Parameter | Type | Description |
|---|---|---|
| grant_typerequired | string | authorization_code here. See Refreshing tokens for refresh_token. |
| coderequired | string | The circa_code_… value from step 2. |
| redirect_urirequired | url | The same redirect URI you used in step 1, character for character. |
| client_idrequired | string | Your circa_cid_…. May be omitted from the body if you authenticate with HTTP Basic, which carries it in the header. |
| client_secret | string | Your circa_cs_…. Required unless you authenticate with HTTP Basic instead. |
| code_verifier | string | Required if you sent a code_challenge in step 1 — the original random string, not the hash. |
Two ways to authenticate your app
Pick one. HTTP Basic keeps the secret out of the request body (and out of anything that logs bodies), so prefer it if your HTTP client makes it easy; the body form is simpler to hand-roll.
curl -s -X POST 'https://api.circa.ai/public/oauth/token' \
-u "$CIRCA_CLIENT_ID:$CIRCA_CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=circa_code_7yQ0sKp2mLxD9fA6cHu8Zw5nT3vJb1R4' \
-d 'redirect_uri=https://app.example.com/integrations/circa/callback' \
-d 'code_verifier=YOUR_CODE_VERIFIER'Success response
{
"access_token": "circa_oat_9nQ2vJ0sYb4pR1tK7mLxD3fA6cHu8Zw5",
"token_type": "Bearer",
"expires_in": 28800,
"refresh_token": "circa_ort_3fA6cHu8Zw5nQ2vJ0sYb4pR1tK7mLxD",
"scope": "profile:read teams:read materials:read"
}expires_inis seconds —28800is 8 hours. Store an absolute expiry, not a countdown.scopeis what you actually got. Read it rather than assuming you received everything you asked for.refresh_tokenis valid for 60 days and rotates on every use — see Refreshing tokens.- Both tokens are opaque and stored hashed. Do not try to parse them; the only thing the value tells you is which kind it is, from its prefix.
Error responses
Every failure at the token endpoint comes back in the standard OAuth shape — a JSON body with an error code and a human-readable error_description, never Circa's usual { statusCode, message } envelope. Branch on error; the description is for your logs, not your control flow.
The status code is 401 for invalid_client (with a WWW-Authenticate: Basic realm="circa" header), 500 for server_error, and 400 for everything else.
{
"error": "invalid_grant",
"error_description": "The authorization code has expired."
}{
"error": "invalid_client",
"error_description": "Client authentication failed."
}| error | Status | What it means |
|---|---|---|
| invalid_request | 400 | A required parameter is missing: code, redirect_uri, refresh_token, or the code_verifier for a flow that sent a code_challenge. |
| invalid_client | 401 | No credentials were sent, the client_id is unknown, the secret does not match, or the app has been suspended. Check you deployed the secret after rotating it. |
| invalid_grant | 400 | The code or refresh token is invalid, expired, already consumed, issued to a different client, paired with a different redirect_uri, or the PKCE code_verifier does not match. Also returned once the underlying authorization has been revoked. |
| unsupported_grant_type | 400 | Only authorization_code and refresh_token exist. client_credentials is not supported. |
| server_error | 500 | Something broke on Circa's side. Retry with backoff. |
Failures before the consent screen
Step 1 is a browser navigation, so its failures never reach your server directly — the consent page renders an error instead of redirecting, which is deliberate: an unknown client_id or an unregistered redirect_urimust not be turned into an open redirect. The page's backing request answers in Circa's Nest-style shape, { error, message } — note message, not error_description; the OAuth2 body is used at the token endpoint only.
{
"error": "invalid_scope",
"statusCode": 400,
"message": "This application is not registered for scope(s): materials:pull."
}| error | Status | What it means |
|---|---|---|
| invalid_client | 404 | Unknown client_id. |
| unauthorized_client | 400 | The application has been suspended. |
| invalid_request | 400 | redirect_uri does not match a registered value, response_type is not code, or code_challenge_method was sent without a code_challenge. |
| invalid_scope | 400 | A requested scope is unknown, the list is empty, or it is outside the set registered on the app. |
| access_denied | — | The user declined. Delivered as query parameters on your redirect URI, not as a response body. |
Step 4 — Call the API
Send the access token as a bearer token, and name the team you are acting on:
curl -s 'https://api.circa.ai/public/materials?search=polyethylene&limit=10' \
-H 'Authorization: Bearer circa_oat_9nQ2vJ0sYb4pR1tK7mLxD3fA6cHu8Zw5' \
-H 'x-team-id: 4b6f9c28-6f4e-4e7f-a36d-2ff989045d10' \
-H 'Circa-Version: 2026-05-11.arrakis'x-team-id is a 400; naming a team the user did not grant — or one they are no longer an active member of — is a 403, even though the token itself is perfectly valid. Call GET /public/teams with teams:read to discover the ids the current token is good for, and store the team id next to the token in your database.Errors on an OAuth-authenticated call
These are ordinary public-API responses, not OAuth protocol responses, so they use the normal { statusCode, message } envelope — with an error discriminator on the two that have one.
{
"statusCode": 400,
"message": "Missing required 'x-team-id' header. Pass the id of a team this authorization covers."
}{
"statusCode": 403,
"message": "'Acme LCA' was not granted access to team '4b6f9c28-6f4e-4e7f-a36d-2ff989045d10'."
}{
"statusCode": 403,
"message": "The user who authorized 'Acme LCA' is no longer a member of team '4b6f9c28-6f4e-4e7f-a36d-2ff989045d10'."
}{
"error": "invalid_token",
"statusCode": 401,
"message": "The access token has expired."
}The 401 message tells you which case you hit — the access token is invalid, has expired, or has been revoked; the authorization behind it has been revoked; or the application has been suspended. Missing a scope is a 403 with error: "insufficient_scope", described under Scopes.
What to persist
Per authorized user, your database wants: the access token and its absolute expiry, the newest refresh token, the granted scope string, and the team ids you discovered. Refresh ahead of expiry rather than waiting for a 401, and re-read the team list periodically — the set can shrink underneath you without any error being reported until you call.