Developer guide
Checkout UI Extensions with a custom backend architecture
A practical backend architecture guide for Shopify Checkout UI Extensions, covering when to call your own backend, how to keep the extension thin, and where teams overbuild the server side.
Why thin checkout extensions age better
Checkout is not a general frontend shell. It is a constrained runtime with explicit
targets, explicit capabilities, and a security model that is much stricter than the
average app page. Shopify's docs lean into that design: checkout extensions run in a
Web Worker, capabilities such as api_access, network_access, and
block_progress must be declared, and the platform exposes checkout-native APIs
directly in the extension runtime.
That is why thin checkout extensions age better. They let checkout do checkout things, and they ask your backend only the questions your backend is uniquely qualified to answer. The extension becomes a small policy consumer, not a tiny SPA that developed a dependency problem and now calls home for emotional support every 400 milliseconds.
"UI extensions run in a Web Worker."
This matters architecturally. A checkout extension is not the right place to assemble a sprawling data graph from your server, compute six derived states, and then decide what the buyer is allowed to do. If the app needs durable enforcement, Shopify Functions are the stronger primitive. Shopify explicitly says validation functions run on Shopify's servers and can block checkout progress when business rules are not met.
"Validation functions run on Shopify's servers and can block checkout progress."
The working rule
The extension should render UI, collect narrow input, and ask your backend for app-owned decisions. It should not become a second checkout engine.
What this backend is actually for
The best checkout backends are boring in the best way. They answer a small number of important questions well. They do not act like a generic BFF layer for everything in checkout.
In practice, a custom backend behind a Checkout UI Extension should usually own four jobs.
Policy evaluation. Decide whether some app-owned condition is true, such as whether a buyer is eligible for a program, whether a merchant feature is enabled, or whether a checkout add-on should be offered.
Persistence. Save app-specific data that must live outside the current checkout session, such as a risk review record, compliance acknowledgment, offer audit trail, or deferred fulfillment preference.
Orchestration. Trigger the rest of your system, such as background jobs, external service calls, or internal domain workflows that should never be embedded into extension code.
Precomputed state serving. Return app-owned data that was prepared ahead of time and cannot be read directly from Shopify in the extension surface.
Notice what is missing from that list: "everything the UI might possibly want to know." That is how teams accidentally build a checkout-flavored micro-frontend platform, which is a phrase that should make any adult feel tired.
Examples that belong on your backend
- Checking an app-owned entitlement table for premium checkout behavior.
- Resolving merchant-specific rules stored in your own database.
- Recording buyer input that must be audited later.
- Calling a third-party compliance or risk provider.
- Creating idempotent commands for downstream systems after a buyer action.
Examples that usually do not
- Reading cart lines that the extension can already access.
- Fetching product data that the Storefront API can already supply.
- Reading configuration that could have been written to metafields ahead of checkout.
- Returning every label, color, and branch decision from a remote endpoint.
What should not go through your backend
Shopify's configuration docs are surprisingly clear here. Before you request network access, Shopify tells you to consider alternatives. One of the biggest is metafields. If your app can write shop, product, or customer metafields ahead of checkout through the Admin API, then checkout can often read what it needs without a live round trip to your server.
"Instead of fetching data with an external network call, consider retrieving the data from a metafield."
Shopify also gives checkout extensions a first-party path to the Storefront API via
api_access. The docs explicitly recommend it for product data, tags,
collections, price conversions, and similar read use cases, with authentication handled
by Shopify. That is a strong signal: if the data already lives on Shopify and the
extension can access it safely, piping it through your backend is often extra latency for
no business value.
| Need | Best source | Why | Main caution |
|---|---|---|---|
| Cart lines, totals, buyer journey context | Checkout extension APIs | Native, immediate, no extra server hop | Respect target and capability limits |
| Product tags, listings, collections, metaobjects | Storefront API via api_access | Shopify handles auth for the extension | Use only the scopes and fields available to this surface |
| Stable config known before checkout | Metafields | Precomputed state beats live dependency chains | Keep write-time sync disciplined |
| App-owned policy or off-platform state | Your backend | That is your system's job | Authenticate requests and keep payloads narrow |
| Hard enforcement of checkout rules | Shopify Functions | Runs on Shopify's side and can reliably block | Do not confuse UI messaging with enforcement |
A useful test is this: if your backend went down for five minutes, what would the buyer lose? If the honest answer is "the extension can no longer decide whether a legal or operationally critical rule applies," then you probably put an enforcement concern in the wrong layer.
The request flow to standardize on
When you genuinely need your own backend, standardize on a narrow action-oriented request
flow. Shopify exposes a session token API on checkout targets, and the target docs say to
call sessionToken.get() whenever you need to make a request to your backend.
Cached tokens are returned when possible, so the extension does not need a homemade token
cache with three edge cases and an identity crisis.
"You should call this method every time you need to make a request to your backend."
The standard request shape looks like this.
- The extension reads Shopify-native context locally.
- The extension asks for a fresh session token.
- The extension calls one action-specific backend endpoint.
- The backend verifies the token and resolves app-owned policy.
- The backend returns a small deterministic payload.
- The extension renders a result or graceful fallback.
1. Declare only the capabilities you actually need
api_version = "2026-01"
[[extensions]]
type = "ui_extension"
name = "Checkout policy banner"
handle = "checkout-policy-banner"
[[extensions.targeting]]
target = "purchase.checkout.block.render"
module = "./Checkout.jsx"
[extensions.capabilities]
api_access = true
network_access = trueShopify's docs say network_access must be requested in the Partner Dashboard
to publish the extension, and the capability must also be set in
shopify.extension.toml. Use it intentionally. Asking for it "just in case" is
usually a sign that architecture decisions have not happened yet.
2. Fetch a session token at call time
import {reactExtension, Banner, useApi} from '@shopify/ui-extensions-react/checkout';
import {useEffect, useState} from 'react';
export default reactExtension('purchase.checkout.block.render', () => <Extension />);
function Extension() {
const api = useApi();
const [state, setState] = useState({loading: true, message: '', status: 'info'});
useEffect(() => {
let cancelled = false;
async function load() {
try {
const token = await api.sessionToken.get();
const response = await fetch('https://app.example.com/api/checkout/policy/resolve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
checkoutToken: api.checkoutToken?.current,
}),
});
if (!response.ok) {
throw new Error(`Policy request failed with ${response.status}`);
}
const payload = await response.json();
if (!cancelled) {
setState({
loading: false,
status: payload.status,
message: payload.message,
});
}
} catch (_error) {
if (!cancelled) {
setState({
loading: false,
status: 'warning',
message: 'We could not load app-specific checkout info right now.',
});
}
}
}
load();
return () => {
cancelled = true;
};
}, [api]);
if (state.loading) {
return <Banner status="info">Checking app policy…</Banner>;
}
return <Banner status={state.status}>{state.message}</Banner>;
}Keep the request payload boring. Send only what your backend cannot derive from trusted claims or from Shopify APIs your server can call itself. A small payload is easier to validate, easier to reason about, and harder to accidentally turn into a security bug.
3. Make CORS boring too
Shopify's docs state that UI extensions run in a Web Worker and the exact origin may
change without notice. Because of that, your server must support CORS for any origin and
always return Access-Control-Allow-Origin: * for extension requests. If you
send Authorization and JSON, you will also need a standard preflight-friendly
response for methods and headers on your side.
"The exact origin they run on may change without notice."
A Rails backend shape that stays sane
In Rails, treat checkout extension endpoints as command or policy endpoints, not as a generic namespace that gradually turns into a second application. One endpoint should do one thing. It should verify the session token, hydrate the app context, resolve a single policy, and return a compact response.
A route and controller with one job
# config/routes.rb
namespace :api do
namespace :checkout do
post "policy/resolve", to: "policies#resolve"
end
end# app/controllers/api/checkout/policies_controller.rb
class Api::Checkout::PoliciesController < ActionController::API
before_action :handle_preflight
before_action :set_cors_headers
before_action :authenticate_checkout_extension!
def resolve
result = Checkout::PolicyResolver.new(
shop_domain: current_shop_domain,
customer_gid: current_customer_gid,
checkout_token: params[:checkoutToken]
).call
render json: {
status: result.banner_status,
message: result.message,
eligible: result.eligible,
code: result.code
}
rescue Checkout::PolicyResolver::UnknownShop
render json: {error: "Unknown shop"}, status: :unauthorized
end
private
def handle_preflight
return unless request.options?
set_cors_headers
head :no_content
end
def authenticate_checkout_extension!
token = request.authorization.to_s.delete_prefix("Bearer ").presence
raise ActionController::BadRequest, "Missing bearer token" if token.blank?
payload, = JWT.decode(
token,
ShopifyApp.configuration.secret,
true,
algorithm: "HS256",
verify_aud: true,
aud: ShopifyApp.configuration.api_key
)
@session_payload = payload.deep_symbolize_keys
raise ActionController::BadRequest, "Expired token" if Time.at(@session_payload[:exp]) <= Time.now
raise ActionController::BadRequest, "Missing destination" if @session_payload[:dest].blank?
rescue JWT::DecodeError => e
render json: {error: "Invalid session token", detail: e.message}, status: :unauthorized
end
def current_shop_domain
URI.parse(@session_payload.fetch(:dest)).host
end
def current_customer_gid
@session_payload[:sub]
end
def set_cors_headers
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
response.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
end
endThe important part is not the exact JWT helper you use. The important part is the trust model. Trust token claims after verification. Do not trust random client parameters when those claims already tell you the shop or customer identity.
Shopify's config docs explicitly warn that a session token guarantees the integrity of its
claims, but not that the HTTP request itself originated from Shopify. That means your
endpoint design still matters. A verified token can justify trusting a subject claim. It
does not justify exposing a dangerous endpoint like /get-discount-code that
happily hands out value to anyone who can satisfy your minimal conditions.
"It does not guarantee the request itself originated from Shopify."
Prefer an explicit service boundary
# app/services/checkout/policy_resolver.rb
module Checkout
class PolicyResolver
Result = Struct.new(:eligible, :message, :banner_status, :code, keyword_init: true)
UnknownShop = Class.new(StandardError)
def initialize(shop_domain:, customer_gid:, checkout_token:)
@shop_domain = shop_domain
@customer_gid = customer_gid
@checkout_token = checkout_token
end
def call
shop = Shop.find_by!(shopify_domain: @shop_domain)
return Result.new(
eligible: false,
banner_status: "info",
code: "feature_disabled",
message: "This checkout feature is not enabled for this merchant."
) unless shop.checkout_policy_enabled?
return Result.new(
eligible: false,
banner_status: "warning",
code: "customer_missing",
message: "We could not verify buyer eligibility for this feature."
) if @customer_gid.blank?
eligibility = Eligibility::CheckoutProgram.new(shop: shop, customer_gid: @customer_gid).call
if eligibility.allowed?
Result.new(
eligible: true,
banner_status: "success",
code: "eligible",
message: "You are eligible for this checkout option."
)
else
Result.new(
eligible: false,
banner_status: "info",
code: eligibility.reason,
message: eligibility.message
)
end
rescue ActiveRecord::RecordNotFound
raise UnknownShop
end
end
endThis is the right kind of boring. The controller handles HTTP and authentication. The service resolves business logic. The extension receives a tiny payload. Nobody is trying to serialize half the company's ontology into checkout.
If you are on Remix
Shopify also provides a dedicated helper for authenticating checkout extension requests in
the Remix package: authenticate.public.checkout. Even if you are a Rails team,
that official helper is useful as a reference for the exact trust boundary Shopify expects
on this surface.
How to structure side effects and retries
Checkout code should assume retries, reloads, duplicate clicks, and inconsistent timing. The extension runtime is not malicious, but the network is a goblin and should be treated accordingly.
That means your backend endpoints should be either read-only or idempotent. If a buyer action creates durable state, put that behind an idempotency key and make duplicate requests harmless.
# app/services/checkout/record_acknowledgement.rb
module Checkout
class RecordAcknowledgement
def initialize(shop:, customer_gid:, idempotency_key:, payload:)
@shop = shop
@customer_gid = customer_gid
@idempotency_key = idempotency_key
@payload = payload
end
def call
CheckoutAcknowledgement.find_or_create_by!(
shop: @shop,
customer_gid: @customer_gid,
idempotency_key: @idempotency_key
) do |record|
record.payload = @payload
record.recorded_at = Time.current
end
end
end
endDo not make the extension responsible for deduplication strategy. Let it send a stable intent key and let the backend own correctness.
Differentiate UI blocking from durable enforcement
Shopify supports checkout UI extension behavior like blocking progress when the extension
has the block_progress capability and the merchant has allowed it. But Shopify
also increasingly pushes developers toward Functions for reliable validation. In January
2026, Shopify's changelog explicitly recommended building custom checkout validation with
Cart and Checkout Validation Functions rather than Checkout UI Extensions because they are
more secure, more performant, and guaranteed to run across supported checkouts.
That is the architectural line to keep straight:
Extension UI: communicate, collect, guide, and sometimes request block behavior in the supported buyer journey APIs.
Your backend: resolve app-owned state and persist app-owned actions.
Functions: enforce rules that must hold even when your backend or UI is having a bad day.
If you blur these layers, you end up with the classic architecture smell: the buyer sees a stern banner that says something is forbidden, but the actual checkout rule is enforced by hope, timing, and a fetch call to a sleepy server in another region.
What teams overbuild
The common overbuilt version of this architecture usually has five symptoms.
A generic extension API. One endpoint becomes many concerns, then many concerns become a parallel backend surface that nobody can reason about safely.
Too many round trips. One request for config, one for eligibility, one for labels, one for a button state, one for the weather on Mars. Checkout is not your place to practice distributed systems fan fiction.
Server-rendered UI decisions. The extension asks the backend what text to show, what branch to take, and what the local state means, instead of treating the backend as a narrow policy provider.
Misplaced enforcement. A team relies on a backend response to "forbid" a checkout action that really needs Function-level enforcement.
App proxy habit transfer. Someone copies a storefront or theme pattern into checkout and discovers that app proxies in checkout have extra caveats, including CORS, no
logged_in_customer_id, and lack of support on password-protected shops.
Shopify's docs spell out those app proxy caveats very directly. Checkout extension requests
to an app proxy are CORS requests, they do not get logged_in_customer_id, and
password-protected shops are not supported because the extension's Web Worker does not
share the parent window session.
So yes, app proxies can still exist in your universe. No, they should not be your default mental model for Checkout UI Extensions.
The architecture decision table
When a team is unsure whether a checkout extension should call the backend, this is the rubric I would standardize on.
| Question | If yes | If no |
|---|---|---|
| Does Shopify already expose the data in extension APIs? | Read it locally in the extension. | Continue down the table. |
| Can the Storefront API provide the data safely? | Use api_access, not your backend. | Continue down the table. |
| Can the data be written ahead of checkout as metafields? | Precompute it and read it in checkout. | Continue down the table. |
| Is the logic app-owned, off-platform, or dependent on your own DB? | Call your backend through a narrow endpoint. | Continue down the table. |
| Must the rule be durably enforced during checkout? | Use Shopify Functions or another supported Shopify primitive for enforcement. | The backend can remain advisory. |
Most healthy architectures settle into this shape:
- Checkout UI Extension for native context and buyer interaction.
- Storefront API for Shopify-owned product or catalog reads.
- Metafields for precomputed merchant or product state.
- Custom backend for app-owned policy, persistence, and orchestration.
- Shopify Functions for real enforcement.
That stack is easier to test, easier to explain to merchants, and much less likely to turn future you into a detective investigating why checkout depends on four endpoints, two cookies, and one ancient decision made by someone who has since become a gardener.
Best internal links
Sources and further reading
Shopify Dev: Checkout UI Extensions API reference
Shopify Dev: Checkout UI extensions configuration
Shopify Dev: purchase.checkout.block.render target API
Shopify Dev: Storefront API in Checkout UI Extensions
Shopify Dev: About cart and checkout validation
Shopify Dev: Shopify Functions API reference
Shopify Dev: Test checkout UI extensions
Shopify Dev: Authenticate checkout extension requests in Remix
Shopify Changelog: Checkout UI extensions now default to non-blocking behavior
Source notes: checked against Shopify documentation and changelog on March 12, 2026. Checkout capabilities, network access requirements, enforcement guidance, and target behavior can evolve, so validate details against the current API version you ship.
FAQ
Should a Checkout UI Extension call my backend for most of its data?
Usually no. Pull checkout-native data from extension APIs, use Storefront API access when Shopify already exposes the data safely, and call your backend only for app-owned logic or persistence.
Can my backend enforce checkout rules by itself?
Not reliably. If the rule must block or guarantee checkout behavior, use Shopify Functions or supported checkout APIs. A backend can advise the extension, but durable enforcement belongs on Shopify's side.
Do I need session tokens for requests from Checkout UI Extensions?
Yes. Fetch a session token for each backend request, send it as bearer auth, and verify it server-side before trusting claims such as shop or customer identity.
Are app proxies the right default for checkout extensions?
No. They can work, but they come with checkout-specific caveats including CORS behavior, missing `logged_in_customer_id`, and unsupported behavior on password-protected shops.
Related resources
Keep exploring the playbook
Shopify Customer Account UI Extensions with a Rails backend
A backend architecture guide for Shopify Customer Account UI Extensions using Rails for app logic, persistence, and API orchestration without turning the extension into a fragile mini-app.
Calling a Rails API from a Shopify customer account extension
A practical guide to calling Rails endpoints from a Shopify Customer Account UI Extension, including session-token verification, endpoint design, and the requests that should not go through your backend at all.
Shopify session tokens in Rails
A Rails-focused guide to Shopify session tokens for embedded apps, covering the Shopify App gem, token exchange, frontend request flow, and the mistakes that still break production auth.