Skip to content
Back to Homephamkhanhminhman.com / blog
OAuth / Distributed Systems 24 Aug 2026 9 min read

Three calls get you a Shopee token. Keeping it alive is the hard part.

Shopee's authorization flow looks small, and it is. generateAuthLink() gives you a link for the seller to click, fetchToken() trades the auth code for a token, refreshToken() renews it before it lapses. Ask any AI and you get those three calls in thirty seconds. Point them at your own test shop and they work.

I wrote shopee-api-client, so I can tell you those three functions really are all there is. They sign a request and send it. They have no idea how many other processes of yours are holding the same refresh token, no idea whether the seller has revoked access, and they remember nothing between two calls. All three problems below live on the far side of that line, and all three stay perfectly quiet while you test one shop on one machine.

1. The three auth endpoints sign differently from the rest of the package

Every call that needs a token — getOrders, updateStock, nearly the whole package — folds accessToken and shopId into the string it signs:

// common/helper.ts
function signRequest(path, config, timestamp) {
  const { partnerId, accessToken, shopId, partnerKey } = config;
  const params = [partnerId, path, timestamp.toString(), accessToken, shopId]
    .filter((item) => item !== null && item !== undefined);
  const baseString = params.reduce((prev, curr) => (prev += curr), '');
  return createHmac('sha256', partnerKey).update(baseString).digest('hex');
}

The auth endpoints cannot sign that way, because at the moment you call them you do not have a token yet. Their signing string is three parts and nothing else:

// common/helper.ts
function signPublicRequest(path, config, timestamp) {
  const { partnerId, partnerKey } = config;
  const baseString = `${partnerId}${path}${timestamp}`;
  return createHmac('sha256', partnerKey).update(baseString).digest('hex');
}

Read the source and something looks off: only generateAuthLink calls that helper. Both token functions re-implement the same formula inline, each with its own createHmac. One rule, three copies. Anyone opening that file wants to tidy it up.

Tidy it if you like, but not by routing them through signRequest. It looks safe: accessToken is undefined at that point, so surely it just falls out of the string. It does fall out, and that is exactly the problem — the .filter() swallows every null and undefined, so nothing throws, nothing warns, and you get back a signature that is perfectly well-formed and completely wrong. Meanwhile shopId is usually sitting right there in the config, so it gets appended quite happily.

What you get is a signature error on exactly the three auth endpoints. And a signature error while fetching a token reads identically to an auth code that was already used, or a refresh token that expired. So the afternoon goes into clicking through the authorization link again for a fresh auth code, and nobody circles back to suspect the .filter() line that was just written to keep things clean.

2. The refresh token is shared state, not a config field

Right above fetchTokenWithRefreshTokenI copied a few numbers from Shopee's docs, along with the date they were last updated: 2022-09-28. Treat them as reference values — check the current docs before you hardcode any of them:

The refresh token is single-use, and every call returns a new one. A fresh access token lives 4 hours, a fresh refresh token 30 days. After a refresh, the old access token stays valid for another 5 minutes and the old refresh token for another 4 hours.

Those two trailing windows are not Shopee being generous. They exist so requests already in flight with the old token do not die for nothing while another process finishes refreshing. Practically: a token cache a few minutes old is fine to use, but a cache older than 4 hours holds a refresh token that is already garbage — and you find out at the exact moment you call refreshToken() and eat the error.

The expensive words are “single-use”. They turn the refresh token from a config field into a contended resource. Two workers both notice shop X is close to expiry, both read the same refresh token out of the database, and both call:

Worker A reads refresh_token = "R1"
Worker B reads refresh_token = "R1"   // nobody has written to the DB yet

Worker A: POST R1  ->  new access_token, refresh_token = "R2"
Worker B: POST R1  ->  error, R1 has already been consumed

A writes R2 to the DB.
B retries with R1, which is dead for good, and retries forever.

The package cannot save you here, and I never intended it to. refreshToken() fires exactly one HTTP request and hands back the result; it has no way of knowing who else is running alongside it. Coordination belongs to the application layer. And this is precisely the class of bug that a one-shop, one-process dev environment is built to never reproduce.

The fix is a lock per shopId, not a global one that makes every shop queue behind every other:

