There is a particular kind of bug report that arrives about two months after a launch. The integration worked. Nobody touched it. Now every publish returns a 401 and the only thing that changed is the calendar.

If you are building against the Pinterest v5 API, this is almost always the same bug, and it is worth understanding properly because the failure is silent, delayed, and the obvious fix makes it worse.

The numbers

From Pinterest’s own token response:

  • expires_in: 2592000: the access token lives 30 days.
  • refresh_token_expires_in: 5184000: the refresh token lives 60 days.

Pinterest now supports only the continuous refresh token: 60-day expiry, refreshable indefinitely. The legacy 365-day refresh token with a hard limit is gone.

Two numbers, and most people only plan for the first one.

The part that catches everyone

When you exchange a refresh token for a new access token, Pinterest hands back a new refresh token too. The one you just used is spent.

This is standard OAuth refresh token rotation, and it is a good security property. It is also the single most common source of dead Pinterest integrations, because the naive implementation looks completely correct:

# This works. For a while.
def get_access_token(account):
    if account.access_token_expires_at > now():
        return account.access_token

    resp = pinterest.post("/v5/oauth/token", data={
        "grant_type": "refresh_token",
        "refresh_token": account.refresh_token,
    })

    account.access_token = resp["access_token"]
    account.save()                      # the bug is what is missing
    return account.access_token

The new refresh_token in that response is thrown away. The stored one is now spent. Every subsequent refresh sends a dead token, and Pinterest refuses it.

You will not notice immediately. The access token you just received is good for 30 days, so the integration keeps publishing happily for a month. Then it stops, and by the time anyone investigates, the deploy that introduced the bug is ancient history and nothing in the recent changelog explains it.

Sixty days after the original authorization, the stored refresh token expires on its own too, and at that point there is no recovery except sending the user back through the full authorization code flow.

Three ways this goes wrong

1. Not persisting the new refresh token. The one above. Fatal, delayed by 30 days.

2. Refreshing without a lock. Two workers notice an expired access token at the same moment. Both read the same refresh token. Both call Pinterest. One wins, one gets a rejection for a token that was valid microseconds ago. Worse, if the loser then writes its failure state, or if the winner’s write is overwritten by a stale read, you have stored a spent token and you are back to case 1.

This is the failure mode that survives code review, because the code is correct in isolation. It only breaks under concurrency, which usually means it only breaks in production.

3. Retrying a failed refresh with the same token. A refresh failure looks like a transient error, so a generic retry wrapper fires again with the identical spent token. It fails again. The retry budget burns, the alert says “Pinterest is down”, and the actual problem is in your own storage layer.

What correct looks like

The rule is simple to state and slightly annoying to implement: the read, the exchange and the write have to be one atomic operation per account.

def get_access_token(account_id):
    with db.transaction():
        # Take a row lock so concurrent workers serialise here.
        account = db.query(
            "SELECT * FROM pinterest_accounts WHERE id = %s FOR UPDATE",
            account_id,
        )

        # Re-read INSIDE the lock. Another worker may have already
        # refreshed while we were waiting, in which case we are done.
        if account.access_token_expires_at > now() + SKEW:
            return account.access_token

        resp = pinterest.post("/v5/oauth/token", data={
            "grant_type": "refresh_token",
            "refresh_token": account.refresh_token,
        })

        # Persist BOTH tokens, in the same transaction that holds the lock.
        db.execute("""
            UPDATE pinterest_accounts
               SET access_token = %s,
                   refresh_token = %s,
                   access_token_expires_at = %s
             WHERE id = %s
        """, (
            resp["access_token"],
            resp["refresh_token"],
            now() + timedelta(seconds=resp["expires_in"]),
            account_id,
        ))

        return resp["access_token"]

Four things are doing work there:

  • FOR UPDATE serialises refreshes per account. Not globally: one slow refresh should not block every other account. A Postgres advisory lock keyed on the account id works equally well if you would rather not hold a row lock across an HTTP call.
  • The re-read inside the lock is the part people skip. Without it, every worker that queued behind the lock proceeds to spend the token it read before waiting.
  • Writing both tokens together means a crash between the two writes cannot leave you with a fresh access token and a spent refresh token.
  • A clock-skew margin on the expiry check (SKEW above) stops you treating a token that is about to expire in flight as still valid.

