Skip to main content

Migrating from Theme Engine API to Storefront SDK

The Theme Engine API is deprecated. Custom JavaScript in your Customer Portal theme that calls Theme Engine routes (/tools/recurring/portal/<customer_hash>/...?token=...) should move to the Recharge Storefront SDK.

You can keep rendering your portal templates with Theme Engine. What changes is how your JavaScript reads and writes data: instead of calling Theme Engine routes with window.customerToken, you get a Recharge session with loginCustomerPortal and pass it to SDK methods.

warning

Use SDK version 2.8.0 or later. Versions of loginCustomerPortal prior to 2.8.0 are deprecated and will stop working inside the Customer Portal. See Migrating loginCustomerPortal.

Prerequisites​

  • Storefront SDK 2.8.0 or later. The CDN snippet below always points at the latest version. If you bundle the SDK yourself, install @rechargeapps/storefront-client@^2.8.0.
  • A Recharge Storefront Access Token. Create one in the merchant admin where you manage your API tokens. Give it the scopes your portal code needs (for example read customers, read subscriptions, write subscriptions).
  • Code running inside the Recharge Customer Portal. loginCustomerPortal reads the customer hash and portal token from the current page, so it only works on Customer Portal pages.

Step 1: Load and initialize the SDK​

Add the SDK script to your Customer Portal theme (for example in your base template) and initialize it once per page.

<script
src="https://static.rechargecdn.com/assets/storefront/recharge-client-2.10.0.min.js"
integrity="sha384-hvrfm4H6UY39qeESB2DwXCjwo/gUttt5rLl+NhRUlVnQrqAQKz0xFClfjLK2N9vk"
crossorigin="anonymous"
></script>
<script>
recharge.init({
// Your myshopify.com domain
storeIdentifier: '{{ store.platform_domain }}',
// Required for API access
storefrontAccessToken: 'strfnt_XXXXXXXX',
// Optional, helps with traceability and debugging
appName: 'customer-portal-theme',
appVersion: '1.0.0',
// Called when a request returns a 401 (for example, an expired session).
// Returning a fresh session lets the SDK retry the failed request automatically.
loginRetryFn: () => recharge.auth.loginCustomerPortal(),
});
</script>

If you bundle your portal JavaScript, import from the package instead:

import { initRecharge, loginCustomerPortal } from '@rechargeapps/storefront-client';

initRecharge({
storeIdentifier: 'your-store.myshopify.com',
storefrontAccessToken: 'strfnt_XXXXXXXX',
loginRetryFn: () => loginCustomerPortal(),
});

Step 2: Get a session with loginCustomerPortal​

Theme Engine authenticated each request with window.customerToken in the query string. The SDK replaces this with a session. Call loginCustomerPortal once when the page loads and reuse the session for every SDK call.

const session = await recharge.auth.loginCustomerPortal();
// session => { apiToken: '...', customerId: 123456 }

loginCustomerPortal reads the customer hash and portal token from the current Customer Portal page and exchanges them for a Recharge session scoped to that customer. It throws if it is called outside the Customer Portal. You don't need to read window.customerToken or build URLs with {{ customer.hash }} yourself.

When a session expires, the SDK calls loginRetryFn and retries the failed request with the new session, but it does not update the session object your code holds. Store the session returned by loginRetryFn where your code reads it, as the full example does, so later calls use the new session instead of failing and logging in again.

Step 3: Replace Theme Engine requests with SDK methods​

Every SDK API method takes the session as its first argument and returns the resource directly. Errors throw instead of resolving with an error payload, so wrap calls in try/catch.

const session = await recharge.auth.loginCustomerPortal();

// Read the customer
const customer = await recharge.customer.getCustomer(session);

// List active subscriptions
const { subscriptions } = await recharge.subscription.listSubscriptions(session, {
status: 'active',
limit: 25,
});

// Update a subscription's quantity
await recharge.subscription.updateSubscription(session, subscriptions[0].id, { quantity: 2 });

// Move the next charge date
await recharge.subscription.updateSubscriptionChargeDate(session, subscriptions[0].id, '2026-12-15');

Common Theme Engine routes and their SDK replacements​

Paths are shown relative to /tools/recurring/portal/<customer_hash>. UMD methods live on the recharge global under the namespace shown (for example recharge.subscription.updateSubscription).

