A checkout UI extension fetch works once three things line up: network_access = true in the extension config, with access granted in the Partner Dashboard; a token from shopify.sessionToken.get() sent as a bearer header; and Access-Control-Allow-Origin: * on every response from your server, including preflights and 401s.
Shopify’s React Router library handles most of that for you, but not the 401s.
The samples below target API version 2026-10 and @shopify/shopify-app-react-router 4.0.0, current as of November 2026.
Do you need network access at all?
Often not. If the data can be written ahead of checkout, Shopify’s capabilities guide suggests storing it in a metafield and reading that from the extension, which adds no external call and leaves uptime to Shopify. We walked through that route in reading cart metafields from an extension.
Reach for fetch when the answer depends on something only your server knows at that moment: a loyalty balance, a live stock check against an ERP, a price from a third party. If you only want product data, api_access and shopify.query cover the Storefront API with no backend involved. Our overview of what runs where in checkout compares the capabilities.
How do you enable fetch in a checkout UI extension?
Two steps, and you need both. In the Partner Dashboard, open your app, click API access requests, then Allow network access under “Allow network access in checkout UI extensions”. Shopify’s guide says the request is approved automatically. Then declare the capability:
api_version = "2026-10"
[[extensions]]
type = "ui_extension"
name = "checkout-offer"
handle = "checkout-offer"
[extensions.capabilities]
network_access = true
[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.checkout.block.render"
Skip the dashboard step and the extension cannot be published with that capability set.
What should the extension send?
A fresh token on every request. The Session Token API gives tokens a five-minute lifetime and caches them internally, so calling get() before each fetch costs little. Storing the string yourself only adds a way to send an expired one.
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useEffect, useState} from 'preact/hooks';
const OFFER_URL = 'https://api.example.com/checkout/offer';
export default function extension() {
render(<Extension />, document.body);
}
function Extension() {
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
async function loadOffer() {
try {
const token = await shopify.sessionToken.get();
const response = await fetch(OFFER_URL, {
headers: {Authorization: `Bearer ${token}`},
signal: AbortSignal.timeout(3000),
});
if (!response.ok) return;
const data: {message: string} = await response.json();
setMessage(data.message);
} catch (error) {
// Checkout carries on without the offer.
console.error('Offer request failed', error);
}
}
loadOffer();
}, []);
if (!message) return null;
return <s-banner>{message}</s-banner>;
}
A failed call renders nothing and leaves checkout usable. The three-second timeout is our choice, not a Shopify limit; pick whatever you would tolerate a buyer waiting for.
Keep the URL absolute. A May 2026 forum thread reports Fetches to the iframe's origin are not allowed. from an extension deriving its base URL with new URL(shopify.extension.scriptUrl).origin. A reply there points out that scriptUrl resolves to the extension CDN, not your app, and will never work in production. The same reply suggests defining the URL in config, with the warning that the environment variable may only be set in development, so check what your production build actually gets.
Why does fetch fail with a CORS error?
Because the extension runs in a Web Worker whose origin, per Shopify, “may change without notice”. You cannot allowlist it, so the guide requires Access-Control-Allow-Origin: *.
Sending Authorization also makes the request non-simple, so the browser sends an OPTIONS preflight first. Your server must answer that without authentication and without redirecting. A redirect produces this one, reported on a password-protected development store:
Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.
Watch for trailing-slash redirects, HTTP-to-HTTPS redirects and auth middleware that runs ahead of your route.
Then there are errors. A browser hides the body and status of any cross-origin response missing the CORS header, so your extension never sees the 401. It sees a rejected promise, with a different message per engine:
Chromium: TypeError: Failed to fetch
Firefox: TypeError: NetworkError when attempting to fetch resource.
WebKit: TypeError: Load failed
So a wrong app secret in production, or an expired token, reads like a CORS misconfiguration. You will go looking in the wrong place unless the server logs tell you otherwise.
How do you verify the session token on the server?
On the React Router template, use authenticate.public.checkout. It answers preflights, verifies the token and hands you a cors wrapper. Per the docs, a POST route still needs a loader that calls it, because that is what responds to the preflight.
Reading the 4.0.0 source turned up three behaviours that page leaves out. The 401 it throws for a missing or invalid token has no CORS headers, which causes exactly the opaque failure above. Its preflight response sets no Access-Control-Allow-Methods, so GET and POST work while PUT and DELETE fail. And it returns 410 to user agents flagged as bots, so a headless Chromium test run gets 410 Gone where you expected a 401; set a normal user agent in those tests.
Wrap the call to fix the first:
import type {LoaderFunctionArgs} from 'react-router';
import {authenticate} from '../shopify.server';
export const loader = async ({request}: LoaderFunctionArgs) => {
try {
const {cors, sessionToken} = await authenticate.public.checkout(request);
return cors(Response.json({message: `Offer for ${sessionToken.dest}`}));
} catch (thrown) {
// The library throws its 401 with no CORS headers. Add one so the
// extension can read the status instead of a generic network error.
if (thrown instanceof Response) {
thrown.headers.set('Access-Control-Allow-Origin', '*');
}
throw thrown;
}
};
Not on React Router? Shopify points PHP and Python apps at verifyCheckoutUIExtReq and verify_checkout_ui_ext_req. On Node or Workers without the template, jose needs about 40 lines. The token is HS256, signed with your app secret, with your client ID in aud:
import {jwtVerify} from 'jose';
const secret = new TextEncoder().encode(process.env.SHOPIFY_API_SECRET);
const clientId = process.env.SHOPIFY_API_KEY;
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Max-Age': '7200',
};
function json(body: unknown, status = 200): Response {
return Response.json(body, {status, headers: corsHeaders});
}
export async function handleOffer(request: Request): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, {status: 204, headers: corsHeaders});
}
const token = request.headers.get('authorization')?.replace(/^Bearer /, '');
if (!token) return json({error: 'missing token'}, 401);
try {
const {payload} = await jwtVerify(token, secret, {
algorithms: ['HS256'],
audience: clientId,
clockTolerance: 10,
});
// Trust the signed claims, never a shop or customer ID from the URL.
return json({
message: `Offer for ${payload.dest}`,
loggedIn: Boolean(payload.sub),
});
} catch {
return json({error: 'invalid token'}, 401);
}
}
This one checks aud. The library passes checkAudience: false on this path and relies on the signature alone, so a token with another client ID but your secret gets through it.
We ran both servers against signed test tokens from Web Workers in Chromium, Firefox and WebKit: valid, wrong secret, expired, wrong audience and absent. The status codes and the three engine messages above come from those runs; the two quoted forum errors do not. A local harness is not a real checkout, so test on a development store before you ship.
What can you trust from the token?
Only its claims. dest is the shop, and sub is the customer GID when the buyer is logged in and your app may read customer accounts. Anonymous buyers have no sub, so a missing one is a normal case and not an error.
Everything else in the request is the buyer’s to edit. Shopify’s guide is blunt about it: trust sub, not a ?customer_id= parameter. Any buyer can copy their own valid token and call your endpoint directly, and the wildcard origin means any website can try too. Shopify’s example is a /get-discount-code endpoint, which would hand codes to anyone who asked. A valid token tells you the claims are genuine. It does not tell you the request came from your extension, so decide on the server whether this buyer should get this data.
Should you call through an App Proxy instead?
We would not. Requests from an extension still run as CORS requests, so nothing gets simpler. Shopify also lists two limits: extension requests get no logged_in_customer_id parameter, and password-protected shops are not supported at all, because the worker does not share the parent window’s session. That rules out any store still behind a password, development stores included if yours has one.
What we would do
Use a metafield if the data can be written ahead of time. If it cannot, call your backend directly on its own domain and keep the endpoint read-only where you can. Add the CORS header to every response before you write any business logic, so failures show up as real status codes from day one.
We would not use network_access for anything a buyer needs in order to finish paying. Your backend then becomes part of checkout’s uptime, and Shopify’s metafield route exists so it does not have to be.
If you want a second pair of eyes on a checkout build, our Shopify development work covers extensions, Functions and the app backend behind them.