OAuth token refresh for a social media API
How automatic OAuth token refresh should work across social platforms, and what happens when a refresh itself fails.
OAuth token refresh should be proactive: track each connection's expiry timestamp and refresh inside a safety window ahead of the deadline, instead of discovering an expired token through a failed customer post. When a provider actively rejects a refresh — after a password change or revoked access — treat that as a terminal state needing customer re-authorization, not something to retry indefinitely.
Every OAuth-based social connection expires eventually. The difference between an integration customers trust and one they abandon is not whether tokens expire — they always do — it is whether refresh happens automatically before that matters, and whether the moments it genuinely cannot be refreshed are surfaced clearly instead of showing up as a mysteriously failed post three days later.
PostGate handles OAuth, publishing and delivery evidence for every network through one API. Start free or read the docs.
Refresh should be proactive, not discovered on a failed publish
The naive approach waits for a publish call to fail with an auth error, then reacts. That works, but it means the first symptom your system sees is a failed customer post — the worst possible moment to discover a token needed attention. A well-built integration tracks token expiry directly and refreshes ahead of that deadline, so publishing itself never has to double as an expiry check.
PostGate's profile health endpoint exposes exactly the fields this requires: current status, the scopes actually granted, token_expires_at, and an explicit reconnect_required flag — so a client can act on expiry before it becomes a failure, not just after.
curl https://postgate.studio/api/profiles/pro_instagram123/health \
-H "Authorization: Bearer $POSTGATE_API_KEY"
{
"status": "active",
"granted_scopes": ["instagram_content_publish", "pages_show_list"],
"token_expires_at": "2026-09-10T00:00:00.000Z",
"reconnect_required": false
}What do the three webhook events mean?
PostGate models refresh outcomes as three separate webhook events, deliberately kept apart because each implies a different fix and a different urgency:
profile.expiring— the authorization is nearing expiry. Nothing is broken yet; this is a heads-up window to reconnect before anything actually stops working.profile.expired— the token ran out with no way to refresh it. Carriesreason: "token_expired".profile.reauth_required— a refresh attempt was actively rejected by the provider, rather than simply expiring on its own. Carriesreasonas one ofrefresh_failed,revoked, orpublish_auth_error(an authorization error surfaced while publishing).
Collapsing all three into one generic "disconnected" state throws away exactly the information a customer needs to know what to do next — reconnect calmly ahead of time, or reconnect now because something already broke.
What happens when the refresh itself fails?
A refresh attempt does not always succeed just because a refresh token exists. The provider can reject it outright — the customer changed their password, revoked the app's access from their own account settings, or the underlying permissions model changed underneath the connection. When that happens, PostGate does not keep retrying a refresh that will keep failing for the same reason; it surfaces profile.reauth_required with the specific reason so your product can show the customer an accurate message instead of a generic error, and route them straight into a new hosted connection rather than a refresh that cannot succeed.
Both profile.expired and profile.reauth_required fire once per authorization state transition, not on every background maintenance sweep — a customer's inbox does not fill up with repeated alerts about the same broken connection, and a profile that reconnects and later breaks again correctly fires a fresh event.
How does a failed refresh show up at publish time?
If a post is attempted against a profile that needs reconnection, the per-target result reflects that specifically. PostGate's error envelope returns profile_disconnected — a distinct, non-retryable code, not a generic failure — and the target status becomes awaiting_reconnect rather than a plain failed, so your integration can tell "this needs the customer" apart from "this can be retried" at a glance, without inspecting a message string.
What does this look like if you own the OAuth flow directly?
Building this yourself means tracking an expiry timestamp per connection, running a scheduled sweep that refreshes tokens inside a safety window before they expire, and — critically — handling the case where the provider's refresh call itself returns an error rather than a new token. That last case is the one naive implementations miss: they retry the refresh indefinitely, or worse, silently keep serving a stale token until a publish call fails downstream. Treat a refresh rejection as its own terminal state that needs the customer, exactly the way profile.reauth_required does.
Frequently asked questions
What is the difference between profile.expiring, profile.expired, and profile.reauth_required?
profile.expiring means the authorization is nearing expiry — nothing is broken yet, it is a heads-up window to reconnect. profile.expired means the token ran out with no way to refresh it (reason: token_expired). profile.reauth_required means a refresh attempt was actively rejected by the provider, with reason set to refresh_failed, revoked, or publish_auth_error. Each implies a different fix and urgency, which is why they are kept apart.
Why did my token refresh fail even though a refresh token exists?
A refresh attempt does not always succeed just because a refresh token exists. The provider can reject it outright — the customer changed their password, revoked the app's access from their own account settings, or the underlying permissions model changed beneath the connection. In that case no amount of retrying helps: the connection needs a fresh authorization from the customer.
What happens when a post targets a profile that needs reconnection?
The per-target result reflects it specifically: PostGate's error envelope returns profile_disconnected — a distinct, non-retryable code — and the target status becomes awaiting_reconnect rather than a plain failed. That lets an integration tell "this needs the customer" apart from "this can be retried" at a glance, without inspecting a message string.
What do I need to build if I run OAuth refresh myself?
Track an expiry timestamp per connection, run a scheduled sweep that refreshes tokens inside a safety window before they expire, and handle the case where the provider's refresh call itself returns an error rather than a new token. That last case is what naive implementations miss — they retry indefinitely or silently serve a stale token until a publish fails. Treat a refresh rejection as its own terminal state that needs the customer.
