Checkout UI extension on api_version = "2026-04" — fetch from extension throws "Fetches to the iframe's origin are not allowed"

Hi all — I’m building a checkout UI extension on api_version = "2026-04" (Preact + web components) that needs to call my app’s backend to check the buyer’s custom role before allowing them through checkout. In local dev (shopify app dev with a stable Tailscale tunnel), every fetch from inside the extension fails with:

Error: Fetches to the iframe's origin are not allowed.

I’ve narrowed it down enough to be confident the code itself is OK — I’m hoping someone can point me at the correct pattern for 2026-04 checkout extensions calling their own backend in dev.

What the extension does (minimal)

tsx

import '@shopify/ui-extensions/preact';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { useBuyerJourneyIntercept } from '@shopify/ui-extensions/checkout/preact';

declare const shopify: any;

export default function extension() {
  render(<Extension />, document.body);
}

function Extension() {
  const [status, setStatus] = useState<'loading' | 'allow' | 'block'>('loading');
  const companyId = shopify?.buyerIdentity?.purchasingCompany?.current?.company?.id;

  useEffect(() => {
    (async () => {
      try {
        const token = await shopify.sessionToken.get();
        const base = new URL(shopify.extension.scriptUrl).origin; // <-- tunnel origin
        const res = await fetch(`${base}/api/get-role`, {
          headers: { Authorization: `Bearer ${token}`, 'X-Company-Id': companyId },
        });
        const { customRole } = await res.json();
        setStatus(customRole === 'Buyer 1' ? 'block' : 'allow');
      } catch (e) {
        console.log('fetch failed', e); // <-- "Fetches to the iframe's origin are not allowed"
        setStatus('allow');
      }
    })();
  }, [companyId]);

  useBuyerJourneyIntercept(({ canBlockProgress }) =>
    canBlockProgress && status === 'block'
      ? { behavior: 'block', reason: 'Approval required', errors: [{ message: 'Approval required' }] }
      : { behavior: 'allow' }
  );
  return <></>;
}

Extension TOML

toml

api_version = "2026-04"

[[extensions]]
type = "ui_extension"
handle = "checkout-approval-gate"

[[extensions.targeting]]
module = "./src/Extension.tsx"
target = "purchase.checkout.actions.render-before"

[extensions.capabilities]
api_access = true
network_access = true
block_progress = true

[[extensions.metafields]]
namespace = "my_namespace"
key = "approval_id"

Confirmed working

  • Extension iframe origin (from console errors) is https://extensions.shopifycdn.com.

  • companyId resolves correctly (gid://shopify/Company/...).

  • useBuyerJourneyIntercept fires with canBlockProgress: true — the gate IS active in the checkout editor with Allow app to block checkout turned on.

  • The extension is correctly placed in the checkout editor and rebuilds on save.

  • The bundle is well under 64 KB.

  • shopify.appMetafields.value returns [] (expected — no approval_id set yet on a fresh cart).

What I’ve tried

  1. Direct fetch to <tunnel>/api/get-role — fails with Fetches to the iframe's origin are not allowed. The request never reaches my backend.

  2. App proxy — added [app_proxy] to shopify.app.toml with my tunnel URL + subpath, called https://<shop>.myshopify.com/apps/<subpath>/get-role/app-proxied. Hit CORS issues

  3. Downgrading api_version to 2026-01 — same Fetches to the iframe's origin are not allowed error, so this isn’t strictly a 2026-04-only thing.

A working checkout-blocker-limited-buyer extension in our other project (GIC) does exactly this same flow on api_version = "2025-07" (React + non-web-components) without any issues, so something changed in the sandbox between then and 2026-01+.

Setup

  • Shopify CLI 3.94.3

  • Node 22

  • Stable HTTPS tunnel (Tailscale Funnel) so the URL doesn’t rotate between restarts

  • Dev store with B2B enabled, app installed, checkout extension activated in the editor with block_progress = true

Questions

  1. In 2026-04+ checkout UI extensions, what’s the supported pattern for calling the app’s own backend from the extension in dev mode? Is there a CLI flag or shopify.app.toml block I’m missing that makes the iframe origin distinct from the backend origin in dev?

  2. If app proxy is the answer, what’s the correct way to register it for a checkout UI extension (vs. a storefront block)? The docs I found mostly cover storefront usage.

  3. Is there a way to add CORS headers / allowed origins so direct cross-origin fetches from extensions.shopifycdn.com to the dev tunnel work?

Any pointers appreciated — happy to share more code or repro.

Thanks!

Hey,

const base = new URL(shopify.extension.scriptUrl).origin; // <-- tunnel origin

Can you log the base and confirm that this is pointing to your tunnel URL and not to extensions.shopifycdn.com when the request is not working?

What you can also use, is is something like:

const base =   process.env.APP_URL || 'https://your-production-url.com

The env will be set for dev, but iirc not for production builds at the moment, so you’d need to hardcode this yourself.

Using shopify.extension.scriptUrl won’t ever work in production as this will point to the extension cdn origin and not your app’s backend.

Thanks!