Shopify App Proxy Returns 404 and the Reason Is Never Obvious
The proxy path, the subpath prefix, and the actual route all have to line up. Here is the checklist and how to verify the signature properly.
Disclosure: I spent four years building Shopify themes and apps at Debutify, finishing as CDO.
The short answer
App proxy requests hit https://shop.myshopify.com/apps/<subpath>/whatever and Shopify forwards them to https://yourapp.com/<proxy-url>/whatever.
A 404 means one of four things:
- The proxy configuration in your app settings does not match your route
- Your route is not handling the full appended path
- You are checking the wrong HTTP method
- Your app is returning 404 for a signature failure without saying so
Verify the mapping first:
Subpath prefix: apps
Subpath: loyalty
Proxy URL: https://yourapp.com/proxy
storefront: /apps/loyalty/points
your app: /proxy/points
If your route is /proxy/points and Shopify is calling /proxy/loyalty/points, that is your 404.
How the path actually maps
This is where most of the confusion is.
Shopify strips the subpath prefix and the subpath, then appends the remainder to your proxy URL.
| Storefront request | Forwarded to |
|---|---|
/apps/loyalty |
https://yourapp.com/proxy |
/apps/loyalty/points |
https://yourapp.com/proxy/points |
/apps/loyalty/points/history?page=2 |
https://yourapp.com/proxy/points/history?page=2 |
So your app needs a route matching /proxy and everything under it. A route defined only at /proxy returns 404 for /proxy/points.
// Express: handle the whole subtree
app.use("/proxy", proxyRouter);
// not this, which only matches exactly /proxy
app.get("/proxy", handler);
In Next.js App Router, that means a catch-all:
app/proxy/[[...path]]/route.ts
The double bracket optional catch-all matches both /proxy and /proxy/anything/deep. A single [...path] does not match the bare /proxy, which is a subtle and common 404.
Verify the signature, and fail visibly
Every proxy request carries a signature query parameter. Requests without a valid one should be rejected, and the rejection should not be a bare 404 that looks identical to a routing problem.
import crypto from "node:crypto";
function verifyProxySignature(query, secret) {
const { signature, ...rest } = query;
if (!signature) return false;
// sort keys, join values with commas, concatenate as key=value with no separator
const message = Object.keys(rest)
.sort()
.map(key => {
const value = Array.isArray(rest[key]) ? rest[key].join(",") : rest[key];
return `${key}=${value}`;
})
.join("");
const digest = crypto.createHmac("sha256", secret).update(message).digest("hex");
const a = Buffer.from(digest, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Three things people get wrong here.
The joining rules differ from webhooks. Proxy signatures sort the keys, join array values with commas, and concatenate the key=value pairs with no separator between pairs. Webhook HMAC verification is a completely different computation over the raw body. Copying one implementation to the other does not work.
Remove signature before computing. Including it produces a mismatch every time.
Return 401, not 404. A signature failure and a routing failure producing the same status is why this is hard to debug:
if (!verifyProxySignature(req.query, process.env.SHOPIFY_API_SECRET)) {
return res.status(401).json({ error: "invalid proxy signature" });
}
That one change turns an ambiguous 404 into a definite answer. It is the error messages as interface point applied to a status code.
Returning Liquid instead of JSON
The genuinely useful feature of app proxies, and the one most people do not know about.
Set the content type and Shopify renders your response through the store's theme, with the theme's header, footer, and styles:
res.set("Content-Type", "application/liquid");
res.send(`
{% layout 'theme' %}
<div class="page-width">
<h1>Your points: ${points}</h1>
{% for reward in rewards %}
<p>{{ reward.title }}</p>
{% endfor %}
</div>
`);
Your response body is treated as Liquid, so you get access to theme objects and the store's design without rebuilding any of it.
Two constraints. You must escape any user data you interpolate, because you are generating template source. And Liquid syntax errors fail silently, so a malformed tag renders as nothing rather than erroring.
To return JSON instead, set Content-Type: application/json and Shopify passes it through unmodified.
The diagnostic sequence
1. Confirm what Shopify is calling. Log every request that reaches your app, including the ones you 404:
app.use((req, res, next) => {
logger.info({ method: req.method, path: req.path, query: req.query }, "incoming");
next();
});
If nothing arrives, the problem is between Shopify and you. If a request arrives at a path you do not handle, that is your answer immediately.
2. Check the proxy URL is reachable and HTTPS. Shopify will not forward to HTTP, and a self signed certificate fails. During development use a tunnel:
cloudflared tunnel --url http://localhost:3000
Then set the proxy URL to the tunnel address. Remember to update it when the tunnel restarts with a new hostname, which is a recurring five minutes lost.
3. Test the route directly, bypassing Shopify:
curl -i "https://yourapp.com/proxy/points?shop=test.myshopify.com&signature=x"
Expect 401 from your signature check. If you get 404, the route does not exist and Shopify is not involved.
4. Check the method. Proxy supports GET and POST. A form posting to a route defined only as GET is a 404 or 405 depending on your framework.
5. Watch for the trailing slash. /apps/loyalty/ and /apps/loyalty can resolve differently depending on your router's strict routing setting.
Things that catch people
The customer is not authenticated by default. The proxy passes logged_in_customer_id in the query string when a customer is logged in, and it is only trustworthy because the signature covers it. Never accept a customer id from a request body or an unsigned parameter.
Caching. Shopify may cache proxy responses. Set headers explicitly for anything personalised:
res.set("Cache-Control", "no-store, must-revalidate");
Serving one customer's points balance to another from a cache is a bad afternoon.
Timeouts. Shopify gives the proxy a limited window. A slow response produces an error page rather than a slow page. Anything that might take a while should return quickly and load the rest client side.
CORS does not apply. The request is server to server from Shopify's infrastructure, then rendered as part of the storefront. Same origin from the browser's perspective, so CORS configuration is irrelevant here, which confuses people who add CORS headers trying to fix a 404.
Query parameters are forwarded, path parameters are not special. Everything after the subpath is just path, and your router has to parse it.
A working skeleton
import express from "express";
const router = express.Router();
router.use((req, res, next) => {
if (!verifyProxySignature(req.query, process.env.SHOPIFY_API_SECRET)) {
return res.status(401).json({ error: "invalid proxy signature" });
}
req.shop = req.query.shop;
req.customerId = req.query.logged_in_customer_id || null;
next();
});
router.get("/", (req, res) => res.json({ ok: true, shop: req.shop }));
router.get("/points", async (req, res) => {
if (!req.customerId) {
res.set("Content-Type", "application/liquid");
return res.send("{% layout 'theme' %}<p>Please log in to see your points.</p>");
}
const points = await getPoints(req.shop, req.customerId);
res.set("Content-Type", "application/liquid");
res.send(`{% layout 'theme' %}<div class="page-width"><h1>${points} points</h1></div>`);
});
app.use("/proxy", router);
Signature check as middleware so no route can forget it, app.use so the whole subtree is handled, and 401 rather than 404 when verification fails.