Refreshing tokens
Access tokens live 8 hours, refresh tokens live 60 days and rotate on every use. Getting the rotation right is the difference between a stable integration and one that logs users out.
| Token | Prefix | Lifetime | Behaviour |
|---|---|---|---|
| Access token | circa_oat_ | 8 hours | Sent as Authorization: Bearer on every API call. |
| Refresh token | circa_ort_ | 60 days | Exchanged for a new pair. Rotates on every use — single-use, and you get a new one back. |
Refresh before the access token expires — a minute or two of margin, or on a schedule — rather than waiting for a 401 and retrying. As long as you refresh at least once every 60 days, and the user does not revoke you, the grant lives indefinitely.
Refreshing
/public/oauth/tokenSame endpoint as the code exchange, same application/x-www-form-urlencoded body, same two client authentication options (HTTP Basic or client_secret in the body).
| Parameter | Type | Description |
|---|---|---|
| grant_typerequired | string | refresh_token. |
| refresh_tokenrequired | string | Your newest circa_ort_… value — the one from the most recent successful exchange, not the one you started with. |
| client_idrequired | string | Your circa_cid_…. May be omitted from the body if you use HTTP Basic. |
| client_secret | string | Your circa_cs_…, unless you use HTTP Basic. |
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=refresh_token' \
-d 'refresh_token=circa_ort_3fA6cHu8Zw5nQ2vJ0sYb4pR1tK7mLxD'Response
{
"access_token": "circa_oat_1tK7mLxD3fA6cHu8Zw5nQ2vJ0sYb4pR",
"token_type": "Bearer",
"expires_in": 28800,
"refresh_token": "circa_ort_0sYb4pR1tK7mLxD3fA6cHu8Zw5nQ2vJ",
"scope": "profile:read teams:read materials:read"
}Note that refresh_token is a new value. The one you just sent is now consumed and will never work again. Refreshing never widens or narrows anything by itself: scopes and teams come straight off the grant. It does read the grant as it stands now, though — if the user re-authorized you in the meantime with a different set, the scope in this response is the new one. Re-read it on every refresh rather than assuming it is stable.
This is a security feature, and it will fire on your own bugs just as readily as on an attacker. The two rules that keep you out of it:
1. Persist the new refresh token atomically. Write it to durable storage in the same transaction that commits the response, before you use the new access token for anything. If your process crashes between receiving the response and saving it, you are left holding a dead token and the user has to reauthorize.
2. Never refresh concurrently. Two workers that both notice an expiring token and both refresh with the same stored value means the second one is a replay — and the grant is gone. Serialize refreshes per grant with a database row lock, a distributed lock, or a single-flight wrapper so concurrent callers await one shared refresh.
A safe refresh, in outline
acquire lock on grant row (SELECT … FOR UPDATE)
re-read the stored refresh token ← another worker may have just rotated it
if the access token is no longer close to expiring: release, use it, done
POST /public/oauth/token grant_type=refresh_token
on 200: write access_token + expiry + NEW refresh_token, commit
on invalid_grant: mark the grant dead, prompt the user to reconnect
release lockRe-reading the stored token after taking the lock is what makes a thundering herd harmless: whoever loses the race sees the freshly rotated token and does not send the stale one.
When a refresh fails
A refresh that cannot succeed returns invalid_grant:
{
"error": "invalid_grant",
"error_description": "The refresh token has expired."
}The error_description tells the causes apart, and they are worth telling apart in your own logging:
The refresh token has expired.— nobody refreshed for 60 days. Common for seasonal or one-off integrations.This refresh token was already used. The authorization has been revoked.— reuse detection fired. The grant is gone; see above.The authorization has been revoked.— the user removed your app or you called the revoke endpoint.The refresh token is invalid.— the value is unknown, or it belongs to a different app.
A suspended app fails earlier, at client authentication: 401 with invalid_client and "This application has been suspended." The same 401 invalid_client is what you get if the client id or secret is wrong, so do not treat every non-200 as a dead grant.
There is no recovery path from any of them other than sending the user back through the authorization code flow. Treat invalid_grantas "disconnected": stop retrying, mark the connection as needing attention, and surface a reconnect action in your UI. Retrying a dead refresh token in a loop just fills your logs.