That covers correctness. The harder question is when to refresh at all.

Refresh before you have to, but decide who for

If refresh only ever happens on demand, an account that publishes nothing for a long time can quietly cross the 60-day line, and the next call fails permanently rather than transiently. So you want some refreshing ahead of expiry. There are two defensible designs, and the difference between them is a product decision, not just an engineering one.

Option A: refresh everything proactively

Run a background job that refreshes any account whose access token expires within, say, seven days. Every refresh rotates the refresh token and resets its 60-day clock, so no token ever reaches the point of no return.

It is simple to reason about, and it is the usual advice. The cost is that you keep every account alive forever, including the ones nobody will ever use again. An account that connected once, published twice, and was abandoned gets refreshed every few weeks for eternity. You spend API calls and write load to preserve credentials for users who are gone, and you hold live tokens for accounts that, from the user’s point of view, they stopped using months ago.

Option B: refresh on demand, and proactively only where there is pending work

This is what we chose for PinBridge.

Active accounts already refresh themselves. Every publish and every read checks the access token and refreshes it if it is near expiry, persisting the rotated refresh token. Because each refresh resets the 60-day clock, an account that is used even once a month stays alive indefinitely with no scheduled job at all. On-demand refresh fully covers the accounts that are actually in use.

That leaves two cases the on-demand path does not cover, and we treat them differently:

  • Scheduled posts are deferred intent, so we protect them. If a user schedules a pin three weeks out and then goes quiet, the publish still has to succeed when it fires. So we do run a proactive job, but it targets only accounts that have a pending, future-dated scheduled pin whose token would otherwise lapse before it runs. It refreshes anything in that set whose access token expires within seven days, and its failures are logged per account rather than swallowed.
  • Genuinely dormant accounts we let expire on purpose. If an account has no activity and nothing scheduled for two months, we do not refresh it. The refresh token lapses, and the next time the user returns we ask them to reconnect.

We opted out of Option A deliberately. Reviving credentials for someone who has been gone for sixty days is work with no reader, and holding live publishing tokens for accounts a user considers abandoned is not a property we want. A reconnect prompt after two months away is expected behaviour, not a failure, so lazy expiry is the honest default. The one case where lazy expiry would actually drop something a user asked for, a scheduled post firing after a quiet stretch, is the case we cover explicitly. Option B costs a little more logic to decide what to refresh, and in exchange it does not hoard tokens for the dead accounts that make up a real share of any mature integration.

Whichever design you pick, the alerting rule is the same: surface refresh failures per account, immediately, rather than as an aggregate error rate. One account losing its token is invisible in a percentage and total for that customer.

When it is already broken

Once a refresh token is spent or expired, there is no clever recovery. The user has to re-authorize through the authorization code flow. The only useful engineering left is making that painless: detect it precisely, tell the user what happened in plain language, and give them a single button that starts the flow again.

The worst version of this is a publish queue that keeps retrying and emailing failure notices for a condition no retry can fix. Detect the auth failure, stop retrying it, and send one clear message instead of a stream of noise.

Why we care about this at PinBridge

PinBridge is a hosted Pinterest publishing API. Token rotation is one of the things you are paying us not to think about: you connect an account once, through the API or the MCP server, and we handle the refresh loop, the per-account locking, and the reconnect prompt when it genuinely is required.

Concretely, refreshes for a single account are serialised with a Postgres row lock and the token is re-read inside it, so two workers racing on the same account can never both spend the same refresh token. Active accounts refresh on demand as they publish. Scheduled posts get a targeted proactive refresh so a pin queued weeks out still fires. And when a token has genuinely lapsed, we detect it precisely and send one clear reconnect message rather than a retry loop.

We arrived at the pattern above the way most people do, which is by getting it wrong first. The lock and the re-read in particular were added after we watched two workers race on the same account.

If you would rather own it yourself, the code above is the whole answer and you should take it. The OAuth details are in our authentication docs, and if you would rather not, that is what we are for.


Pinterest token lifetimes cited from Pinterest’s own developer documentation, which is the authority here and worth reading before you build against v5.

Leave a Reply

Your email address will not be published. Required fields are marked *

Skip the token plumbing

PinBridge is a hosted Pinterest publishing API. Connect an account once and we handle the refresh loop, the locking and the reconnect prompt.