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

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.

TokenPrefixLifetimeBehaviour
Access tokencirca_oat_8 hoursSent as Authorization: Bearer on every API call.
Refresh tokencirca_ort_60 daysExchanged 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

POST/public/oauth/token

Same 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).

ParameterTypeDescription
grant_typerequiredstringrefresh_token.
refresh_tokenrequiredstringYour newest circa_ort_… value — the one from the most recent successful exchange, not the one you started with.
client_idrequiredstringYour circa_cid_…. May be omitted from the body if you use HTTP Basic.
client_secretstringYour 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

200OK
{
  "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.

✕
Reuse of a refresh token revokes the entire grant
Refresh tokens are single-use and Circa records that they were used. If an already-consumed refresh token is presented again, that is the signature of a stolen token being replayed, and Circa responds the only safe way: the whole grant is revoked. Every access token and every refresh token issued from it dies immediately, and the user must authorize your app again from scratch.

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 lock

Re-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:

400Bad Request
{
  "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.

ℹ
Rotating your client secretin the portal does not invalidate anyone's tokens — it only changes how your server authenticates at the token endpoint. Deploy the new secret and keep refreshing as normal.