Developer guide
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.
What this article is really about
The hard part is not making a fetch call from the extension. That part is almost insultingly easy. The hard part is deciding what the backend is allowed to trust, what the extension should ask for, and which requests should never touch Rails in the first place.
Customer account UI extensions run in a sandbox, have limited web API access, and use platform APIs instead of direct DOM access. When you add a Rails backend into that flow, the winning pattern is not “make the extension talk to our whole app”. The winning pattern is “give the extension a tiny, deliberate surface and let Rails own the real business logic”.
That distinction matters because Shopify explicitly warns that although your server can verify a session token, you still cannot assume the request itself is special or privileged just because it included one. In other words, the token is useful, but it is not a little crown that says “your majesty, please expose the discount codes”.
“Requests could originate from anywhere on the Internet.”
The working rule
Use the extension to collect trusted platform context plus the minimum business input. Use Rails to verify claims, authorize the action, and do the durable work.
The request shape to standardize on
Standardize on one shape for every authenticated call from the extension to Rails:
- The extension asks Shopify for a fresh session token.
- It sends that token as a bearer token in
Authorization. - It sends only the action payload that Rails genuinely needs.
- Rails derives identity and shop context from verified claims, not from free-form body fields.
Shopify’s own guidance is refreshingly blunt here. The session token API says you should call
get() every time you need to make a backend request, and that Shopify will return a cached
token when possible. That is a much healthier contract than inventing your own token cache and later
discovering that you have built a very small, very annoying time bomb.
“You should call this method every time you need to make a request to your backend.”
Extension code, the boring good version
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
async function createReturnRequest(orderId: string, reason: string) {
const token = await shopify.sessionToken.get();
const response = await fetch('https://app.example.com/customer-account/return-requests', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
orderId,
reason,
}),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
return <s-button onClick={() => createReturnRequest('gid://shopify/Order/123', 'Damaged')}>
Start return
</s-button>;
}Notice what is not in that payload: no shop, no customerId, no
“trust me bro” fields like isLoggedIn: true. If Rails needs shop context, it should get it
from the verified token. If Rails needs the customer identity, it should get it from the verified token
or from first-party extension APIs. The payload is for business input, not identity.
| Field | Who should supply it | Why |
|---|---|---|
Authorization: Bearer <jwt> | Extension | Rails uses it to verify the shop context and customer-related claims. |
orderId | Extension | It is business input, but Rails must still verify the customer can act on that order. |
reason, message, choice | Extension | These are user inputs, not identity claims. |
shop | Rails derives it | The token already contains the destination shop domain. |
customerId | Rails derives it | The optional sub claim is the trustworthy place to start. |
How Rails should verify and normalize the call
Shopify documents the important parts of the session token shape for customer account extensions:
the token is signed using your shared app secret, it includes the app client id in aud,
the shop domain in dest, a short-lived expiry, a unique jti, and an optional
sub claim containing the customer gid when the customer is logged in and your app has the
required access.
So the Rails job is straightforward in principle:
- Extract the bearer token.
- Verify the JWT signature with your app secret.
- Verify the audience matches your app client id.
- Read the destination shop from
dest. - Read the customer gid from
sub, if present. - Load your own shop record and build a normalized current-context object.
- Authorize the requested action against your own data model.
Do that once in a concern or base controller and every extension endpoint becomes simpler. You stop passing identity around like an office hot potato and you start working with a stable context object.
Rails routes
# config/routes.rb
namespace :customer_account do
match "*path", via: :options, to: "base#preflight"
post "return-requests", to: "return_requests#create"
post "support-conversations", to: "support_conversations#create"
get "order-app-state/:order_gid", to: "order_app_state#show"
endCORS base controller
# app/controllers/customer_account/base_controller.rb
class CustomerAccount::BaseController < ActionController::API
before_action :set_cors_headers
def preflight
head :ok
end
private
def set_cors_headers
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, OPTIONS")
response.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key")
response.set_header("Access-Control-Max-Age", "86400")
end
endJWT verification and context normalization
# app/controllers/concerns/require_customer_account_extension.rb
module RequireCustomerAccountExtension
extend ActiveSupport::Concern
included do
before_action :require_customer_account_extension!
end
private
CustomerAccountContext = Data.define(
:shop,
:shop_domain,
:customer_gid,
:claims,
)
def require_customer_account_extension!
token = bearer_token
raise JWT::DecodeError, "Missing bearer token" if token.blank?
payload, = JWT.decode(
token,
ENV.fetch("SHOPIFY_API_SECRET"),
true,
algorithm: "HS256",
aud: ENV.fetch("SHOPIFY_API_KEY"),
verify_aud: true,
leeway: 5,
)
shop_domain = payload.fetch("dest")
customer_gid = payload["sub"]
shop = Shop.find_by!(shopify_domain: shop_domain)
@customer_account_context = CustomerAccountContext.new(
shop:,
shop_domain:,
customer_gid:,
claims: payload,
)
rescue JWT::DecodeError, JWT::ExpiredSignature, ActiveRecord::RecordNotFound, KeyError
render json: {error: "Unauthorized"}, status: :unauthorized
end
def customer_account_context
@customer_account_context
end
def bearer_token
request.authorization&.delete_prefix("Bearer ")&.strip
end
endAuthorize the business action, not just the token
# app/controllers/customer_account/return_requests_controller.rb
class CustomerAccount::ReturnRequestsController < CustomerAccount::BaseController
include RequireCustomerAccountExtension
def create
order_gid = params.require(:orderId)
reason = params.require(:reason)
raise ActionController::BadRequest, "Customer claim missing" if customer_account_context.customer_gid.blank?
order = ShopifyOrderLookup.new(
shop: customer_account_context.shop,
order_gid:,
).call
unless order.customer_gid == customer_account_context.customer_gid
return render json: {error: "Forbidden"}, status: :forbidden
end
return_request = ReturnRequestCreator.new(
shop: customer_account_context.shop,
customer_gid: customer_account_context.customer_gid,
order_gid:,
reason:,
idempotency_key: request.headers["Idempotency-Key"],
).call
render json: {
id: return_request.id,
status: return_request.status,
}, status: :created
end
endThat is the main mindset shift. A verified token answers “who signed these claims?” and “which shop and customer are we probably dealing with?”. It does not answer “is this customer allowed to perform this exact action on this exact resource?”. That second question is your app’s job.
“This only guarantees the integrity of its claims.”
For high-consequence write endpoints, it is also worth using idempotency keys and recording enough audit information to explain later what happened. Not because Shopify is scary, but because networks are weird, browsers retry, humans double-click, and support tickets always arrive five minutes before lunch.
Which endpoints should exist
Good extension-facing endpoints are narrow and named after user actions. Bad endpoints are generic, transport-shaped, or suspiciously similar to “we exposed our internal app API and hoped for the best”.
In customer accounts, the extension usually lives close to a concrete customer workflow: request a
return, open a support thread, confirm a subscription action, load app-owned state for a specific order,
or move into a full-page flow tied to a single order. Shopify’s order page target is explicit about this:
if the page is tied to an order, use customer-account.order.page.render; if it is not,
use customer-account.page.render.
| Endpoint shape | Verdict | Why |
|---|---|---|
POST /customer-account/return-requests | Good | One action, one policy, one predictable payload. |
POST /customer-account/support-conversations | Good | Clear intent and easy to audit. |
GET /customer-account/order-app-state/:order_gid | Usually good | Reasonable when the extension needs app-owned state Shopify does not provide. |
POST /customer-account/graphql | Risky | You can do it, but you usually end up rebuilding authorization logic for every field. |
POST /customer-account/api with { action: ... } | Bad | Congratulations, you invented a future incident review. |
A practical endpoint menu
Create something durable. Example: return request, cancellation request, support case, reorder intent.
Load app-owned state. Example: per-order warranty status, bundle enrollment, internal workflow status, or third-party sync state.
Trigger an integration flow. Example: create a helpdesk ticket, sync to an ERP, generate a shipping label request, notify a service team.
Notice what is missing: endpoints whose sole job is to relay Shopify-owned facts that the extension can already access safely. Those are just latency machines wearing a fake moustache.
CORS, network access, and why app proxies get weird
External calls from customer account UI extensions are not available by accident. Shopify requires two things:
- network access approval in the Partner Dashboard
network_access = trueinshopify.extension.toml
Shopify’s configuration docs also spell out the CORS catch that bites a lot of teams. UI extensions run
in a Web Worker and therefore have a null origin. Your server must allow that cross-origin
request pattern by returning Access-Control-Allow-Origin: *. If you lock your backend to the
storefront origin or some admin domain you copied from an old tutorial, you get the classic “works in
Postman, explodes in the extension” experience.
api_version = "2026-01"
[[extensions]]
name = "Order tools"
handle = "order-tools"
type = "ui_extension"
[[extensions.targeting]]
target = "customer-account.order-status.block.render"
module = "./Extension.jsx"
[extensions.capabilities]
network_access = trueShopify says network access approval is automatically granted once requested, which is nice. A rare and beautiful moment where a platform says “yes” without a multi-day ritual involving screenshots and sadness.
What about app proxies?
App proxies are not forbidden here, but they are often the wrong default. Shopify documents several constraints that matter specifically for UI extensions:
- App proxy requests still execute as CORS requests.
They do not get
logged_in_customer_id. Shopify tells you to use the session tokensubclaim instead.- They are not supported for password-protected shops in this context.
- They do not handle every HTTP method.
So yes, you can route through an app proxy. No, that does not make it the cleanest architecture. If the goal is a direct extension-to-backend call with verified claims, calling your backend directly is often simpler and more debuggable.
When not to call Rails at all
This is the section that saves you from building a backend endpoint just because you had coffee and felt powerful.
If the extension only needs data or writes that Shopify already exposes safely in-surface, use the first-party API directly. The authenticated account API already provides customer information for the authenticated customer. The customer account UI extension API surface also exposes the Customer Account API, navigation, localization, settings, storage, and more. If your job is simply “show customer info” or “write a small bit of structured data that belongs in Shopify”, a Rails hop is often extra plumbing with no corresponding wisdom.
“Writing to metafields directly ... without making a separate call to ... a third-party server.”
Shopify’s customer account configuration docs go even further: all customer account UI extension targets can read and write metafields using the Customer Account API. That means some state that developers instinctively bounce through Rails can actually live and move entirely through Shopify’s surface.
| Use case | Call Rails? | Better default |
|---|---|---|
| Show the authenticated customer’s basic identity | No | Use authenticatedAccount.customer, assuming required permissions exist. |
| Store or update a small customer metafield | Usually no | Use the Customer Account API write path directly. |
| Show app-owned workflow state for a specific order | Usually yes | Rails, because the state belongs to your app. |
| Trigger a return workflow in an external system | Yes | Rails or another backend you control. |
| Read a Shopify fact the extension already has | No | Do not build a ceremonial proxy for information Shopify already hands you. |
The deciding question is simple: does this request require app-owned decisions, persistence, or integration orchestration? If yes, call Rails. If not, there is a very real chance you are just moving bytes around to feel productive.
Common failure modes that burn a day
Fetching the token once and reusing it forever. Shopify says fetch a token every time you make a backend request. Do not invent a sticky token cache in the extension.
Trusting
customerIdfrom the body. Use verified claims or first-party APIs. Body fields are business input, not identity.Confusing authentication with authorization. A valid JWT is not permission to mutate any record the caller mentions.
Configuring CORS for the storefront origin instead of the extension reality. Customer account UI extensions run in a worker with a null origin.
Using an app proxy because it feels more Shopify-ish. Then spending the afternoon rediscovering its UI extension constraints.
Building a generic “customer account API” surface. The extension grows, the endpoint grows, the policy logic smears everywhere, and future-you starts making eye contact with the void.
Returning sensitive data purely because the token verified. Shopify explicitly warns that the request itself is not guaranteed to have originated from your extension.
Sending every read through Rails. Some reads and writes belong directly in Shopify via first-party extension APIs and Customer Account API metafields.
A reliable mental checklist
Verify the token. Derive the shop. Derive the customer. Authorize the exact resource. Keep the endpoint narrow. Prefer first-party APIs when the backend adds no real value.
Best internal links
Sources and further reading
Shopify Dev: Customer Account UI extensions session token API
Shopify Dev: Customer Account UI extensions authenticated account API
Shopify Dev: Customer Account UI extensions configuration
Shopify Dev: Customer Account UI extensions API index
Shopify Dev: Building metafield writes into extensions
Shopify Dev: customer-account.order.page.render target
FAQ
Do I need an app proxy for customer account extensions?
No. Direct requests to your backend are often simpler. Customer account UI extensions can make external network calls when network access is enabled, CORS is configured correctly, and your backend verifies the session token. App proxies still work in some cases, but they come with extra constraints.
Can I trust the session token alone?
You can trust the integrity of verified claims such as the shop domain and, when present, the customer gid in sub. You still need your own authorization rules before returning sensitive data or mutating state.
Should the extension send customer_id in the request body?
Usually no. Derive customer identity from trusted token claims or first-party APIs. If the client sends an order id or other business input, Rails should still verify that the authenticated customer is allowed to act on it.
When can I skip Rails completely?
Skip Rails when the extension only needs data or write paths Shopify already provides directly, such as authenticated account information or customer account metafield writes. Use Rails when you need app-owned persistence, custom business decisions, or third-party orchestration.
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.
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.
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.