Theme Engine APIStorefront SDK
GET /request_objects?schema={"customer": {}}customer.getCustomer(session)
GET /request_objects?schema={"subscriptions": []}subscription.listSubscriptions(session, query)
GET /request_objects?schema={"subscription": {"id": 1, "bundle_selections": {}}}subscription.getSubscription(session, id, { include: ['bundle_selections'] })
POST /subscriptions/<id>subscription.updateSubscription(session, id, data)
POST /subscriptions/<id>/set_next_charge_datesubscription.updateSubscriptionChargeDate(session, id, date)
POST /subscriptions/<id>/set_next_charge_date?pause=truesubscription.updateSubscriptionChargeDate(session, id, date, { pause: true })
POST /subscriptions/<id>/skipsubscription.skipSubscriptionCharge(session, id, date)
POST /subscriptions/<id>/cancelsubscription.cancelSubscription(session, id, data)
POST /subscriptions/<id>/activatesubscription.activateSubscription(session, id)
POST /subscriptions/<id>/bundlesbundle.updateBundle(session, id, data)
POST /charges/<id>/skipcharge.skipCharge(session, id, purchaseItemIds)
POST /addresses/<id>address.updateAddress(session, id, data)
POST /onetimesonetime.createOnetime(session, data)

Differences to watch for​

  • No token or customer hash in URLs. The session carries authentication. Remove window.customerToken, {{ customer.hash }} and {{ shopify_proxy_url if proxy_redirect else "" }} from your request code.
  • Product and variant IDs. Theme Engine payloads use shopify_variant_id. SDK requests use external_variant_id: { ecommerce: '<variant id>' } (and external_product_id the same way).
  • Responses. Theme Engine wraps results (for example { "subscription": { ... } }). SDK methods return the resource itself (for example updateSubscription resolves to the subscription). List methods return { subscriptions, next_cursor, previous_cursor }.
  • Field values. Statuses are lowercase in the SDK ('active', 'cancelled') where Theme Engine returns uppercase ('ACTIVE', 'CANCELLED'), so update any status comparisons. Dates such as next_charge_scheduled_at come back as YYYY-MM-DD instead of Theme Engine's YYYY-MM-DDT00:00:00.
  • Errors. SDK methods reject on failure. Use try/catch (or .catch) instead of checking the response body for errors.

Full example​

The example below is a subscription management script for a Customer Portal page. It loads the customer's subscriptions and wires up buttons to change quantity, move the next charge date, add a one-time product and cancel.

Before: Theme Engine API​

<script>
const baseUrl = `{{ shopify_proxy_url if proxy_redirect else "" }}/tools/recurring/portal/{{ customer.hash }}`;

async function themeEngineRequest(path, { method = 'GET', data, query = '' } = {}) {
const url = `${baseUrl}${path}?token=${window.customerToken}${query}`;
// Only send a JSON Content-Type with a body: Theme Engine rejects GET requests that include it
const response = await fetch(
url,
data ? { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) } : { method }
);
return response.json();
}

async function loadSubscriptions() {
const schema = JSON.stringify({ subscriptions: [] });
const { subscriptions } = await themeEngineRequest('/request_objects', {
query: `&schema=${encodeURIComponent(schema)}`,
});
return subscriptions.filter(subscription => subscription.status === 'ACTIVE');
}

function updateQuantity(subscriptionId, quantity) {
return themeEngineRequest(`/subscriptions/${subscriptionId}`, {
method: 'POST',
data: { quantity },
});
}

function setNextChargeDate(subscriptionId, date) {
return themeEngineRequest(`/subscriptions/${subscriptionId}/set_next_charge_date`, {
method: 'POST',
data: { date },
});
}

function addOnetime(addressId, variantId, nextChargeDate) {
return themeEngineRequest('/onetimes', {
method: 'POST',
data: {
shopify_variant_id: variantId,
address_id: addressId,
quantity: 1,
next_charge_scheduled_at: nextChargeDate,
},
});
}

function cancel(subscriptionId, reason) {
return themeEngineRequest(`/subscriptions/${subscriptionId}/cancel`, {
method: 'POST',
data: { cancellation_reason: reason },
});
}

