PUBLIC
Exchanges a valid refresh token for new access, ID, and refresh tokens.
The submitted refresh token is immediately invalidated and replaced with a new one.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Why this endpoint exists
Access tokens are deliberately short-lived (typically 60 minutes), so a leaked one has a small window. That is only workable if the session can be extended without dragging the customer through login again — which is what the refresh token is for.
Call this before the access token expires and the customer's session continues uninterrupted. The submitted refresh token is invalidated and replaced, so a stolen refresh token stops working the moment the legitimate client refreshes.
Request
Two ways to supply the token. The cookie wins when both are present.
# Browser — the HttpOnly cookie carries it, no body needed
curl -X POST https://api.acc.funtrips.io/v2/session/token/refresh \
--cookie "refresh_token=eyJjdHki…"
# Native app — explicit body
curl -X POST https://api.acc.funtrips.io/v2/session/token/refresh \
-H "Content-Type: application/json" \
-d '{ "refresh_token": "eyJjdHki…" }'| Field | Type | Required | Notes |
|---|---|---|---|
refresh_token | string | Conditional | Required unless the refresh_token cookie is present |
The request body is optional as a whole. Supplying neither cookie nor body field is a 400.
For a browser client with credentials: 'include', the correct call is a bodyless POST — the cookie set by code exchange or magic-link verify does the work.
Response
200 OK:
{
"data": {
"access_token": "eyJraWQ…",
"id_token": "eyJraWQ…",
"refresh_token": "eyJjdHki…",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2026-09-03T16:35:00Z"
}
}
Note this response has nometaenvelope field — unlike almost every other endpoint. Readdataand do not depend onmeta.correlation_idhere; theX-Correlation-Idresponse header still carries it.
Cookie rotation
The access_token cookie is always rewritten. A new refresh_token cookie is written only when the provider rotates the refresh token; otherwise the existing refresh cookie stays valid.
So do not assume every refresh yields a new refresh token. For native clients: if refresh_token is present in the body, replace what you stored; if absent, keep the one you have.
When to refresh
Proactively, before expiry. Track expires_at and refresh when roughly a minute remains. This avoids the alternative — refreshing reactively on a 401 — which is worse than it looks on the checkout routes.
Those routes treat an invalid token as guest rather than returning 401. A silently expired session therefore does not fail loudly; it prices the basket without the customer's loyalty discount and returns 200. There is no 401 to react to.
async function withFreshSession(fn) {
if (Date.now() > expiresAt - 60_000) {
const res = await fetch(`${BASE}/session/token/refresh`, {
method: 'POST', credentials: 'include',
});
if (!res.ok) return restartHandoff();
({ expires_at: expiresAt } = (await res.json()).data);
}
return fn();
}Refresh once and serialise concurrent callers behind that single attempt. Firing several refreshes in parallel means each invalidates the previous one's result, and the loser gets a 401 on a token that was valid a moment ago.
Errors
| Status | type | Meaning | What to do |
|---|---|---|---|
400 | — | Neither cookie nor body field supplied | Send one |
401 | refresh-token-invalid | Expired, revoked, or already used | The session is over — restart the hand-off |
500 | — | Unexpected fault. Retryable |
refresh-token-invalid is terminal for that session. Do not retry: send the customer back through session exchange or magic link. It is 401 rather than 500 specifically so your front-end can branch on it.
Notes
- A logged-out session cannot be refreshed.
POST /auth/logoutrevokes the refresh token at the identity provider. - Refresh tokens are single-use where rotation is enabled. Never refresh from two places concurrently.
