App/uninstalled and compliance webhooks return 500 after enabling expiring offline access tokens

We recently enabled expiring offline access tokens (future: { expiringOfflineAccessTokens: true }) in our React Router app, as required for public apps.

Since then, our app/uninstalled and mandatory compliance webhooks (shop/redact etc.) fail with a 500 on every delivery and retry. All other webhooks work fine, and bad-HMAC requests correctly get a 401. Our handlers follow the template docs exactly, including the if (!session) check.

What we found: authenticate.webhook() tries to refresh the shop’s access token before our handler code runs. But when a shop uninstalls, Shopify revokes their tokens - so the refresh can never succeed, and the library responds with a 500 instead of continuing with session: undefined like the docs describe. Our handler never runs, so we never get to return the 200.

It only happens when the stored token is already stale (shop idle for an hour or more before uninstalling). Uninstalling right after using the app works fine - which is why our own testing never caught it, but the app review store hit it.

Reproduce:

  1. Enable expiringOfflineAccessTokens
  2. Install the app, don’t touch it for an hour
  3. Uninstall without opening the app
  4. app/uninstalled → 500, and shop/redact later fails the same way

This is a problem for app review, since compliance webhooks must return a 200.

Has anyone else run into this since switching to expiring tokens? Is there a recommended way to handle webhooks for shops whose tokens are already revoked?

Or are we doing something completely wrong here?

export const action = async ({ request }: ActionFunctionArgs) => {
  const { shop, session, topic } = await authenticate.webhook(request);

  console.log(`Received ${topic} webhook for ${shop}`);

  if (!session) {
    return new Response();
  }

  await db.session.deleteMany({ where: { shop } });

  return new Response();
};

Hey @James_A - thanks for flagging this and for theeproduction steps. I’m going to raise this internally. I’ll keep you in the loop and follow up here once I have more information.