Guides

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.

Updated March 12, 2026
14 min read
Editorial note: This is the boring, durable version of the pattern. Not the version where your extension grows tiny arms and tries to become a second app.

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:

  1. The extension asks Shopify for a fresh session token.
  2. It sends that token as a bearer token in Authorization.
  3. It sends only the action payload that Rails genuinely needs.
  4. 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.

FieldWho should supply itWhy
Authorization: Bearer <jwt>ExtensionRails uses it to verify the shop context and customer-related claims.
orderIdExtensionIt is business input, but Rails must still verify the customer can act on that order.
reason, message, choiceExtensionThese are user inputs, not identity claims.
shopRails derives itThe token already contains the destination shop domain.
customerIdRails derives itThe 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"
end

CORS 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
end

JWT 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
end

Authorize 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
end

That 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 shapeVerdictWhy
POST /customer-account/return-requestsGoodOne action, one policy, one predictable payload.
POST /customer-account/support-conversationsGoodClear intent and easy to audit.
GET /customer-account/order-app-state/:order_gidUsually goodReasonable when the extension needs app-owned state Shopify does not provide.
POST /customer-account/graphqlRiskyYou can do it, but you usually end up rebuilding authorization logic for every field.
POST /customer-account/api with { action: ... }BadCongratulations, 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 = true in shopify.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 = true

Shopify 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 token sub claim 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 caseCall Rails?Better default
Show the authenticated customer’s basic identityNoUse authenticatedAccount.customer, assuming required permissions exist.
Store or update a small customer metafieldUsually noUse the Customer Account API write path directly.
Show app-owned workflow state for a specific orderUsually yesRails, because the state belongs to your app.
Trigger a return workflow in an external systemYesRails or another backend you control.
Read a Shopify fact the extension already hasNoDo 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 customerId from 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

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

Guides

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.

guidesShopify developersession tokens