Shopify app/uninstalled Webhook: Fix the Silent 500

If your Shopify app/uninstalled webhook returns 500 and never runs its cleanup, the cause is usually the library, not your handler. With expiring offline tokens on, authenticate.webhook tries to refresh the shop’s expired token before your code runs, the refresh fails because the shop has uninstalled, and the route throws. Verify the HMAC yourself instead, and delete sessions without asking for one.

That failure is open as shopify-app-js issue #3360, filed on 1 August 2026. As of 6 November 2026 the issue is still open, the proposed fix in PR #3443 is unmerged, and @shopify/shopify-app-react-router 4.0.1, published the day before, still ships the same code. The official React Router app template turns expiringOfflineAccessTokens on by default, so a fresh app built from it today has the bug.

Why does the Shopify app/uninstalled webhook return 500?

Because of the order the library does things in. authenticate.webhook checks the HMAC, loads the shop’s offline session, and then, if the access token is within five minutes of expiry and has a refresh token, calls Shopify’s token endpoint to refresh it. Only after that does it hand your route a context.

For a shop that has just uninstalled, the refresh cannot succeed. Shopify’s offline access token guide says “A merchant uninstalling your app ends the access of all your app’s tokens for that store.” The helper in the package’s dist/esm/server/helpers/refresh-token.mjs then does this with anything other than an invalid_subject_token error:

throw new Response(undefined, {
  status: 500,
  statusText: 'Internal Server Error',
});

React Router sends a thrown Response straight back to Shopify, and, as the issue’s author found, does not log it. All you get is a 500 in the access logs with no stack trace behind it.

The library’s own webhook reference promises something different: “If the app is already uninstalled, the session may be undefined.” That is what the template’s handler is written for, with its if (session) guard. The guard never gets a chance to run.

Why your own testing missed it

Expiring offline tokens last an hour (expires_in is 3600). Install the app on a dev store, click around, uninstall, and the token is still fresh: no refresh, no failure, a clean 200. The failure needs a shop whose token expired before it uninstalled, which is likely for any merchant who had stopped opening the app before removing it. Several reports on the issue found it the same way, through the App Store review store’s failed shop/redact deliveries.

What happens when the deliveries keep failing?

Shopify “retries failed webhook calls up to eight times in a four-hour period”, according to the troubleshooting guide. Every retry hits the same expired session and fails the same way, and then the delivery is gone. Your session row stays, any “mark shop as uninstalled” logic never runs, and the shop keeps counting as installed in your own reporting.

The mandatory compliance topics take the same path, because they also arrive for shops that have gone. Our post on compliance webhooks for uninstalled shops covers what those handlers should do; the HMAC check below is the part they can share with the uninstall route.

There is a second failure that only shows up if the merchant comes back. Say the first delivery fails, and within the four-hour retry window the merchant reinstalls. Token exchange stores a fresh offline session under the same offline_<shop> id. The next retry of the old uninstall then finds a valid session, the template’s guard passes, and it deletes the session the new install depends on. The merchant’s next visit to the admin repairs it with another token exchange, but your background jobs and webhook consumers have no token until then.

How do you handle app/uninstalled without a session?

You never needed one. The token is dead by the time the webhook arrives, so the handler has nothing to say to the Admin API, and asking the library to load and refresh a session only exposes you to the bug. Shopify’s delivery verification guide gives you what you need instead: compute HMAC-SHA256 of the raw body with your client secret and compare it to the X-Shopify-Hmac-Sha256 header. The shop and the trigger time arrive as headers too, listed in the delivery structure reference.

// app/routes/webhooks.app.uninstalled.tsx
import { createHmac, timingSafeEqual } from "node:crypto";
import type { ActionFunctionArgs } from "react-router";
import db from "../db.server";

