← Shopify App BuilderCONTENT HISTORY

Update to Shopify App Builder

Snapshot Sep 30, 2026 · 23:13 UTC · version 1.4.1

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Implement OAuth 2.0, Token Exchange, Managed Installation, App Proxy, and webhook verification for Shopify apps. Support online/offline tokens, session storage (Prisma, Redis, Memory), and multi-auth patterns. Covers admin API, public apps, custom apps, and customer account authentication. Triggers include: 'Shopify authentication', 'OAuth 2.0', 'Token Exchange', 'Managed Installation', 'App Proxy', 'Webhook signature', 'HMAC verification', 'Admin API auth', 'Customer Account API', 'Session storage', 'Online token', 'Offline token', 'Remix auth', 'shopify auth'.",
  "included_files": [],
  "name": "app-auth",
  "skill_md_contents": "---\nname: app-auth\ndescription: \"Implement OAuth 2.0, Token Exchange, Managed Installation, App Proxy, and webhook verification for Shopify apps. Support online/offline tokens, session storage (Prisma, Redis, Memory), and multi-auth patterns. Covers admin API, public apps, custom apps, and customer account authentication. Triggers include: 'Shopify authentication', 'OAuth 2.0', 'Token Exchange', 'Managed Installation', 'App Proxy', 'Webhook signature', 'HMAC verification', 'Admin API auth', 'Customer Account API', 'Session storage', 'Online token', 'Offline token', 'Remix auth', 'shopify auth'.\"\n---\n\n# Shopify App Authentication\n\nShopify provides multiple authentication flows depending on your app type and use case. The modern standard is **Token Exchange** (2024+) for server-rendered apps, **Managed Installation** for headless apps, and **OAuth 2.0 Authorization Code Grant** for legacy/custom implementations. All flows result in an access token for the Shopify GraphQL Admin API.\n\n## Authentication Flows Overview\n\n### 1. Token Exchange (Recommended for 2024+)\n\n**Use Case:** Server-rendered apps (Remix, Next.js with SSR), Shopify CLI apps\n**Flow:** Merchant installs app → Shopify generates temporary exchange token → App exchanges for access token\n**Security:** No client secret exposed; uses PKCE-style rotation per request\n**Token Lifetime:** Access tokens are short-lived; refresh tokens rotate automatically\n\n**Token Exchange Diagram:**\n```\n1. Merchant clicks \"Install\" in Shopify Admin\n2. Shopify redirects: https://your-app.com/auth/callback?code=EXCHANGE_TOKEN\n3. App validates HMAC, exchanges code for access token (private, server-side only)\n4. Shopify Admin API grants scopes; token stored in session/database\n5. Token auto-refreshes on next request if expired\n```\n\n**Remix Implementation (Token Exchange):**\n\n```typescript\n// shopify.app.ts (App Configuration)\nimport { shopifyApp } from '@shopify/shopify-app-remix/server';\nimport { restResources } from '@shopify/shopify-api/rest/admin/2026-07';\nimport { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';\nimport { prisma } from '~/db.server';\n\nconst shopify = shopifyApp({\n  apiKey: process.env.SHOPIFY_API_KEY || '',\n  apiSecret: process.env.SHOPIFY_API_SECRET || '',\n  scopes: process.env.SCOPES?.split(',') || [\n    'write_products',\n    'read_products',\n    'write_orders',\n    'read_orders',\n    'write_customers',\n    'read_customers',\n    'write_discounts',\n    'read_discounts',\n    'write_fulfillments',\n    'read_fulfillments',\n    'write_inventory',\n    'read_inventory',\n  ],\n  appUrl: process.env.SHOPIFY_APP_URL || 'http://localhost:3000',\n  auth: {\n    path: '/auth',\n    callbackPath: '/auth/callback',\n  },\n  webhooks: {\n    path: '/webhooks',\n  },\n  isEmbeddedApp: true, // Polaris admin dashboard\n  sessionStorage: new PrismaSessionStorage(prisma),\n  restResources, // Includes REST API helpers\n});\n\nexport default shopify;\n```\n\n**Environment Variables (.env):**\n```bash\nSHOPIFY_API_KEY=your-public-api-key-from-partner-dashboard\nSHOPIFY_API_SECRET=your-private-api-secret\nSHOPIFY_APP_URL=https://your-domain.ngrok.io  # or prod URL\nSCOPES=write_products,read_products,write_orders,read_orders\n```\n\n**Auth Routes (routes/auth.$.tsx - Catch-all route):**\n```typescript\nimport { redirect } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { authenticate } = await import('~/shopify.server');\n  return authenticate.admin(request); // Token Exchange happens here\n};\n```\n\n**Callback Handling (routes/auth.callback.tsx):**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { session } = await authenticate.admin(request);\n  // Session contains:\n  // - session.accessToken (valid for API calls)\n  // - session.shop (merchant's shop domain)\n  // - session.scope (granted scopes)\n  // - session.state (optional custom data)\n\n  return redirect('/app'); // Redirect to dashboard after auth\n};\n```\n\n**Using Token in API Calls (routes/app.products.tsx):**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\nimport { GraphQLClient } from 'graphql-request';\n\nexport const loader = async ({ request }) => {\n  const { session, admin } = await authenticate.admin(request);\n\n  // Method 1: Use Shopify's admin helper (recommended)\n  const response = await admin.graphql(`\n    query GetProducts {\n      products(first: 10) {\n        edges {\n          node {\n            id\n            title\n            handle\n          }\n        }\n      }\n    }\n  `);\n\n  // Method 2: Manual GraphQL call\n  const client = new GraphQLClient(\n    `https://${session.shop}/admin/api/2026-07/graphql.json`,\n    {\n      headers: {\n        'X-Shopify-Access-Token': session.accessToken,\n        'Content-Type': 'application/json',\n      },\n    }\n  );\n\n  const data = await client.request(/* ... */);\n\n  return json({ products: response.data?.products?.edges || [] });\n};\n```\n\n**Token Refresh (Automatic):**\nToken Exchange tokens auto-refresh via Shopify's session middleware. No manual refresh needed:\n```typescript\n// Tokens are refreshed transparently on each request\nconst response = await admin.graphql(query); // Handles refresh internally\n```\n\n### 2. Managed Installation (Headless/Custom Apps)\n\n**Use Case:** Headless storefront, mobile apps, third-party integrations\n**Flow:** Merchant authorizes app → Shopify generates permanent access token (no secret rotation)\n**Token Lifetime:** Long-lived; no refresh required\n**Security:** Token is permanent; store securely in environment variable\n\n**Managed Installation Setup:**\n\nIn Shopify Partner Dashboard:\n1. App Settings > API Credentials\n2. Select \"Managed installation\" under Admin API access scopes\n3. Merchant grants permission once\n4. Copy access token to your environment\n\n```bash\n# .env\nSHOPIFY_ADMIN_ACCESS_TOKEN=<SHOPIFY_ADMIN_ACCESS_TOKEN>\nSHOPIFY_SHOP_URL=example-shop.myshopify.com\n```\n\n**Using Managed Installation Token:**\n```typescript\nimport { GraphQLClient } from 'graphql-request';\n\nconst client = new GraphQLClient(\n  `https://${process.env.SHOPIFY_SHOP_URL}/admin/api/2026-07/graphql.json`,\n  {\n    headers: {\n      'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || '',\n    },\n  }\n);\n\n// Token never expires; call API anytime\nconst query = `query { products(first: 10) { edges { node { id title } } } }`;\nconst products = await client.request(query);\n```\n\n### 3. OAuth 2.0 Authorization Code Grant (Legacy, Still Supported)\n\n**Use Case:** Public apps with traditional OAuth flow\n**Flow:** Merchant clicks \"Install\" → App redirects to Shopify OAuth → Merchant authorizes → App receives code → App exchanges code for token\n**Token Lifetime:** Long-lived access token (no expiration unless revoked)\n**Security:** Client secret required; PKCE optional\n\n**OAuth Flow Diagram:**\n```\n1. Merchant visits: https://your-app.com/auth\n2. App redirects to: https://your-shop.myshopify.com/admin/oauth/authorize?client_id=KEY&scope=write_products&redirect_uri=https://your-app.com/auth/callback&state=RANDOM\n3. Merchant authorizes app in Shopify Admin\n4. Shopify redirects back: https://your-app.com/auth/callback?code=AUTHORIZATION_CODE&hmac=SIGNATURE&state=RANDOM&shop=your-shop.myshopify.com\n5. App validates HMAC and state\n6. App exchanges code for token (server-side, using client secret)\n7. App stores token in database\n```\n\n**Express.js OAuth Example:**\n```typescript\nimport express from 'express';\nimport axios from 'axios';\nimport crypto from 'crypto';\n\nconst app = express();\n\nconst API_KEY = process.env.SHOPIFY_API_KEY || '';\nconst API_SECRET = process.env.SHOPIFY_API_SECRET || '';\nconst REDIRECT_URI = process.env.REDIRECT_URI || 'https://your-app.com/auth/callback';\nconst SCOPES = 'write_products,read_products';\n\n// Step 1: Redirect merchant to Shopify OAuth\napp.get('/auth', (req, res) => {\n  const shop = req.query.shop as string;\n\n  if (!shop || !shop.includes('.myshopify.com')) {\n    return res.status(400).send('Missing or invalid shop parameter');\n  }\n\n  const state = crypto.randomBytes(16).toString('hex');\n  const nonce = crypto.randomBytes(16).toString('hex');\n\n  // Store state in session (or database) for validation\n  req.session.state = state;\n  req.session.nonce = nonce;\n\n  const authUrl = new URL(\n    `/admin/oauth/authorize`,\n    `https://${shop}`\n  );\n  authUrl.searchParams.append('client_id', API_KEY);\n  authUrl.searchParams.append('scope', SCOPES);\n  authUrl.searchParams.append('redirect_uri', REDIRECT_URI);\n  authUrl.searchParams.append('state', state);\n\n  res.redirect(authUrl.toString());\n});\n\n// Step 2: Handle OAuth callback\napp.get('/auth/callback', async (req, res) => {\n  const { code, hmac, shop, state } = req.query;\n\n  // Validate HMAC\n  const message = Object.entries(req.query)\n    .filter(([key]) => key !== 'hmac')\n    .map(([key, value]) => `${key}=${value}`)\n    .sort()\n    .join('&');\n\n  const hash = crypto\n    .createHmac('sha256', API_SECRET)\n    .update(message, 'utf8')\n    .digest('base64');\n\n  if (hash !== hmac) {\n    return res.status(401).send('Unauthorized request detected');\n  }\n\n  // Validate state\n  if (state !== req.session.state) {\n    return res.status(401).send('State mismatch');\n  }\n\n  try {\n    // Exchange code for access token\n    const response = await axios.post(\n      `https://${shop}/admin/oauth/access_token`,\n      {\n        client_id: API_KEY,\n        client_secret: API_SECRET,\n        code,\n      }\n    );\n\n    const { access_token, scope } = response.data;\n\n    // Store access token (in database, not session, for persistence)\n    await storeAccessToken(shop as string, access_token, scope);\n\n    // Redirect to app dashboard\n    res.redirect(`/app?shop=${shop}`);\n  } catch (error) {\n    console.error('Token exchange error:', error);\n    res.status(500).send('Authentication failed');\n  }\n});\n\n// Helper function to store token\nasync function storeAccessToken(shop: string, token: string, scope: string) {\n  // Store in database (example using mock storage)\n  const db = {\n    shops: {} as Record<string, { token: string; scope: string }>,\n  };\n  db.shops[shop] = { token, scope };\n}\n\napp.listen(3000);\n```\n\n## Session Storage Adapters\n\nAccess tokens must be stored persistently. Shopify provides adapters for common storage backends:\n\n### Prisma (Recommended for Remix)\n\n**Schema (prisma/schema.prisma):**\n```prisma\nmodel Session {\n  id        String    @id\n  shop      String\n  state     String\n  isOnline  Boolean   @default(false)\n  accessToken String\n  refreshToken String?\n  scope     String\n  expiresAt DateTime?\n  createdAt DateTime  @default(now())\n  updatedAt DateTime  @updatedAt\n\n  @@unique([shop, state])\n  @@index([shop])\n}\n```\n\n**Setup (shopify.app.ts):**\n```typescript\nimport { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';\nimport { prisma } from '~/db.server';\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage: new PrismaSessionStorage(prisma),\n});\n```\n\n### Redis\n\n```typescript\nimport { RedisSessionStorage } from '@shopify/shopify-app-session-storage-redis';\nimport redis from 'redis';\n\nconst redisClient = redis.createClient({\n  host: 'localhost',\n  port: 6379,\n});\n\nconst sessionStorage = new RedisSessionStorage({\n  client: redisClient,\n  prefix: 'shopify_session:',\n});\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage,\n});\n```\n\n### In-Memory (Development Only)\n\n```typescript\nimport { MemorySessionStorage } from '@shopify/shopify-app-session-storage';\n\nconst sessionStorage = new MemorySessionStorage();\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage, // CAUTION: Sessions lost on app restart; development only\n});\n```\n\n### DynamoDB\n\n```typescript\nimport { DynamoDBSessionStorage } from '@shopify/shopify-app-session-storage-dynamodb';\nimport { DynamoDBClient } from '@aws-sdk/client-dynamodb';\n\nconst dynamoDBClient = new DynamoDBClient({ region: 'us-east-1' });\n\nconst sessionStorage = new DynamoDBSessionStorage({\n  client: dynamoDBClient,\n  tableName: 'shopify-sessions',\n});\n\nconst shopify = shopifyApp({\n  // ...\n  sessionStorage,\n});\n```\n\n## Online vs. Offline Tokens\n\n**Online Token:**\n- Scope: Current user's permissions (typically admin user)\n- Expiration: 24 hours\n- Use Case: Browser-based actions (Polaris admin dashboard)\n- Limitations: Cannot run background jobs; limited when user logs out\n\n**Offline Token:**\n- Scope: App's granted scopes\n- Expiration: None; permanent until revoked\n- Use Case: Background jobs, webhooks, scheduled tasks\n- Limitations: None; use by default for app operations\n\n**Requesting Offline Token in Token Exchange:**\n```typescript\n// shopify.app.ts\nconst shopify = shopifyApp({\n  // ...\n  auth: {\n    path: '/auth',\n    callbackPath: '/auth/callback',\n  },\n});\n\n// Remix automatically requests offline token by default\n// No action needed; use session.accessToken for API calls\n```\n\n**Using Offline Token for Webhooks:**\n```typescript\n// webhooks/products-update.ts\nexport const webhooks = {\n  APP_UNINSTALLED: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/app-uninstalled',\n  },\n  PRODUCTS_UPDATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/products-update',\n  },\n};\n\nexport default defineWebhooksConfig(\n  async (request, { admin, session }) => {\n    const { body, query } = await graphql.query(request, {\n      query: GET_PRODUCT,\n      variables: { id: 'gid://shopify/Product/123' },\n    });\n\n    // session.accessToken is offline token; valid here\n    console.log(`Webhook processed with token for shop: ${session.shop}`);\n  },\n  webhooksConfig\n);\n```\n\n## App Proxy Authentication\n\n**Use Case:** Storefront (public-facing) requests to app backend\n**Authentication:** HMAC signature validation (like webhooks)\n**Flow:** Storefront → Liquid proxy request → App backend (validates HMAC) → Response\n\n**Setting Up App Proxy (shopify.app.toml):**\n```toml\n[[extensions]]\ntype = \"app_proxy\"\nname = \"Storefront API\"\nurl = \"/api/proxy\"\nsubpath = \"loyalty\"  # Requests to /apps/loyalty/* are routed here\n```\n\n**App Proxy Handler (Remix routes/api/proxy.ts):**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport crypto from 'crypto';\n\nexport const loader = async ({ request }) => {\n  const url = new URL(request.url);\n  const hmac = url.searchParams.get('hmac') || '';\n  const timestamp = url.searchParams.get('_t') || '';\n  const shop = url.searchParams.get('shop') || '';\n\n  // Build message to validate HMAC\n  const params = new URLSearchParams();\n  Array.from(url.searchParams.entries()).forEach(([key, value]) => {\n    if (key !== 'hmac') params.append(key, value);\n  });\n\n  const message = params.toString();\n  const hash = crypto\n    .createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')\n    .update(message, 'utf8')\n    .digest('base64');\n\n  if (hash !== hmac) {\n    return json({ error: 'Unauthorized' }, { status: 401 });\n  }\n\n  // Validate timestamp (within 24 hours)\n  const requestTime = parseInt(timestamp, 10);\n  const currentTime = Math.floor(Date.now() / 1000);\n  if (Math.abs(currentTime - requestTime) > 86400) {\n    return json({ error: 'Request expired' }, { status: 401 });\n  }\n\n  // Valid app proxy request; return customer's loyalty points\n  const customerId = url.searchParams.get('customer_id') || '';\n  const points = await getLoyaltyPoints(customerId);\n\n  return json({ points });\n};\n\nasync function getLoyaltyPoints(customerId: string) {\n  // Fetch from database\n  return 1500; // Example\n}\n```\n\n**Storefront Liquid Snippet:**\n```liquid\n<div id=\"loyalty-widget\">\n  <p>Your loyalty points: <span id=\"points\">Loading...</span></p>\n</div>\n\n<script>\nfetch('/apps/loyalty?customer_id={{ customer.id }}')\n  .then(r => r.json())\n  .then(data => {\n    document.getElementById('points').textContent = data.points;\n  });\n</script>\n```\n\n## Webhook Signature Verification\n\n**How It Works:** Shopify sends HMAC-SHA256 signature in `X-Shopify-Hmac-SHA256` header\n\n**Verify Signature (Manual):**\n```typescript\nimport crypto from 'crypto';\n\nexport const verifyWebhookSignature = (\n  request: Request,\n  secret: string\n): boolean => {\n  const hmacHeader = request.headers.get('X-Shopify-Hmac-SHA256') || '';\n  const body = request.body; // Must be raw bytes, not JSON\n\n  const hash = crypto\n    .createHmac('sha256', secret)\n    .update(body)\n    .digest('base64');\n\n  return crypto.timingSafeEqual(\n    Buffer.from(hash),\n    Buffer.from(hmacHeader)\n  );\n};\n```\n\n**Verify Signature (Remix Shopify Package):**\n```typescript\nimport { authenticate } from '~/shopify.server';\n\nexport const action = async ({ request }) => {\n  const { webhook } = await authenticate.webhook(request);\n\n  // Signature already validated by middleware\n  console.log(`Webhook received for shop: ${webhook.shop}`);\n  console.log(`Topic: ${webhook.topic}`);\n  console.log(`Body:`, webhook.payload);\n\n  return json({ status: 'ok' });\n};\n```\n\n**Register Webhook (shopify.app.ts):**\n```typescript\nconst shopify = shopifyApp({\n  // ...\n  webhooks: {\n    path: '/webhooks',\n    validateHmac: true, // Automatic signature verification\n  },\n});\n\n// Define webhooks in routes/webhooks.ts\nexport const webhooks = {\n  APP_UNINSTALLED: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/app-uninstalled',\n  },\n  ORDERS_CREATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/orders-create',\n  },\n};\n```\n\n## Customer Account API Authentication\n\n**Use Case:** Access customer account data (orders, addresses, metafields)\n**Authentication:** Customer-specific access tokens (from Shopify Hydrogen or customer flow)\n**Note:** Different from admin API; limited to customer data only\n\n**Get Customer Access Token (in Hydrogen/Storefront):**\n```typescript\n// This is typically handled by Shopify's customer auth flow\nconst customerAccessToken = 'shpuc_XXXXX'; // Provided by auth\n\nconst response = await fetch(\n  `https://example-shop.myshopify.com/api/2026-07/graphql.json`,\n  {\n    method: 'POST',\n    headers: {\n      'X-Shopify-Storefront-Access-Token': 'public-storefront-token',\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      query: `\n        query {\n          customer(customerAccessToken: \"${customerAccessToken}\") {\n            id\n            firstName\n            email\n            orders(first: 10) {\n              edges {\n                node {\n                  id\n                  orderNumber\n                  totalPrice\n                }\n              }\n            }\n          }\n        }\n      `,\n    }),\n  }\n);\n```\n\n## Public vs. Custom vs. Custom Distribution Apps\n\n**Public App (Shopify App Store):**\n- Listed in Shopify App Store\n- OAuth 2.0 or Token Exchange\n- Scopes reviewed by Shopify\n- Available to all merchants\n- Example: \"Email Marketing Pro\"\n\n**Custom App (Internal Use):**\n- Private; not listed in App Store\n- No scopes; request all access\n- Created by/for single merchant\n- Only accessible to merchant account\n- Example: Custom inventory sync for specific store\n\n**Custom Distribution App:**\n- Limited distribution; only shared with specific merchants via link\n- Behaves like public app (listed in custom store) but not publicly visible\n- OAuth 2.0 required\n- Scopes still reviewed\n\n**Configuration (shopify.app.toml):**\n```toml\n# Public App\nscopes = \"write_products,read_products,write_orders\"\ndistribution = \"public\"\n\n# Custom App (all scopes by default)\ndistribution = \"private\"\n\n# Custom Distribution\ndistribution = \"custom\"\nallowedDomains = [\"company-partner.myshopify.com\"]\n```\n\n## Top 10 Authentication Bugs & Fixes\n\n| Bug | Symptom | Fix |\n|-----|---------|-----|\n| **Missing HMAC validation** | Webhook spoofing; malicious requests processed | Always validate HMAC signature in webhook handlers; use `authenticate.webhook(request)` |\n| **Storing access token in session cookie** | Token exposed in browser; XSS vulnerability | Store token in database (Prisma); never send to client; use httpOnly cookies for session ID only |\n| **Expired token not refreshed** | \"Unauthorized\" errors after 24h (online tokens) | Token Exchange auto-refreshes; OAuth tokens are permanent; Managed Installation tokens never expire |\n| **Incorrect HMAC secret** | \"Invalid signature\" errors on valid requests | Use correct `SHOPIFY_API_SECRET`; verify in Partner Dashboard > App Settings |\n| **State parameter not validated** | CSRF attacks; attacker redirects merchant | Store state in session; validate in callback; use `crypto.randomBytes(16).toString('hex')` for state generation |\n| **App proxy timestamp not checked** | Old requests replayed; business logic executed twice | Validate timestamp within 24h; use `Math.abs(currentTime - timestamp) < 86400` |\n| **Scope creep (requesting too many scopes)** | App rejected by Shopify; merchant distrust | Request only scopes needed; remove unused scopes from SCOPES array |\n| **Token stored in environment variable for multi-tenant** | Security breach; one merchant's token accessed by another | Use database storage (Prisma/Redis); one token per shop; index by shop domain |\n| **Webhook signature verified but not in constant-time** | Timing attacks; signature can be guessed | Use `crypto.timingSafeEqual()` for comparison; avoid simple `===` |\n| **Customer access token hardcoded** | Exposed in source code; customer data accessed | Never hardcode tokens; pass via environment variables or customer auth flow |\n\n## Full Working Examples\n\n### Example 1: Remix Token Exchange App (Complete)\n\n**Directory Structure:**\n```\nmy-app/\n├── app/\n│   ├── routes/\n│   │   ├── auth.$.tsx (Auth handler)\n│   │   ├── auth.callback.tsx (Callback)\n│   │   └── app.products.tsx (Protected route)\n│   ├── shopify.server.ts (Config)\n│   └── db.server.ts (Prisma client)\n├── prisma/\n│   ├── schema.prisma\n│   └── migrations/\n├── .env (API keys)\n└── shopify.app.toml\n```\n\n**shopify.app.toml:**\n```toml\nscopes = \"write_products,read_products,write_orders,read_orders\"\n```\n\n**app/shopify.server.ts:**\n```typescript\nimport { shopifyApp } from '@shopify/shopify-app-remix/server';\nimport { PrismaSessionStorage } from '@shopify/shopify-app-session-storage-prisma';\nimport { prisma } from '~/db.server';\n\nexport const shopify = shopifyApp({\n  apiKey: process.env.SHOPIFY_API_KEY,\n  apiSecret: process.env.SHOPIFY_API_SECRET,\n  scopes: process.env.SCOPES?.split(',') || [],\n  appUrl: process.env.SHOPIFY_APP_URL,\n  auth: {\n    path: '/auth',\n    callbackPath: '/auth/callback',\n  },\n  webhooks: {\n    path: '/webhooks',\n  },\n  isEmbeddedApp: true,\n  sessionStorage: new PrismaSessionStorage(prisma),\n});\n\nexport const authenticate = shopify.authenticate;\n```\n\n**app/routes/auth.$.tsx:**\n```typescript\nimport { redirect } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  await authenticate.admin(request);\n  return redirect('/app');\n};\n```\n\n**app/routes/auth.callback.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { session } = await authenticate.admin(request);\n  return redirect(`/app?shop=${session.shop}`);\n};\n```\n\n**app/routes/app.products.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { useLoaderData } from '@remix-run/react';\nimport { authenticate } from '~/shopify.server';\n\nexport const loader = async ({ request }) => {\n  const { admin } = await authenticate.admin(request);\n\n  const response = await admin.graphql(`\n    query GetProducts {\n      products(first: 10) {\n        edges {\n          node {\n            id\n            title\n          }\n        }\n      }\n    }\n  `);\n\n  const products = response.data?.products?.edges || [];\n\n  return json({ products });\n};\n\nexport default function Products() {\n  const { products } = useLoaderData<typeof loader>();\n\n  return (\n    <div>\n      <h1>Products</h1>\n      <ul>\n        {products.map((p) => (\n          <li key={p.node.id}>{p.node.title}</li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n```\n\n### Example 2: Webhook Signature Verification\n\n**routes/webhooks.ts:**\n```typescript\nimport { define } from '@shopify/shopify-app-remix/server';\nimport { DeliveryMethod } from '@shopify/shopify-api';\n\nexport const webhooks = define({\n  APP_UNINSTALLED: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/app-uninstalled',\n  },\n  PRODUCTS_CREATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/products-create',\n  },\n  PRODUCTS_UPDATE: {\n    deliveryMethod: DeliveryMethod.Http,\n    callbackUrl: '/webhooks/products-update',\n  },\n});\n\nexport async function handleWebhook(\n  topic,\n  shop,\n  body,\n  webhookId\n) {\n  switch (topic) {\n    case 'app/uninstalled':\n      await deleteShopData(shop);\n      break;\n    case 'products/create':\n      await syncProduct(shop, body);\n      break;\n    case 'products/update':\n      await updateProduct(shop, body);\n      break;\n  }\n}\n\nasync function deleteShopData(shop: string) {\n  // Clean up shop data on uninstall\n  console.log(`App uninstalled for shop: ${shop}`);\n}\n\nasync function syncProduct(shop: string, body: any) {\n  const product = JSON.parse(body).product;\n  console.log(`Product created in ${shop}: ${product.title}`);\n}\n\nasync function updateProduct(shop: string, body: any) {\n  const product = JSON.parse(body).product;\n  console.log(`Product updated in ${shop}: ${product.title}`);\n}\n```\n\n**routes/webhooks/app-uninstalled.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport { authenticate } from '~/shopify.server';\nimport { deleteShopData } from '~/models/shop.server';\n\nexport const action = async ({ request }) => {\n  const { webhook } = await authenticate.webhook(request);\n\n  // HMAC signature already validated\n  await deleteShopData(webhook.shop);\n\n  return json({ status: 'processed' });\n};\n```\n\n### Example 3: App Proxy with Customer Loyalty\n\n**routes/api/proxy.tsx:**\n```typescript\nimport { json } from '@shopify/remix-oxygen';\nimport crypto from 'crypto';\n\nexport const loader = async ({ request }) => {\n  const url = new URL(request.url);\n  const hmac = url.searchParams.get('hmac') || '';\n  const timestamp = url.searchParams.get('_t') || '';\n\n  // Build message for HMAC validation\n  const params = new URLSearchParams();\n  Array.from(url.searchParams.entries()).forEach(([key, value]) => {\n    if (key !== 'hmac') params.append(key, value);\n  });\n\n  const message = params.toString();\n  const hash = crypto\n    .createHmac('sha256', process.env.SHOPIFY_API_SECRET || '')\n    .update(message, 'utf8')\n    .digest('base64');\n\n  // Validate signature\n  if (hash !== hmac) {\n    return json({ error: 'Unauthorized' }, { status: 401 });\n  }\n\n  // Validate timestamp\n  const requestTime = parseInt(timestamp, 10);\n  const currentTime = Math.floor(Date.now() / 1000);\n  if (Math.abs(currentTime - requestTime) > 86400) {\n    return json({ error: 'Request expired' }, { status: 401 });\n  }\n\n  // Valid request; return loyalty data\n  const customerId = url.searchParams.get('customer_id') || '';\n  const loyaltyPoints = await getLoyaltyPoints(customerId);\n\n  return json({ loyaltyPoints, success: true });\n};\n\nasync function getLoyaltyPoints(customerId: string): Promise<number> {\n  // Fetch from database\n  return 1500;\n}\n```\n\n## API Version & Scope Reference\n\n**Current when this release was audited:** `2026-07`. Verify Shopify's latest stable version before deployment.\n\n**Essential Scopes:**\n- `write_products`, `read_products` — Manage product catalog\n- `write_orders`, `read_orders` — Access order data\n- `write_customers`, `read_customers` — Manage customer data\n- `write_fulfillments`, `read_fulfillments` — Manage fulfillments\n- `write_inventory`, `read_inventory` — Manage inventory levels\n- `write_discounts`, `read_discounts` — Create/manage discounts\n- `write_draft_orders`, `read_draft_orders` — Draft order management\n- `write_checkout`, `read_checkout` — Checkout customization\n- `write_metafields`, `read_metafields` — Manage custom data\n\n**Scope Review:** Scopes are reviewed by Shopify during app approval. Request only necessary scopes.\n\n## Resources\n\n- **Shopify OAuth Docs:** https://shopify.dev/docs/apps/auth\n- **Session Storage:** https://shopify.dev/docs/apps/auth-session-storage\n- **Webhook Verification:** https://shopify.dev/docs/apps/webhooks/configuration/verify-webhook-authenticity\n- **Token Exchange:** https://shopify.dev/docs/apps/auth/get-access-tokens/token-exchange\n"
}

SHA-256 of public snapshot: c942d1157efb72454dbc49bf8ca729e3c824492d62315d4cd5f24dc8fa1ccb9b