Skip to content

Refresh tokens

Access tokens are short-lived (1 hour). Refresh tokens let you mint new ones without re-prompting the organizer or participant for consent.

This page covers refresh-token rotation, token lifetimes, and concurrent refresh handling.

Token lifetimes

One table, shared across both OAuth flows:

Token or grant Lifetime Notes
Authorization code 10 minutes Single use. Exchanging the same code twice returns invalid_grant and revokes the first-issued family.
Access token 1 hour Same for installation and user tokens.
Refresh token sliding window 90 days from the last successful refresh Each successful refresh resets the 90-day idle window.
Refresh token hard cap 1 year from the original consent Re-consent required after that.

When to refresh

Two valid strategies - pick one and stick with it:

Reactive (most common)

When an API call returns 401 token_expired, refresh and retry once. Simple, works.

def call_api(token, url):
    res = http.get(url, headers={"Authorization": f"Bearer {token.access_token}"})
    if res.status == 401 and res.json()["error"] == "token_expired":
        token = refresh(token)
        res = http.get(url, headers={"Authorization": f"Bearer {token.access_token}"})
    return res

Proactive

Refresh before the access token actually expires - say, when now + 5 minutes > token.access_expires_at. Avoids the extra request on every API call but requires you to track expiration.

Pick proactive if you do scheduled batch work where a 401 mid-batch is annoying. Pick reactive if you do scattered request/response work where simplicity wins. Don't mix - concurrent calls hitting both refresh paths fights each other.

Refresh request

POST /oauth/token HTTP/1.1
Host: auth.revento.app
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token={your refresh token}
&client_id={your client_id}
&client_secret={your client_secret}

No PKCE on refresh (PKCE is only for the initial code exchange). No redirect_uri.

Refresh response

{
  "access_token":       "rev_install_new_xyz...",
  "refresh_token":      "rev_refresh_new_abc...",
  "token_type":         "Bearer",
  "expires_in":         3600,
  "refresh_expires_in": 7776000,
  "scope":              "event.read participants.read program.read",
  "event_id":           "evt_abc123",
  "organization_id":    "org_xyz789"
}

Both expires_in and refresh_expires_in are in seconds. The 7,776,000 above is 90 days - the refresh-token sliding window. Each successful refresh resets refresh_expires_in back to 90 days, up to the 1-year hard cap from initial issue.

Critical: the response contains a new refresh token. Refresh tokens are one-time use - the moment you successfully exchange one, the old refresh token is invalidated. Store the new one immediately, atomically, before doing anything else with the new access token.

If your code does:

# WRONG
new_tokens = refresh(old_token)
do_something_that_might_fail(new_tokens.access_token)
db.save(new_tokens)  # never reached on failure → next refresh uses stale old_token → 400 invalid_grant

You'll occasionally end up in a state where the DB still has the old refresh token but the server has invalidated it. The next refresh attempt fails permanently and the organizer has to re-consent.

The robust pattern:

# Right
new_tokens = refresh(old_token)
db.save(new_tokens)              # persist FIRST
do_something(new_tokens.access_token)

Or even better, save inside the same transaction that triggered the refresh:

with db.transaction():
    new_tokens = refresh(old_token)
    db.save(new_tokens)
    return new_tokens

Refresh-token lifetime

The shared token-lifetime table above applies to both organizer-authorized installation tokens and Login with Revento user tokens.

The sliding window means: each successful refresh extends the refresh token's life by 90 days from that moment. A connection that refreshes weekly will never hit the 90-day idle expiry.

The 1-year hard cap is absolute. After 1 year from the original consent, the user must re-consent through the full OAuth flow - no amount of refreshing extends past this. Plan a reconnect flow before the hard cap is reached.

Refresh-token reuse

Refresh tokens are single-use. If an old refresh token is used again after a successful refresh, Revento revokes the related authorization and re-consent is required.

The remediation when it happens: full re-consent. There's no "undo." Make sure your code handles this case explicitly:

if response.status == 400 and response.json()["error"] == "invalid_grant":
    mark_installation_revoked()
    trigger_reconnect_handling()

Concurrent-refresh races

The most common bug: two parts of your code refresh the same token at the same time.

If one refresh succeeds and another repeats the old token, Revento treats that as refresh-token reuse and the authorization must be re-consented.

Avoidance:

Option 1: single-flight refresh

In-process: a mutex around the refresh, deduping concurrent attempts to one network call. The N concurrent callers all get the same new tokens.

Option 2: distributed lock

If your refresh might happen across multiple processes or instances, take a shared lock keyed on the installation id before refreshing. Other callers wait, reload the token from storage after the lock releases, and continue.

Option 3: serialize through a single worker

Route all refresh attempts through one serialized path. Other callers either wait or defer refresh to that path.

Pick whichever fits your architecture. The cheapest is option 1 if your refresh attempts are within a single process; option 2 once you're horizontally scaled.

When the family is revoked (deliberately, by leak detection, or accidentally via concurrent refresh), the next API call returns 401 token_revoked. Refresh attempts return 400 invalid_grant.

Recovery requires the organizer to go back through the organizer-connect flow. Your integration should:

  1. Mark the connection in your system as needing re-consent.
  2. Tell the organizer that they need to reconnect, and send them back through your normal OAuth entry point.
  3. Optionally, email or notify based on your customer relationship.

There is no programmatic recovery - re-consent is required.

Common pitfalls (recap)

  • Storing the new refresh token after using the new access token. Persist FIRST.
  • Sharing a refresh token across multiple processes without a lock. Family-revoke is unforgiving.
  • Catching 400 invalid_grant as a transient error and retrying. It's permanent. Mark the installation revoked.
  • Refreshing on every API call "just in case." Hammers the token endpoint, increases the chance of races. Refresh only when the access token is genuinely expired.
  • Treating 401 token_expired and 401 token_revoked the same. The first is recoverable by refreshing; the second requires re-consent. Read the structured error field, not just the status code.