Calling External APIs from Customer Account UI Extensions: network_access, CORS, and Testing with Real Data [Tutorial]
Verified in August 2026 with Shopify CLI 3.90 and api_version 2026-04, in an authorized test environment with New Customer Accounts enabled. Throughout this article, we distinguish between behavior documented in the official docs (linked) and behavior that isn't documented but that we confirmed in testing as of August 2026.
Introduction: What to Check Before Calling an External API
There are times when you want to show data from a system outside Shopify on the customer account page: a membership tier, a points balance, or the progress of a process managed externally, for example. Customer Account UI Extensions (UIE from here on) can call external APIs directly from the extension, so in principle this kind of display can be built (capabilities).
Before starting the implementation, check whether the data you want to display from the external system can be placed in metafields instead. The official network access documentation itself directs you to consider metafields first, because data that can be written ahead of time doesn't need an external call, and you can leave display speed and reliability to Shopify. Reading and writing metafields is covered in Reading and Writing Past Order Data with Customer Account UI Extensions: When to Use Signals, GraphQL, and Metafields.
Even so, an external API becomes necessary in two cases: when you need a real-time decision, and when the source of truth lives in an external system and can't be fully synced. Once you know you're in one of these cases, the next decision is how to handle authentication, and using UIE doesn't by itself mean you need a backend. If you need to store secrets such as API keys, put a backend in place; if an unauthenticated public API returns the appropriate CORS headers, you can fetch it directly from UIE. The latter matches the official spec, and we confirmed the same behavior in a test store. The URL of an unauthenticated API that is already being called from the browser is public information, so wrapping it in a backend doesn't add any security.
1. How It Works: The null Origin and Access-Control-Allow-Origin
UIE runs inside a sandboxed Web Worker, isolated from the page's JavaScript. Requests sent from this environment carry no recognizable origin, the so-called null origin. This is why there are two requirements for calling external APIs; the official docs list one on the extension side and one on the server side.
On the extension side, you declare network_access under [extensions.capabilities] in shopify.extension.toml.
[extensions.capabilities]
network_access = true
On the server side, the response must return the following header.
Access-Control-Allow-Origin: *
Allowing only a specific origin isn't enough. A null origin has no value to match against, so the request is only accepted with the wildcard *. This is as documented, and the test store behaved the same way.
Returning * is not the same as leaving the API unprotected. The API's own defenses, such as returning only information that is safe to expose, validating input, and rate limiting, remain just as necessary with or without this header. The browser uses the header to decide whether to hand the response to JavaScript. What information the server returns is controlled separately, on the API side.
Declaring and Enabling network_access: toml and Partner Dashboard Settings
In addition to the toml declaration, enable "Allow network access in checkout and account UI extensions" in the app settings in the Partner Dashboard. According to the official documentation, this permission is approved automatically and the required approval scope is granted immediately.
In our test store in August 2026, the request UI described in the older documentation was nowhere to be found in the new Dev Dashboard, and deploy went through as-is with the declaration in toml. Automatic approval is what the official docs state; the missing request UI and the successful deploy are what we observed on screen at the time.
The call itself is a regular fetch. The following code is a minimal example that asks a decision API whether a perk from an external membership system can be used on the product being viewed, and displays the result in one of three states. The endpoint URL isn't hardcoded; it's received from the extension's settings as shopify.settings.value.benefit_endpoint. The assumption is that no secrets are placed in settings.
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useEffect, useState} from 'preact/hooks';
export default async () => {
render(<BenefitAvailability />, document.body);
};
function BenefitAvailability() {
const [state, setState] = useState({status: 'loading'});
useEffect(() => {
const controller = new AbortController();
const endpoint = shopify.settings.value.benefit_endpoint;
const productId = shopify.target.value.merchandise.product.id;
async function load() {
try {
const url = new URL(endpoint);
url.searchParams.set('product_id', productId);
const response = await fetch(url, {signal: controller.signal});
if (!response.ok) {
throw new Error(`Benefit API returned ${response.status}`);
}
const data = await response.json();
setState({status: 'ready', available: data.available === true});
} catch (error) {
if (error.name !== 'AbortError') {
setState({status: 'error'});
}
}
}
load();
return () => controller.abort();
}, []);
if (state.status === 'loading') {
return <s-text>Checking whether your perk can be used</s-text>;
}
if (state.status === 'error') {
return <s-text>We could not confirm whether your perk can be used.</s-text>;
}
return state.available
? <s-text>Your perk can be used on this product.</s-text>
: <s-text>Your perk cannot be used on this product.</s-text>;
}
When the component is torn down, the request is aborted with AbortController, and the resulting AbortError doesn't move the state to error. The product ID is taken from shopify.target.value.merchandise.product.id and passed as the product_id query parameter. If response.ok is false, the code throws Benefit API returned ${response.status} and falls to error; otherwise it decides based on data.available === true. There are three states, loading, ready, and error, and we come back to how these three states are handled as a design question in section 4.
A successful fetch doesn't mean you can trust the returned JSON as-is. When calling an external API, check the HTTP status, validate the fields you need, and prepare a UI for when the request fails.
2. Calling from UIE an External API the Theme Used to Call: Do You Need a Relay Backend?
A common situation when migrating from a theme is wanting to call the same external API from UIE that the theme's JavaScript was calling. At the design stage, it was easy to assume the call would be blocked by CORS from inside the sandbox and to plan for a backend relay.
In our August 2026 testing, an API that the theme (storefront) could call was also callable directly from UIE. That was because the server was configured to return Access-Control-Allow-Origin: *, which eliminated the need for a backend whose only purpose was relaying the external API. If an API could be called cross-origin from the theme, check its response headers. If it returns Access-Control-Allow-Origin: *, the server-side requirement for calling it directly from UIE is already met.
That said, not every API can use the same setup. APIs that allow only specific origins, and APIs that rely on cookies or secret credentials, can't meet the requirements from the previous section. Rather than deciding from the existing implementation alone, confirm with the following four steps.
- Check whether the external API requires secret credentials. If it does, relay it through a backend.
- Check whether the API's response returns
Access-Control-Allow-Origin: *. - Declare
network_access = truein toml and deploy. - In the actual UIE, verify the three patterns of success, business-rule error, and network failure on a real store.
3. Telling CORS Errors from 403s: Seeing Where the Request Stopped
When debugging external requests, investigate CORS errors and 403s separately. On screen, both look like a failed API call, but the request stopped in different places.
| What you see | What it means | Next step |
|---|---|---|
| CORS error (blocked by the browser) | The response lacks the required header, and the browser isn't handing the result to JavaScript | Check Access-Control-Allow-Origin on the server (and the preflight response, if needed) |
| HTTP 403 | The request reached some server layer and was rejected there | Check access controls such as per-endpoint authorization, IP restrictions, and WAF |
In our August 2026 testing, we hit a case where one API on a domain went through while only a different endpoint on the same domain returned 403. At first we read it as a CORS problem and kept revisiting the server's CORS configuration, without success. If JavaScript can see a 403, the request has at least reached the server side. Since it was rejected after arriving, the place to look is per-endpoint authorization rather than domain-wide configuration, including the possibility that the 403 is coming from a CDN or WAF layer.
Before you start isolating the problem, first confirm that the extension is rendering at all. When the entire extension shows nothing, the cause is sometimes a runtime error that happens before any request is made; how to read that situation is covered in Getting Started with Customer Account UI Extensions: From Scaffold to Testing on a Live Store.
4. Designing UI That Depends on an External API: Handling Actions When the Decision Isn't Available
In a UI that switches actions based on the result from an external API, you also need to decide how it behaves when that result can't be obtained. Taking as an example a UI that asks an external membership system whether a perk can be used and switches the order actions accordingly, we compare three approaches.
Fail-open, which allows the action even when the decision can't be made, carries the risk of an order going through with a perk applied that shouldn't have been usable. Fail-closed, which stops every action on the screen when the decision can't be made, disables actions that have nothing to do with the perk just because the external system didn't respond.
The approach we took in testing was to disable only the actions that depend on the external decision. Actions unrelated to the external decision remain available even when the request fails.
| Action | Depends on the external decision | On network failure |
|---|---|---|
| Order using the perk | Yes | Disabled, with a message that it couldn't be confirmed |
| Order without the perk | No | Remains available |
As in the minimal example, the state is split into loading, ready, and error, and a clickable button is never shown while loading, not even for an instant. If a button can be clicked before ready, you're allowing an action before the decision has arrived. A "not allowed" result and a failed request mean different things to the user, so we don't disguise an error as "the perk can't be used"; we treat it as a state where the decision couldn't be confirmed. Choosing the safe side doesn't require disabling features that don't depend on the external decision.
5. How to Test with Real Data
In UIE development, we learned through testing that what the reference says doesn't always work as written. Based on that experience, we adopted a procedure of checking against the schema first and then verifying on a real store. This procedure can also be included in the instructions handed to an AI agent.
Two Stages: Schema Validation and Verification on a Real Store
Before implementation, always run your GraphQL through schema validation. Detecting nonexistent fields and checking permission requirements in the official reference are tasks to finish at this stage. This is where you learn, for example, that customAttributes doesn't exist on Order in the Customer Account API. That matches the official type definitions, and the test store agreed. This is covered in detail in Reading and Writing Past Order Data with Customer Account UI Extensions: When to Use Signals, GraphQL, and Metafields.
After implementation, verify on a real store. Even a field that exists in the schema isn't guaranteed to return a value in the runtime for that target. In our testing, there was a case where a field present in the type definitions didn't appear in the signal, so we made the real store the final arbiter. The point of the two stages is to prevent the accident of designing on the basis of the reference and then finding that it doesn't work in practice.
Creating Test Data: A Three-Step Procedure Using draftOrder
Testing requires orders of specific shapes: a product with a quantity greater than one, a line item treated as partially canceled, an order with no properties, and so on. Rather than waiting for one to appear by chance in the admin, it's faster to synthesize the exact shape via the Admin API, and the same state can be reproduced. Create a draft order, convert it to an order, then add the attributes the display test needs to the order.
- Create a draft order with
draftOrderCreate. Specify products, quantities, and line item properties here. - Turn it into an order with
draftOrderComplete. - Add customAttributes to the order after the fact with
orderUpdate. This reproduces states such as "treated as canceled" or "status in the external system" as attributes for display testing; no actual cancellation is performed.
We don't use orderCreate, which creates orders directly, because permission constraints make it unavailable in many environments. Even with the draft-order route, on stores with price-rewriting apps installed, complete kept being rejected with "the presented price is invalid." Fixing the price on the draft with priceOverride before completing got it through, as confirmed on a test store in August 2026.
Creating test data also surfaces differences in inventory settings between environments. It's not unusual for a test store and production to differ in whether inventory is tracked or whether sales are allowed when out of stock, and building test data is a chance to notice these environmental differences.
Don't cram test cases into a single order. Split them into small orders where the expected result is uniquely determined: "product with quantity 2," "partial cancellation," "with and without status," "with and without properties." If everything is packed into one order, you can't isolate which condition caused a display to break. Create and update orders only in an authorized test environment.
Showing Branch UI: Reversible Changes on the Data Side
To bring branch UI such as "out of stock," "perk unavailable," or "decision unavailable" onto the screen, you can either plant debug flags in the code or temporarily change the data. To go through the same code path as production, creating the branch condition on the data side is the better fit: change a product metafield value, change a variant's inventory policy, and so on, to create the branch condition itself.
Before changing data, record the previous value, the target resource, and the restore procedure. After testing, restore the original values following that record.
The exception is reproducing a network failure; you can't simply take the external API down. Prepare a failure condition for testing and try it within a scope that doesn't affect other users.
Instructing AI to Do the Development: Handing Over the Verification Protocol
If you only say "build a UIE that calls an external API," the work ends once the code is generated, so include the testing approach from this article directly in the instructions.
Pre-checks:
network_access declaration / external API auth method / CORS response / required scopes
Test cases:
success / rejected by business rules / 403 / CORS error / timeout / invalid JSON / empty values
Expected UI:
what is shown in each of loading, ready, and error, and enabled/disabled state per action
Data changes:
target / values before and after / reversibility / restore procedure / confirm the connected store
Done when:
schema validated / verified on a real store / no secrets in logs / data restored
Include in the completion criteria verification on a real store, a report that separates the official spec from observed results, and restoring data after checking each branch. With real-store verification in the instructions, you avoid the situation where implementation is declared finished on the strength of the reference alone. Why the completion criteria extend to confirming rendering and behavior on a real store is covered in Getting Started with Customer Account UI Extensions: From Scaffold to Testing on a Live Store.
Series Wrap-Up: Three Decision Frames
Whether you need a backend is decided by your authentication policy for external dependencies. Display, adding to cart, carrying data forward, and saving customer input are all within what UIE can do on its own. A backend comes into consideration in three cases: when you need somewhere to keep secrets, when you write to orders automatically in response to a webhook, and when you write back legacy data.
Think of data access in three layers: signal, GraphQL, and metafields. What each layer can provide can't be designed from the reference alone, so build reconciliation against a real store into the process.
For navigating to the storefront, only href is available. Design around building URLs ahead of time, on the assumption that the URL is fixed by the time the link can be clicked. This is the official spec, and the test store behaved the same way.
A quick-reference table of what can and can't be done, along with a collection of use cases, is compiled in Adding Custom Features to Shopify's New Customer Account Pages: What UI Extensions Can and Can't Do.
FAQ
Q: Can I embed the external API's auth token in the extension?
No. Extension code is delivered to the browser, so any embedded secret is as good as public. Needing to call an authenticated API is precisely the condition for standing up a backend.
Q: If I get a 403, is it a CORS misconfiguration?
Treat it as a separate problem. The moment JavaScript can see a 403, the request has reached the server layer. Check the endpoint's authorization conditions, IP restrictions, and WAF. For a CORS error, start from the server's response headers.
Q: Metafields or an external API: which should I choose?
If the data to display can have its source of truth inside Shopify, use metafields. Reading them requires a toml declaration and access settings; the steps are in Reading and Writing Past Order Data with Customer Account UI Extensions: When to Use Signals, GraphQL, and Metafields. If the decision depends on the latest state in an external system as its source of truth, use the external API, and relay through a backend if authentication is required.