function hmacMatches(body: Buffer, header: string | null): boolean {
  const secret = process.env.SHOPIFY_API_SECRET;
  if (!secret) throw new Error("SHOPIFY_API_SECRET is not set");
  if (!header) return false;
  const expected = createHmac("sha256", secret).update(body).digest();
  const received = Buffer.from(header, "base64");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

export const action = async ({ request }: ActionFunctionArgs) => {
  if (request.method !== "POST") return new Response(null, { status: 405 });

  // Raw bytes, before anything parses them: the HMAC is over the exact body.
  const body = Buffer.from(await request.arrayBuffer());
  if (!hmacMatches(body, request.headers.get("X-Shopify-Hmac-Sha256"))) {
    return new Response(null, { status: 401 });
  }

  const shop = request.headers.get("X-Shopify-Shop-Domain");
  if (!shop) return new Response(null, { status: 400 });

  // A retry of an old uninstall can arrive after the merchant reinstalled.
  // If we have authenticated this shop since the uninstall fired, ignore it.
  const triggeredAt = Date.parse(request.headers.get("X-Shopify-Triggered-At") ?? "");
  const record = await db.shop.findUnique({ where: { shop } });
  if (record && !Number.isNaN(triggeredAt) && record.lastAuthAt.getTime() > triggeredAt) {
    return new Response();
  }

  await db.session.deleteMany({ where: { shop } });
  await db.shop.updateMany({
    where: { shop },
    data: { uninstalledAt: Number.isNaN(triggeredAt) ? new Date() : new Date(triggeredAt) },
  });
  return new Response();
};

The length check before timingSafeEqual matters, because that function throws on buffers of different lengths, and a malformed header should be a 401, not a crash. A missing X-Shopify-Triggered-At skips the reinstall check rather than failing the delivery, since a 400 would only buy you eight more retries.

How do you tell a stale uninstall from a real one?

Record when you last authenticated each shop. The afterAuth hook runs after every token exchange, and a merchant cannot open an app they have uninstalled to start one, so any lastAuthAt later than the webhook’s trigger time means the merchant came back.

// app/shopify.server.ts (inside shopifyApp({ ... }))
hooks: {
  afterAuth: async ({ session }) => {
    await db.shop.upsert({
      where: { shop: session.shop },
      create: { shop: session.shop, lastAuthAt: new Date() },
      update: { lastAuthAt: new Date(), uninstalledAt: null },
    });
  },
},

The template’s Prisma schema has no shop table, so add one:

model Shop {
  shop          String    @id
  lastAuthAt    DateTime
  uninstalledAt DateTime?
}

X-Shopify-Triggered-At comes from Shopify’s clock and lastAuthAt from yours. A few seconds of skew does not matter here, because nobody reinstalls an app a few seconds after removing it.

What should the uninstall handler delete?

Sessions, and nothing the merchant would miss. Their settings, their content and anything they paid you to build stay until shop/redact, which arrives 48 hours after the uninstall and is the point at which you are actually asked to erase the shop’s data. A merchant who uninstalled by mistake and reinstalls the next morning should find everything where they left it.

You also do not need to cancel billing or tidy up subscriptions. Shopify’s subscription billing guide says “When a merchant uninstalls your app, their app subscription is automatically canceled.” If you are moving an existing install base onto expiring tokens, our guide to Shopify’s expiring offline tokens covers the migration that puts every one of those shops into the window where this bug bites.

How we tested this

We installed @shopify/shopify-app-react-router 4.0.1 (with @shopify/shopify-api 15.1.0) on Node 24.12.0 and ran the template’s handler and the one above through the real shopifyApp and authenticate.webhook, with memory session storage, signed deliveries, and the afterAuth hook above triggered by an admin request carrying a signed ID token. Shopify’s token endpoint was simulated: refresh calls got a 401 invalid_request, and token exchange returned a new token. We did not run this against a live store. The Prisma calls ran against an in-memory stub with the same method signatures, and the schema above was not migrated.

The template handler returned 500 for a shop with an expired token and kept the session, and returned 200 when the token was still fresh. The handler above returned 200 for the expired shop, deleted the session and made no token calls. It returned 401 for a bad signature. Replaying an old uninstall after a simulated reinstall deleted the new session under the template handler and left it alone under ours.

What we would do

Ship the session-free handler now, for app/uninstalled and the three compliance topics, and keep it after PR #3443 or another fix lands. A handler that never touches the token endpoint cannot be broken by a change to how tokens refresh. To prove the bug on your own app first, install it on a dev store, leave it untouched for more than an hour, uninstall, and look at the delivery in your app’s Logs page.

Whoooop builds and maintains Shopify apps on the React Router stack, and the webhook and token handling covered here is a routine part of that Shopify development work.

Need this built properly?

Get in touch