Need to manage API keys, view logs, or check usage?Open the Developer portal →

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=f8Kd2p9QzR1a

Query parameters

ParameterTypeDescription
client_idrequiredstringYour app's public identifier, circa_cid_…, from the Developer portal.
redirect_urirequiredurlMust 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_typestringAlways 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.
scoperequiredstringSpace-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.
statestringAn 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_challengestringPKCE. 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_methodstringS256 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.
⚠
Always send state, always verify it
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=f8Kd2p9QzR1a

On 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=f8Kd2p9QzR1a
⚠
Codes are single-use and expire in 10 minutes
Exchange the code as soon as your callback runs. Replaying a code that has already been consumed is treated as theft: the entire grant is revoked, taking every access and refresh token issued from it with it, and the user has to authorize you again. Make sure a double-submitted callback, a retry wrapper or a browser prefetch cannot exchange the same code twice.

Step 3 — Exchange the code for tokens

POST/public/oauth/token

This 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.

ParameterTypeDescription
grant_typerequiredstringauthorization_code here. See Refreshing tokens for refresh_token.
coderequiredstringThe circa_code_… value from step 2.
redirect_urirequiredurlThe same redirect URI you used in step 1, character for character.
client_idrequiredstringYour circa_cid_…. May be omitted from the body if you authenticate with HTTP Basic, which carries it in the header.
client_secretstringYour circa_cs_…. Required unless you authenticate with HTTP Basic instead.
code_verifierstringRequired 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

200OK
{
  "access_token": "circa_oat_9nQ2vJ0sYb4pR1tK7mLxD3fA6cHu8Zw5",
  "token_type": "Bearer",
  "expires_in": 28800,
  "refresh_token": "circa_ort_3fA6cHu8Zw5nQ2vJ0sYb4pR1tK7mLxD",
  "scope": "profile:read teams:read materials:read"
}
  • expires_in is seconds — 28800 is 8 hours. Store an absolute expiry, not a countdown.
  • scope is what you actually got. Read it rather than assuming you received everything you asked for.
  • refresh_token is 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.

400Bad Request
{
  "error": "invalid_grant",
  "error_description": "The authorization code has expired."
}
401Unauthorized
{
  "error": "invalid_client",
  "error_description": "Client authentication failed."
}
errorStatusWhat it means
invalid_request400A required parameter is missing: code, redirect_uri, refresh_token, or the code_verifier for a flow that sent a code_challenge.
invalid_client401No 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_grant400The 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_type400Only authorization_code and refresh_token exist. client_credentials is not supported.
server_error500Something 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.

400Bad Request
{
  "error": "invalid_scope",
  "statusCode": 400,
  "message": "This application is not registered for scope(s): materials:pull."
}
errorStatusWhat it means
invalid_client404Unknown client_id.
unauthorized_client400The application has been suspended.
invalid_request400redirect_uri does not match a registered value, response_type is not code, or code_challenge_method was sent without a code_challenge.
invalid_scope400A 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 required on every call
A bearer token on its own is not enough. Circa is multi-tenant and the grant covers a set of teams, so every request must say which one it means. Omitting 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.

400Bad Requestx-team-id was not sent
{
  "statusCode": 400,
  "message": "Missing required 'x-team-id' header. Pass the id of a team this authorization covers."
}
403Forbiddenthe team is not in the grant
{
  "statusCode": 403,
  "message": "'Acme LCA' was not granted access to team '4b6f9c28-6f4e-4e7f-a36d-2ff989045d10'."
}
403Forbiddenthe user has left the team
{
  "statusCode": 403,
  "message": "The user who authorized 'Acme LCA' is no longer a member of team '4b6f9c28-6f4e-4e7f-a36d-2ff989045d10'."
}
401Unauthorizedthe token is not usable
{
  "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.