document.addEventListener('DOMContentLoaded', async () => {
const subscriptions = await loadSubscriptions();
if (subscriptions.length === 0) return;
let subscription = subscriptions[0];

document.querySelector('#increase-quantity').addEventListener('click', async () => {
({ subscription } = await updateQuantity(subscription.id, subscription.quantity + 1));
});
document.querySelector('#delay-charge').addEventListener('click', async () => {
({ subscription } = await setNextChargeDate(subscription.id, '2026-12-15'));
});
document.querySelector('#add-onetime').addEventListener('click', () => {
addOnetime(subscription.address_id, 41291293425861, subscription.next_charge_scheduled_at);
});
document.querySelector('#cancel').addEventListener('click', () => {
cancel(subscription.id, 'Too much product');
});
});
</script>

After: Storefront SDK​

<script
src="https://static.rechargecdn.com/assets/storefront/recharge-client-2.10.0.min.js"
integrity="sha384-hvrfm4H6UY39qeESB2DwXCjwo/gUttt5rLl+NhRUlVnQrqAQKz0xFClfjLK2N9vk"
crossorigin="anonymous"
></script>
<script>
// Shared by every action so a refreshed session is reused instead of logging in again on each call
let session;

recharge.init({
storeIdentifier: '{{ store.platform_domain }}',
storefrontAccessToken: 'strfnt_XXXXXXXX',
appName: 'customer-portal-theme',
appVersion: '1.0.0',
loginRetryFn: async () => {
session = await recharge.auth.loginCustomerPortal();
return session;
},
});

async function loadSubscriptions(session) {
const { subscriptions } = await recharge.subscription.listSubscriptions(session, {
status: 'active',
limit: 25,
});
return subscriptions;
}

function updateQuantity(session, subscriptionId, quantity) {
return recharge.subscription.updateSubscription(session, subscriptionId, { quantity });
}

function setNextChargeDate(session, subscriptionId, date) {
return recharge.subscription.updateSubscriptionChargeDate(session, subscriptionId, date);
}

function addOnetime(session, addressId, variantId, nextChargeDate) {
return recharge.onetime.createOnetime(session, {
external_variant_id: { ecommerce: String(variantId) },
address_id: addressId,
quantity: 1,
next_charge_scheduled_at: nextChargeDate,
});
}

function cancel(session, subscriptionId, reason) {
return recharge.subscription.cancelSubscription(session, subscriptionId, {
cancellation_reason: reason,
});
}

document.addEventListener('DOMContentLoaded', async () => {
// One login per page load. loginRetryFn replaces the session if it expires.
session = await recharge.auth.loginCustomerPortal();
const subscriptions = await loadSubscriptions(session);
if (subscriptions.length === 0) return;
// SDK methods return the updated subscription, so keep it current for later actions
let subscription = subscriptions[0];

document.querySelector('#increase-quantity').addEventListener('click', async () => {
try {
subscription = await updateQuantity(session, subscription.id, subscription.quantity + 1);
} catch (error) {
console.error('Could not update quantity', error);
}
});
document.querySelector('#delay-charge').addEventListener('click', async () => {
try {
subscription = await setNextChargeDate(session, subscription.id, '2026-12-15');
} catch (error) {
console.error('Could not update next charge date', error);
}
});
document.querySelector('#add-onetime').addEventListener('click', async () => {
try {
await addOnetime(session, subscription.address_id, 41291293425861, subscription.next_charge_scheduled_at);
} catch (error) {
console.error('Could not add one-time product', error);
}
});
document.querySelector('#cancel').addEventListener('click', async () => {
try {
await cancel(session, subscription.id, 'Too much product');
} catch (error) {
console.error('Could not cancel subscription', error);
}
});
});
</script>

Migrating bundle selections​

The Theme Engine bundle example reads bundle selections through request_objects and updates them with POST /subscriptions/<id>/bundles. With the SDK:

const session = await recharge.auth.loginCustomerPortal();

// Read the subscription with its bundle selections
const subscription = await recharge.subscription.getSubscription(session, 123456789, {
include: ['bundle_selections'],
});

// Replace the bundle contents
await recharge.bundle.updateBundle(session, 123456789, {
external_product_id: { ecommerce: '7134322196677' },
external_variant_id: { ecommerce: '41291293425861' },
items: [
{
collection_id: '285790863557',
collection_source: 'shopify',
external_product_id: '7200063029445',
external_variant_id: '41504993575109',
quantity: 1,
},
{
collection_id: '288157827269',
collection_source: 'shopify',
external_product_id: '7200064241861',
external_variant_id: '41510933299397',
quantity: 2,
},
],
});

See the API methods for every available call and the scopes each one needs.