async function refreshShopToken(shopId: string) {
  const lockKey = `shopee:refresh:${shopId}`;
  const acquired = await redis.set(lockKey, "1", "NX", "EX", 30);
  if (!acquired) return; // another worker is refreshing this shop

  try {
    const fresh = await db.getShopeeConfig(shopId); // re-read AFTER taking the lock
    const shopee = new ShopeeModule(fresh);
    const token = await shopee.refreshToken();
    await db.saveShopeeToken(shopId, token); // write immediately, do not batch
  } finally {
    await redis.del(lockKey);
  }
}

The two comments in there carry most of the weight. Read the config before you win the lock and you still hit the exact race above — just milliseconds later, and much harder to believe you still have a bug. Batch the token writes to save round trips and you stretch the window where a refresh token that has already been consumed is still sitting in another process's memory, still assumed good.

Same idea applies to the module object itself. setConfig() does not return a new config, it overwrites in place:

// module/shopee/index.ts
setConfig(config: ShopeeConfig) {
  Object.assign(this.config, config);
}

Keeping one shared ShopeeModule and calling setConfig() whenever the shop changes is what almost everyone writes first — I wrote it that way too. It behaves until two requests overlap: shop A's request is waiting on the network, shop B's request swaps the config, and when A wakes up it signs with B's token. No exception, nothing in the logs — just a valid API call sent to the wrong shop. The outcome depends on network ordering, so do not expect to reproduce it by clicking around.

The package ships a type describing the shape you actually want, but it is only a type, with no logic attached:

// config.request.ts
export interface ShopeeConfigList {
  [shopId: string]: ShopeeConfig;
}

So the rule is simpler than hunting for places that need locking: new a ShopeeModuleper request, with the config read fresh from the database for that request's shopId. One instance serves one shop for its whole life. You pay one small object per request and the entire class of bug stops existing.

3. Authorization — the layer refresh tokens cannot reach

Both sections above quietly assume there is still something left to refresh. Access and refresh tokens are one layer, and code can rotate them on its own. The seller's authorization is the layer underneath, and your code cannot create it. Once it expires, refreshToken() fails even when the refresh token in your hand is valid by every number you stored — there is simply nothing behind it to renew against.

The awkward part is that no API answers “how many days of authorization does this shop have left”, and you cannot infer it from the token you are holding. The only channel is the webhook. Shopee gives you a week of warning through push code 12:

{
  "code": 12,
  "data": {
    "shop_expire_soon": [23213, 243242, 342343],
    "expire_before": 1619740800
  }
}

Miss that push and you learn the other way: one shop's sync jobs start failing in bulk and no amount of retrying brings them back. The only recovery is the seller re-authorizing by hand, which means you need to be able to reach them, which is why that week of warning is worth far more than the effort of persisting one webhook.

There is one more trap on this layer, the kind that anyone testing with their own shop walks straight past. Authorization comes in two flavours, and the code makes you pick exactly one:

// fetchTokenWithAuthCode
if (!shopId && !mainAccountId) {
  throw new Error('[Shopee API] fetchToken requires either shopId or mainAccountId in config.');
}
if (shopId && mainAccountId) {
  throw new Error('[Shopee API] fetchToken accepts only one of shopId or mainAccountId.');
}

A seller authorizing a single shop comes through shopId. A main account authorizing a batch of shops comes through mainAccountId, and the SHOP_AUTHORIZATION webhook then returns shop_id_list. The types keep both fields optional, which matches reality: the first flavour carries shop_id, the second carries shop_id_list.

A handler that only reads shop_id works flawlessly all through development, because you test with one shop. Then comes the first main account authorizing twelve shops, and you fetch a token for exactly none of them. No exception, no error log — just twelve shops quietly absent from your system until someone asks why their orders never arrived.

Where the line sits

These are not three separate bugs. They are the same misunderstanding showing up three times: mistaking a transport client for a lifecycle manager. The package signs requests and sends them, and it does that part well. Who is allowed to refresh, who gets to write the result, which shop's config is sitting in which instance, what happens when a seller stops agreeing — those questions were always yours.

If you only take one thing from this, take this one: run two workers refreshing the same shop at the same time. Under thirty lines of code. It is the difference between a system you actually understand and a system that has only been lucky.

OAuth / Distributed SystemsBack to Home