Marketplace APIs 8 min read

Etsy's Rotating OAuth Refresh Token (and the Sync Race It Creates)

Your Etsy integration works for weeks, then a shop quietly stops syncing and only a human re-login fixes it. That is the rotating refresh token: Etsy issues a new one on every refresh and kills the old, so a crashed write or two concurrent syncs orphan the shop. Here is the mechanism, the scheduled-vs-manual sync race, and the cache-plus-lock fix.

Updated Aug 2026
Etsy's Rotating OAuth Refresh Token (and the Sync Race It Creates)

Why Your Etsy Integration Stops Syncing After Weeks #

Your Etsy integration works for weeks. Then one shop quietly stops importing orders, and the logs show:

POST /v3/public/oauth/token  →  400
{"error":"invalid_grant"}

Re-connecting the shop — a human clicking "Allow" again — fixes it instantly. A week later it happens to a different shop. Nothing in your code changed.

That is Etsy's rotating refresh token. Etsy's Open API v3 issues a new refresh token every time you refresh, and invalidates the old one the moment it does. If your app ever loses the new token — a crashed write, or two syncs refreshing at once — the shop is orphaned, and only a human re-consent brings it back. It is the most common reason a working Etsy integration rots over time, and it is a property of the API, not your code.

Bigger picture: Etsy is the only one of the four big marketplace APIs whose stored credential changes during normal operation. For the side-by-side, see four marketplace APIs, four auth models.

A metal chain, representing a token refresh chain that breaks at a single weak link
The refresh chain renews forever — until one failed write breaks it permanently.

PKCE, and the Two Tokens Etsy Gives You #

Etsy v3 uses OAuth 2.0 authorization-code with PKCE — the only one of the major marketplaces that requires it. You generate a random code_verifier, derive code_challenge = base64url(sha256(verifier)), and send it with code_challenge_method=S256 to https://www.etsy.com/oauth/connect. The shop owner clicks Allow, Etsy redirects back with a code, and you exchange the code (presenting the original verifier) for two tokens.

The two tokens behave very differently, and the difference is the whole story:

  • Access token — formatted {user_id}.{random}, lives one hour. You send it as a bearer on every call. Short-lived and disposable.
  • Refresh token — lives 90 days, and rotates on every refresh. Each call to the token endpoint with grant_type=refresh_token returns a brand-new access token and a brand-new refresh token, and invalidates the one you just used.

Refreshing is a plain server-to-server POST — no browser, no callback:

POST https://api.etsy.com/v3/public/oauth/token
grant_type=refresh_token
client_id=<your keystring>
refresh_token=<current refresh token>

→ { "access_token": "...", "refresh_token": "...NEW...", "expires_in": 3600 }

Because the chain self-renews, a shop that syncs at least once every 90 days never has to log in again — if you never drop a rotation. Etsy's own authentication guide documents the grant but is quiet on the operational hazards below.

Gotcha: every v3 request also needs an x-api-key header set to keystring:shared_secret — the keystring alone returns 403 "Shared secret is required in x-api-key header." That is separate from the bearer and easy to miss. See Etsy's request standards.

The Crash Window: One Failed Write Disconnects the Shop for Good #

Here is the failure the docs don't warn you about. The instant Etsy hands you a new refresh token, the old one is dead. So this ordinary-looking code has a permanent-disconnect window in it:

# WRONG — the gap between these lines can strand the shop
data = await refresh(decrypt(account.refresh_token))   # old token is now dead
account.refresh_token = encrypt(data["refresh_token"]) # new token, in memory only
await db.commit()                                      # crash / deploy / OOM here → new token lost

If the process dies between receiving the new token and committing it — a deploy rolling the container, an OOM kill, a database blip — you are left holding a refresh token Etsy has already invalidated, and the only valid one existed for a few milliseconds in a variable that no longer exists. No retry can recover it. The shop must be re-authorised by a human.

You cannot make this impossible — a hard crash mid-commit will always lose data — but you can make it rare and small. Two rules: between the refresh response and the commit, do nothing else — no extra API calls, no image downloads, no slow work — persist the rotated token in one fast transaction and only then continue; and if the commit itself fails while you still hold the 200 response, retry the write, not the refresh, because the new token only exists in that response body. Then, most importantly, refresh far less often — which is the next section.

The Sync Race: A Scheduled Pull vs a Manual Sync Now #

Even with an atomic write, concurrency alone will orphan a shop. Two overlapping syncs of the same shop each read the same stored refresh token and each call the token endpoint. Whichever refreshes second is holding a token the first already rotated away:

POST /v3/public/oauth/token  →  400 {"error":"invalid_grant"}

The classic trigger is a scheduled sync overlapping a manual "Sync now" a user clicked. Your hourly worker probably guards against overlapping itself; it rarely guards against a human pressing a button mid-run. And if the two writes interleave, you can persist a token Etsy no longer honours while the live one is thrown away — the same permanent disconnect, reached a different way.

