Reference

Shopify Expiring Token Management

Requirement

As of April 2026, Shopify mandates that all OAuth apps use expiring offline access tokens with rotation. Non-expiring tokens return 403: Non-expiring access tokens are no longer accepted for the Admin API. This affects every Shopify Admin API call made by CRM Sync — customer sync, product queries, order lookups, webhook registration, and storefront token provisioning.

What Changed

Before (pre-April 2026)After (mandatory)
Access token never expiresAccess token expires ~24 hours after issuance
No refresh token issuedRefresh token issued alongside access token
Store once, use foreverMust refresh before expiry; refresh token rotates on each use
Token prefix: shpat_Token prefix: shpua_ (OAuth expiring)

Compliance Flag

The Shopify app must declare expiring token support:

// app/shopify.server.ts
shopifyApp({
  // ...
  future: {
    expiringOfflineAccessTokens: true,
  },
});

The OAuth token exchange must include expiring: "1":

POST https://{shop}/admin/oauth/access_token
Content-Type: application/x-www-form-urlencoded

client_id={id}&client_secret={secret}&code={code}&expiring=1

Architecture

Token Lifecycle

Install / Re-install OAuth
        │
        ▼
POST /admin/oauth/access_token  (code + expiring=1)
        │
        ▼
┌─────────────────────────────────────┐
│  access_token   (shpua_..., ~24h)   │
│  refresh_token  (one-time use)      │
│  expires_in     (seconds)           │
└──────────────┬──────────────────────┘
               │
               ▼
        KV Store (CRM_STATE)
        ├── shopify_admin_token
        ├── shopify_refresh_token
        └── shopify_token_expires_at (ISO timestamp)
               │
               │  Before expiry (5-min buffer)
               ▼
POST /admin/oauth/access_token  (grant_type=refresh_token)
        │
        ▼
┌─────────────────────────────────────┐
│  NEW access_token                   │
│  NEW refresh_token  (old one dies)  │
│  NEW expires_in                     │
└─────────────────────────────────────┘

Three Token Surfaces

SurfaceWhat it doesWhen
Shopify App loader (app/routes/app.tsx)Sends session.accessToken to CRM worker via POST /config?shop=Every time merchant opens the app
CRM Worker cron (*/15 * * * *)Calls refreshShopifyTokenIfNeeded() with 5-min buffer before expiryEvery 15 minutes
Settings page (/admin/shopify-refresh)Force-refresh via manual button clickOn demand

Multi-Tenant Token Storage

Each tenant's tokens are stored independently in KV under tenant:{shop}:

{
  "shopify_admin_token": "shpua_...",
  "shopify_refresh_token": "shprf_...",
  "shopify_token_expires_at": "2026-05-21T19:00:00.000Z",
  "shopify_store_domain": "hx-stage.myshopify.com",
  "shopify_app_secret": "..."
}

The cron iterates all registered tenants and refreshes each independently.


Implementation Reference

Core Refresh Function

workers/crm-sync/src/index.ts — refreshShopifyTokenIfNeeded()

Where Refresh Is Called

Call siteTrigger
shopifyAdminGql()Before every Admin API GraphQL call
createShopifyCustomerIfMissing()Before customer creation
scheduled() cron handlerPer-tenant before customer sync
POST /admin/shopify-refreshManual force-refresh from Settings UI

OAuth Install Flow

/admin/shopify-install → redirect to Shopify OAuth → /admin/shopify-callback

The callback handler:

  1. Exchanges authorization code for tokens with expiring: "1"
  2. Stores access_token, refresh_token, expires_at in tenant KV
  3. Registers the tenant via registerTenant()
  4. Auto-registers CUSTOMERS_CREATE and CUSTOMERS_UPDATE webhooks

Shopify App Session Sync

app/routes/app.tsx loader:

  1. Authenticates the admin session via shopify.authenticate.admin()
  2. Sends session.accessToken to CRM worker via POST /config?shop=
  3. Provisions a Storefront API token via Admin API if not already present
  4. The CRM worker maps shopify_access_token → shopify_admin_token

This ensures the CRM worker always has a fresh token when the merchant opens the app, even if the cron-refreshed token has expired.


Required Scopes

Declared in shopify.app.crm-sync.toml under [access_scopes]:

ScopePurpose
read_customersCustomer sync, identity lookup
write_customersCustomer creation, tag/metafield writes
customer_read_customersCustomer Account API reads
customer_write_customersCustomer Account API writes
read_productsShop embed product grid
read_ordersOrder history in dashboard

Scopes in the TOML must match the OAuth install URL request. Shopify silently drops undeclared scopes. Deploy scope changes with:

npx shopify app deploy --config=shopify.app.crm-sync.toml

Diagnostics

Settings Page Indicators

The /settings admin page shows:

Manual Actions

ButtonEndpointWhat it does
Force RefreshPOST /admin/shopify-refreshRefreshes immediately regardless of expiry
Test APIGET /admin/shopify-testCalls Shop API and returns status
Re-install OAuthGET /admin/shopify-install?shop=Starts fresh OAuth flow

Common Failures

SymptomCauseFix
403: Non-expiring access tokensUsing legacy shpat_ tokenRe-install OAuth to get shpua_ token
401: [API] Invalid API keyToken expired and refresh failedCheck shopify_app_secret in KV, force refresh
Refresh returns 400Refresh token already used (rotated)Re-install OAuth
shopify_refresh_token: NoneInitial install didn't include expiring: "1"Re-install OAuth
Cron not refreshingNo tenants registeredCall POST /config?shop= to register

Operational Checklist