An integration that worked yesterday starts answering expired_token today, and the instinct is to assume something broke. Usually nothing did: in Bitrix24 (Alaio) an access token is short-lived by design, and an expiry is the protocol working as specified. The errors worth investigating come one step later — when the renewal itself is refused.

What is the difference between an access token and a refresh token?

An app authorized on a portal holds a pair. The access_token is the credential attached to every REST call; the token response ships it alongside expires_in, documented as 3600 — one hour — and expires, the same moment as a Unix timestamp. The refresh_token is never sent to the REST API. Its only job is to obtain a new pair from the authorization server at https://oauth.bitrix.info/oauth/token/, via grant_type=refresh_token with client_id and client_secret. Its documented lifetime is 180 days.

The renewal response contains a new refresh_token, not just a new access token. That is the most common source of trouble: an integration that persists the access token and discards the returned refresh token keeps replaying an aging credential until it stops working.

Why is expired_token normal and not a failure?

expired_token is a documented system error, HTTP 401, described as the access token having expired. It says nothing about your app's registration, the portal's plan or the user's rights — only that the hour elapsed. Renew, repeat the call, move on.

The documentation is explicit about what not to do: pinging the authorization server before every REST request "just in case" is called an incorrect scenario that creates unnecessary load. Renew reactively. Note that a 401 can also arrive as NO_AUTH_FOUND — wrong authorization data — which refreshing does not fix.

What does invalid_grant actually mean?

invalid_grant and invalid_token are OAuth 2.0 token-endpoint conventions rather than entries in the Bitrix24 system error table, which documents expired_token but not these two. You see them because the authorization server refused the renewal, or because an OAuth library normalized that refusal into a standard name. The meaning is the same either way: the refresh token you presented is no longer a valid grant.

Realistic reasons, roughly in order of frequency:

  • the app was removed and installed again on the portal — the old pair does not survive it;
  • the refresh token was last used more than 180 days ago;
  • the pair was already exchanged, and you replayed a superseded copy;
  • the trial or paid period covering the app has lapsed;
  • the stored pair belongs to a different portal than the one you are calling.

This differs from an app that is registered but not present on the portal, which produces its own error — see application not installed. A dead grant is a credential problem; a missing installation is a registration problem.

Why must token storage make the renewal atomic?

Because a renewal is a swap, not an append. Two workers that see a 401 in the same second will both post the same refresh token; one gets a fresh pair, the other is refused, and if that second worker writes its error state over the row, the good pair is lost. From the outside this looks like tokens dying at random under load.

Store the pair per portal (keyed by member_id), lock before renewing, re-read the row after acquiring the lock in case another process already renewed, and write access_token and refresh_token together in one transaction.

When is re-authorization the only fix?

When the refresh token is genuinely rejected, no API call resurrects it. The pair must be issued again through the authorization flow: an administrator opens or reinstalls the app on the portal, and your handler stores the new pair. Retrying the same refresh token — with backoff, with anything — will not change the answer. Treat a refused grant as terminal, stop the retry loop, and surface it as "reconnect required", not as a transient failure. For the wider map of REST responses see the Bitrix24 REST API error codes reference; for 503s that are worth retrying, see query limit exceeded.

Why do third-party connectors hit this more often?

Because they stack every failure mode at once. A connector platform usually keeps one credential record per portal while running several workers against it — exactly the race described above. It normalizes provider errors into generic OAuth names, so invalid_grant is all you see and the portal-specific reason is hidden. And it sits outside the portal: an administrator who reinstalls the app has no reason to think the connector's stored copy just died, so the integration keeps retrying a grant invalidated by an action nobody logged on that side.

Inbound webhooks avoid this entirely — a static key, nothing expiring on a timer, at a known cost in security. It is a choice about who owns the credential lifecycle.