Why your Shopify app's uninstall webhook returns 500
17 October 2026
Your app/uninstalled handler returns 500. Nothing appears in
your logs. The handler itself is fine, and if you retry a few minutes
later it sometimes works. Here is what is actually happening, why it
leaves no trace, and why it quietly breaks your GDPR erasure two days
later.
The symptom
On 1 September 2026, a genuine uninstall of my app produced four consecutive 500s between 15:24:37 and 15:27:39. No session rows were deleted, no shop was tombstoned, and the only evidence that anything had happened was the status code in Shopify's webhook delivery log. Nothing was written on my side.
I added a handleError export and the next uninstall
succeeded, on identical handler code. That is a misleading result, and it
took reproducing the failure locally to understand why.
Why nothing is logged
Two separate problems stack here, and the second hides the first.
React Router's entry.server only reports errors through
handleError. If you have not exported one, every unhandled
server error in production is invisible. That is worth fixing on its own,
and it was my bug rather than Shopify's.
But exporting handleError does not surface this particular
failure either. The Shopify SDK does not throw an Error here.
It throws a bare Response with status 500 and no body, and
React Router treats a thrown Response as control flow rather
than as a failure. It never reaches handleError at all. You
get a 500 on the wire and silence everywhere else.
The root cause
authenticate.webhook validates the HMAC and then, before
handing the request back to you, calls
ensureValidOfflineSession. With the
future.expiringOfflineAccessTokens flag enabled, that helper
refreshes any offline token within five minutes of expiry.
On app/uninstalled that refresh cannot succeed. The app has
just been uninstalled, so the token it is trying to exchange is already
dead. The refresh fails, the SDK converts the failure into a 500, and your
handler is never invoked.
This is why my second attempt appeared to work. That session was two minutes old, nowhere near its expiry window, so no refresh was attempted. The change I had just shipped had nothing to do with it.
That near-miss is the part worth remembering. A change deployed immediately before a success looks like the cause of the success. Reproducing the original failure locally is what separated the two. Without it I would have shipped a logging change and believed the bug was closed.
Why it is worse than one failed webhook
This is not a development-store quirk. It fires for any merchant whose offline token happens to be near expiry when they uninstall, which skews towards dormant shops, which are exactly the shops most likely to uninstall.
And it compounds. Shopify sends shop/redact 48 hours after an
uninstall. That request finds the same stale session, hits the same
refresh, fails the same way, and the GDPR erasure you are required to
perform silently never runs. You will not find out from your logs,
because there are none.
The fix
None of my webhook handlers need the session. Three never read it and the fourth tolerates its absence. So an offline token that cannot be refreshed should not cost me the delivery.
On a 500, drop exactly the sessions that would have triggered a refresh, then retry once:
const retry = request.clone(); // the first attempt consumes the body
try {
return await authenticate(request);
} catch (error) {
// 401, 400 and 405 are the SDK rejecting the request.
// They are correct answers and must reach Shopify unchanged.
if (!(error instanceof Response) || error.status !== 500) throw error;
const shop = request.headers.get("x-shopify-shop-domain");
if (!shop) throw error;
const dropped = await dropStaleSessions(shop);
// Nothing stale means this 500 was something else. Rethrow rather
// than retry into the same wall.
if (dropped === 0) throw error;
return await authenticate(retry);
}
The selection matters as much as the retry:
prisma.session.deleteMany({
where: {
shop,
refreshToken: { not: null },
expires: { lt: new Date(Date.now() + REFRESH_WINDOW_MS) },
},
});
REFRESH_WINDOW_MS is five minutes, mirroring the SDK's own
constant. A session with no refresh token is left alone, because the SDK
would not have touched it either. Deleting rather than patching is
deliberate: an offline token that cannot be refreshed is already useless,
and an embedded app rebuilds the session silently through token exchange
on the merchant's next admin request.
The security question you should be asking
This code reads x-shopify-shop-domain, a request header, and
deletes rows based on it. That deserves scrutiny.
The argument it rests on is that the SDK validates the HMAC before it loads any session, and rejects a bad signature with 401 or 400. A 500 can therefore only occur on a request already proven authentic. The header is never trusted for the work itself: it selects which stale rows to drop and nothing else, and the retry re-validates the signature from scratch against an untouched clone of the request.
That is an assumption about somebody else's code, so it is pinned by a test. If a future SDK version ever loads the session first, the test fails and the argument gets re-examined rather than quietly becoming false. I verified the behaviour across all five webhook topics, and an unsigned or forged request still returns 400 or 401 and deletes nothing.
How to reproduce it locally
Webhooks are unreachable from the UI, which is most of why this was
expensive to find. Two scripts made it tractable: one that serves the
production build locally, and one that posts a correctly signed webhook at
it. Backdate a session row so it expires within five minutes of now, leave
its refresh token in place, and send app/uninstalled. You
will get the 500.
One more thing that made this harder than it needed to be:
fly logs is a rolling buffer of tens of minutes, not a
history. The original failure window had aged out before I went looking,
and an incident discovered the next morning would have left nothing at
all. If your logs are not persisted somewhere, your first production
mystery will be unsolvable.
The outcome
A genuine uninstall now logs what it actually did:
APP_UNINSTALLED ... deleted 1 session(s), tombstoned 1 shop(s)
Returns 200, the quotes survive, and a reinstall clears the tombstone.
I am Jack Petersen, and I build Counterline, a B2B quoting app for Shopify (App Store listing). I also wrote up what Shopify actually does with B2B catalog prices on draft orders, which turned out to be the opposite of what everyone repeats.