A scheduled Etsy sync and a manual sync both read the same refresh token; the second refresh returns invalid_grant because the first already rotated it. Scheduled sync Manual "Sync now" read RT1 refresh → RT2 RT1 now dead persist RT2 read RT1 refresh with RT1 → 400 invalid_grant both read the same token rotation invalidates it Fix: cache the access token until near expiry, and serialise refreshes per shop with a lock

The Fix: Cache the Access Token, Refresh Only Near Expiry, Lock Per Shop #

The fix is not clever, and that is the point: stop refreshing on every sync. Cache the one-hour access token with its expiry, reuse it until it is nearly expired, and when you do refresh, hold a per-shop lock so two syncs can never refresh at once. Rotations drop from "every sync" to "once an hour", and both the race and the crash window nearly vanish.

async def get_valid_access_token(db, account):
    # 1) Reuse the cached access token until it is close to expiry.
    if account.token_expires_at and account.token_expires_at - now() > timedelta(minutes=5):
        return decrypt(account.access_token)

    # 2) One refresh per shop at a time — the lock is the whole defence
    #    against two syncs rotating the token out from under each other.
    async with per_shop_lock(account.id):
        await db.refresh(account)
        # A sync that waited on the lock may have refreshed already.
        if account.token_expires_at and account.token_expires_at - now() > timedelta(minutes=5):
            return decrypt(account.access_token)

        data = await post_token(
            grant_type="refresh_token",
            client_id=os.environ["ETSY_KEYSTRING"],
            refresh_token=decrypt(account.refresh_token),
        )
        # 3) Persist the ROTATED refresh token + new access token atomically,
        #    with nothing else between the response and the commit.
        account.refresh_token = encrypt(data["refresh_token"])
        account.access_token = encrypt(data["access_token"])
        account.token_expires_at = now() + timedelta(seconds=data["expires_in"])
        await db.commit()
        return data["access_token"]

Three things do the work: the expiry check keeps most syncs from refreshing at all; the double-check inside the lock means a sync that waited on the lock uses the token the winner just wrote instead of refreshing again; and the single-transaction persist keeps the crash window to one fast write.

If you run more than one worker process, the lock has to be distributed — a row lock on the account (SELECT ... FOR UPDATE) or an advisory lock keyed by shop id, not an in-process mutex. An in-process lock protects you right up until the day you scale out, and then quietly stops working.

Store the expiry as a real column. token_expires_at lets you reason about — and display — a shop whose token is about to lapse. A credential that dies quietly is an outage nobody notices until a seller asks where their orders went. The same discipline matters across accounts: see why multi-account marketplace APIs need dedicated egress IPs.

Rotation vs Static: How Etsy Compares to Amazon and eBay #

The atomic-write and per-shop-lock machinery above exists for exactly one reason: Etsy's refresh token rotates. Amazon's and eBay's do not, so neither needs any of it. If you have integrated those and assumed Etsy behaves the same, this is the assumption that breaks:

Etsy v3 Amazon SP-API eBay
Refresh token life90 days of inactivityLong-lived, static~18 months
Rotates on refresh?Yes — every timeNoNo
Access token life1 hour1 hour2 hours
PKCE required?Yes (S256)NoNo
Atomic persist required?YesNoNo
Concurrent-refresh race?YesNoNo

Amazon and eBay both hand you a static refresh token you can store once and mint access tokens from forever; a second concurrent refresh is harmless because it returns the same still-valid token. eBay's own quirk is elsewhere — it wants a RuName alias where a redirect URL should go, not rotation. Only Etsy makes the stored secret a moving target, and that single "yes" in the rotation row is what forces every row below it.

FAQ #

Why does my Etsy integration randomly stop syncing?

Almost always the rotating refresh token. Etsy issues a new refresh token on every refresh and invalidates the previous one, so either a failed write or two concurrent refreshes will orphan the shop, which then returns 400 invalid_grant until a human re-authorises it. Cache access tokens and serialise refreshes per shop.

How long does an Etsy refresh token last?

90 days of inactivity — but it rotates on every refresh, so an actively-syncing shop's token string changes constantly while the 90-day clock only matters if the shop goes completely unused. Sync at least once inside the window and the chain self-renews indefinitely.

Does refreshing an Etsy token give me a new refresh token?

Yes, every single time. The response to a grant_type=refresh_token call contains a new access token and a new refresh token, and the refresh token you sent is invalidated immediately. You must persist the new one atomically or the shop is disconnected.

How do I stop concurrent Etsy syncs from breaking auth?

Cache the one-hour access token with its expiry and only refresh inside a small window before it expires, and wrap the refresh in a per-shop lock so two syncs can never rotate the token at once. With more than one worker, make the lock distributed — a row lock or advisory lock keyed by shop id, not an in-process mutex.

Can I add an Etsy scope without reconnecting the shop?

No. Scopes are fixed at authorisation and a refresh cannot change them, so adding transactions_w to a read-only connection means re-consent on every shop. Request the full scope set up front, even scopes you will not use until later.

Don't want to build this yourself?

I set this up for sellers and agencies every week. Book a free 30-minute audit of your Amazon data & PPC setup — you'll leave with a plan either way, whether we work together or not.

Found this helpful? Share it:

WhatsApp