Chapter 10: Shopify Integration

AdPriority is an embedded Shopify app that reads product data, applies priority scoring rules, and stores sync metadata via metafields. This chapter covers every touchpoint between AdPriority and the Shopify platform: authentication, API usage, webhooks, billing, and the App Bridge embedded experience.


8.1 Authentication: Token Exchange Flow

Shopify deprecated the legacy authorization code flow for embedded apps. All new apps must use Token Exchange, where the frontend obtains a session token from App Bridge and the backend exchanges it for an offline access token.

+-------------------+        +-------------------+        +-------------------+
|                   |        |                   |        |                   |
|   Shopify Admin   |        |   AdPriority UI   |        | AdPriority Backend|
|   (App Bridge)    |        |   (React/Polaris) |        |   (Express/TS)    |
|                   |        |                   |        |                   |
+--------+----------+        +--------+----------+        +--------+----------+
         |                            |                            |
         |  1. Merchant opens app     |                            |
         +--------------------------->|                            |
         |                            |                            |
         |  2. App Bridge issues      |                            |
         |     session token (JWT)    |                            |
         |<---------------------------+                            |
         |                            |                            |
         |                            |  3. Frontend sends session |
         |                            |     token in Authorization |
         |                            |     header                 |
         |                            +--------------------------->|
         |                            |                            |
         |                            |  4. Backend validates JWT  |
         |                            |     (signature, audience,  |
         |                            |      expiry)               |
         |                            |                            |
         |                            |  5. If first install:      |
         |                            |     POST /oauth/token with |
         |                            |     session token to get   |
         |                            |     offline access token   |
         |                            |                            |
         |                            |  6. Store encrypted access |
         |                            |     token in database      |
         |                            |<---------------------------+
         |                            |                            |
         |                            |  7. Return app data        |
         |                            |<---------------------------+

Session Token Structure

App Bridge 4.1 automatically issues session tokens as JWTs signed with the app’s client secret. Each token is valid for 60 seconds and is auto-refreshed by App Bridge before expiry.

JWT Claims:

ClaimDescriptionExample
issShop admin URLhttps://nexus-clothes.myshopify.com/admin
destShop URLhttps://nexus-clothes.myshopify.com
audApp client IDa1b2c3d4e5f6...
subShopify user ID12345678
expExpiry timestamp1707500000
nbfNot before timestamp1707499940
iatIssued at timestamp1707499940
jtiUnique token IDabc-123-def
sidSession IDsession_xyz

Server-Side Validation

// backend/src/integrations/shopify/session.ts
import jwt from 'jsonwebtoken';

interface ShopifySessionToken {
  iss: string;   // https://{shop}.myshopify.com/admin
  dest: string;  // https://{shop}.myshopify.com
  aud: string;   // client_id
  sub: string;   // user_id
  exp: number;
  nbf: number;
  iat: number;
  jti: string;
  sid: string;
}

export function validateSessionToken(token: string): ShopifySessionToken {
  const decoded = jwt.verify(token, process.env.SHOPIFY_CLIENT_SECRET!, {
    algorithms: ['HS256'],
    audience: process.env.SHOPIFY_CLIENT_ID!,
    clockTolerance: 10, // seconds of clock skew tolerance
  }) as ShopifySessionToken;

  // Extract shop domain from iss claim
  const shopDomain = decoded.iss.replace('https://', '').replace('/admin', '');

  if (!shopDomain.endsWith('.myshopify.com')) {
    throw new Error('Invalid shop domain in session token');
  }

  return decoded;
}

Token Exchange for Offline Access

On first install, the backend exchanges the session token for a long-lived offline access token:

// backend/src/integrations/shopify/oauth.ts
import axios from 'axios';

interface TokenExchangeResponse {
  access_token: string;
  scope: string;
}

export async function exchangeToken(
  shop: string,
  sessionToken: string
): Promise<TokenExchangeResponse> {
  const response = await axios.post(
    `https://${shop}/admin/oauth/access_token`,
    {
      client_id: process.env.SHOPIFY_CLIENT_ID,
      client_secret: process.env.SHOPIFY_CLIENT_SECRET,
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: sessionToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:id-token',
      requested_token_type:
        'urn:shopify:params:oauth:token-type:offline-access-token',
    }
  );

  return response.data;
}

The offline access token does not expire. It persists until the merchant uninstalls the app. Store it encrypted at rest (AES-256) in the database, never in logs or client-side code.


8.2 Required API Scopes

AdPriority requests the minimum scopes necessary. Shopify reviewers reject apps that over-request permissions.

ScopePurposeTierJustification for App Store Review
read_productsFetch product catalog (titles, types, tags, collections, variants)All“AdPriority reads product information to apply priority scoring rules and generate Google Merchant Center custom labels.”
write_productsWrite metafields for priority storage and sync statusAll“AdPriority stores priority scores and sync status in product metafields for persistent data across sessions.”
read_inventoryCheck stock levels per locationGrowth+“AdPriority uses inventory levels to adjust priority scores, lowering priority for out-of-stock items.”

Scopes are declared in the app configuration (TOML) and requested during OAuth:

# shopify.app.toml
[access_scopes]
scopes = "read_products,write_products,read_inventory"

8.3 Shopify Admin REST API Usage

AdPriority uses the Admin REST API (version 2024-10 or latest stable) for product data retrieval. GraphQL is used selectively for billing and metafield operations.

Products Endpoint

Endpoint: GET /admin/api/2024-10/products.json

ParameterValuePurpose
limit250Maximum products per page
since_id{last_product_id}Cursor-based pagination
fieldsid,title,product_type,vendor,tags,status,variants,created_atMinimize payload
statusactiveOnly fetch active products

Response structure (relevant fields):

{
  "products": [
    {
      "id": 8779355160808,
      "title": "Jordan Craig Stacked Jeans - Dark Blue",
      "product_type": "Men-Bottoms-Pants-Jeans",
      "vendor": "Jordan Craig",
      "tags": "jordan-craig, NAME BRAND, in-stock, Men",
      "status": "active",
      "created_at": "2025-11-15T10:30:00-05:00",
      "variants": [
        {
          "id": 46050142748904,
          "title": "30 / Dark Blue",
          "sku": "107438",
          "inventory_quantity": 12
        },
        {
          "id": 46050142781672,
          "title": "32 / Dark Blue",
          "sku": "107439",
          "inventory_quantity": 8
        }
      ]
    }
  ]
}

Product Data We Extract

For each product and variant, AdPriority captures:

FieldSourceUsage
product.idproducts[].idGMC ID construction, database key
variant.idproducts[].variants[].idGMC ID construction (variant-level)
titleproducts[].titleDisplay in dashboard
product_typeproducts[].product_typeCategory rule matching (90 types at Nexus)
vendorproducts[].vendorBrand tier determination (175 vendors at Nexus)
tagsproducts[].tagsTag modifiers (NAME BRAND, DEAD50, Sale, etc.)
statusproducts[].statusFilter to active only
created_atproducts[].created_atNew arrival detection (14-day threshold)
inventory_quantityvariants[].inventory_quantityInventory status label

Variants Endpoint (Supplementary)

For products with many variants, use the dedicated endpoint:

Endpoint: GET /admin/api/2024-10/products/{product_id}/variants.json

This is used during reconciliation when variant data may be incomplete from the products endpoint.

Pagination Strategy: since_id

AdPriority uses Shopify’s since_id pagination for full catalog sync. This is the most efficient approach for bulk retrieval, avoiding the overhead of cursor-based Link headers.

Full Catalog Sync Flow (Nexus: 2,425 active products)
======================================================

Page 1:  GET /products.json?limit=250&status=active&since_id=0
         -> Returns products 1-250, note last product.id

Page 2:  GET /products.json?limit=250&status=active&since_id={last_id}
         -> Returns products 251-500

...

Page 10: GET /products.json?limit=250&status=active&since_id={last_id}
         -> Returns products 2,251-2,425 (final page, <250 results)

Total API calls: ceil(2,425 / 250) = 10 requests
Time estimate:   ~5-10 seconds (with rate limit compliance)

Implementation

// backend/src/integrations/shopify/products.ts
import { ShopifyClient } from './client';

interface ShopifyProduct {
  id: number;
  title: string;
  product_type: string;
  vendor: string;
  tags: string;
  status: string;
  created_at: string;
  variants: ShopifyVariant[];
}

interface ShopifyVariant {
  id: number;
  title: string;
  sku: string;
  inventory_quantity: number;
}

export async function fetchAllProducts(
  client: ShopifyClient
): Promise<ShopifyProduct[]> {
  const allProducts: ShopifyProduct[] = [];
  let sinceId = 0;
  const fields = 'id,title,product_type,vendor,tags,status,created_at,variants';

  while (true) {
    const response = await client.get('/products.json', {
      params: {
        limit: 250,
        since_id: sinceId,
        status: 'active',
        fields,
      },
    });

    const products: ShopifyProduct[] = response.data.products;

    if (products.length === 0) break;

    allProducts.push(...products);
    sinceId = products[products.length - 1].id;

    // Respect Shopify rate limits (40 requests/second bucket)
    await sleep(100);
  }

  return allProducts;
}

API Rate Limits

Shopify uses a leaky bucket algorithm:

PlanBucket SizeLeak Rate
Standard40 requests2/second
Shopify Plus80 requests4/second

AdPriority monitors the X-Shopify-Shop-Api-Call-Limit response header (e.g., 32/40) and throttles when the bucket is above 80% capacity.


8.4 Webhooks

Webhooks keep AdPriority synchronized with product changes in real time. All webhooks are verified via HMAC-SHA256 before processing.

Webhook Registration

Webhooks are registered during app installation via the REST API:

// backend/src/integrations/shopify/webhooks.ts
const WEBHOOK_TOPICS = [
  { topic: 'products/create',          address: '/webhooks/products-create' },
  { topic: 'products/update',          address: '/webhooks/products-update' },
  { topic: 'products/delete',          address: '/webhooks/products-delete' },
  { topic: 'app/uninstalled',          address: '/webhooks/app-uninstalled' },
];

export async function registerWebhooks(
  client: ShopifyClient,
  appUrl: string
): Promise<void> {
  for (const webhook of WEBHOOK_TOPICS) {
    await client.post('/webhooks.json', {
      webhook: {
        topic: webhook.topic,
        address: `${appUrl}${webhook.address}`,
        format: 'json',
      },
    });
  }
}

Product Webhooks

WebhookTriggerAdPriority Action
products/createNew product added in ShopifyApply category rules, calculate initial priority, add to sync queue
products/updateProduct edited (title, type, tags, etc.)Re-evaluate priority if product_type or tags changed; skip if only price/description changed
products/deleteProduct deleted from ShopifyMark as deleted in database, remove from Google Sheet on next sync

App Lifecycle Webhook

WebhookTriggerAdPriority Action
app/uninstalledMerchant removes the appDelete all store data: products, rules, calendars, sync logs. Cancel subscription. Revoke tokens. Mandatory.

GDPR Mandatory Webhooks

These three webhooks are required for App Store approval. Apps are rejected without them. Since AdPriority stores no customer PII (only product data), these return 200 OK with appropriate responses.

WebhookEndpointResponse
customers/data_requestPOST /webhooks/customers-data-requestReturn 200 OK with { "message": "AdPriority does not store customer personal data" }
customers/redactPOST /webhooks/customers-redactReturn 200 OK – no customer data to delete
shop/redactPOST /webhooks/shop-redactDelete all data for the shop (products, rules, tokens, sync logs). Return 200 OK.

HMAC Verification

Every incoming webhook must be verified. The X-Shopify-Hmac-Sha256 header contains a Base64-encoded HMAC-SHA256 digest of the raw request body, computed with the app’s client secret.

// backend/src/api/middleware/shopify.ts
import crypto from 'crypto';

export function verifyWebhookHmac(
  rawBody: Buffer,
  hmacHeader: string,
  secret: string
): boolean {
  const calculated = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(calculated),
    Buffer.from(hmacHeader)
  );
}

// Express middleware
export function shopifyWebhookAuth(req: Request, res: Response, next: Function) {
  const hmac = req.headers['x-shopify-hmac-sha256'] as string;
  const topic = req.headers['x-shopify-topic'] as string;
  const shop = req.headers['x-shopify-shop-domain'] as string;

  if (!hmac || !topic || !shop) {
    return res.status(401).json({ error: 'Missing Shopify headers' });
  }

  if (!verifyWebhookHmac(req.rawBody, hmac, process.env.SHOPIFY_CLIENT_SECRET!)) {
    return res.status(401).json({ error: 'Invalid HMAC signature' });
  }

  req.shopifyTopic = topic;
  req.shopifyShop = shop;
  next();
}

Webhook Processing Flow

+------------------+     +-------------------+     +------------------+
| Shopify Platform |     | AdPriority API    |     | Background Queue |
+--------+---------+     +--------+----------+     +--------+---------+
         |                        |                         |
         | POST /webhooks/...     |                         |
         +----------------------->|                         |
         |                        |                         |
         |                 Verify HMAC                      |
         |                 Extract topic + shop             |
         |                 Return 200 OK immediately        |
         |<-----------------------+                         |
         |                        |                         |
         |                 Enqueue job for                  |
         |                 async processing                 |
         |                        +------------------------>|
         |                        |                         |
         |                        |              Process webhook:
         |                        |              - Fetch full product
         |                        |              - Recalculate priority
         |                        |              - Update database
         |                        |              - Flag for GMC sync

Key principle: Return 200 OK immediately, then process asynchronously via Bull queue. Shopify retries webhooks that do not receive a 2xx response within 5 seconds.


8.5 App Bridge 4.1: Embedded Experience

AdPriority runs as an embedded app inside the Shopify Admin. App Bridge 4.1 provides the shell: navigation, session tokens, and native UI primitives.

Frontend Setup

// admin-ui/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AppProvider } from '@shopify/polaris';
import enTranslations from '@shopify/polaris/locales/en.json';
import App from './App';

// App Bridge 4.1 initializes automatically when loaded
// inside Shopify Admin iframe. No manual init required.

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <AppProvider i18n={enTranslations}>
      <App />
    </AppProvider>
  </React.StrictMode>
);

Authenticated Fetch

App Bridge 4.1 provides an authenticatedFetch wrapper that automatically attaches the session token to every request:

// admin-ui/src/utils/api.ts

// App Bridge 4.1 injects shopify global
declare const shopify: {
  idToken(): Promise<string>;
};

export async function apiFetch(path: string, options: RequestInit = {}) {
  const token = await shopify.idToken();

  const response = await fetch(`/api${path}`, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

Key App Bridge Features Used

FeatureUsage in AdPriority
Session tokensAuthentication for all API calls
NavigationApp pages within Shopify Admin sidebar
Toast notificationsSuccess/error messages after sync operations
ModalConfirmation dialogs for bulk priority changes
Loading barProgress indication during catalog sync
RedirectNavigate to Shopify billing confirmation page

App Routing Within Shopify Admin

Shopify Admin
+----------------------------------------------------+
|  [Sidebar]           [Main Content Area]            |
|                                                     |
|  Home                +-----------------------------+|
|  Orders              | AdPriority App (iframe)     ||
|  Products            |                             ||
|  ...                 |  /app/dashboard             ||
|  Apps >              |  /app/products              ||
|    AdPriority        |  /app/rules                 ||
|                      |  /app/calendar              ||
|                      |  /app/sync                  ||
|                      |  /app/settings              ||
|                      +-----------------------------+|
+----------------------------------------------------+

8.6 Billing API

AdPriority uses Shopify’s Billing API (GraphQL) for subscription management. Shopify handles payment collection, invoicing, and the merchant-facing subscription UI.

Creating a Subscription

mutation AppSubscriptionCreate {
  appSubscriptionCreate(
    name: "AdPriority Growth"
    lineItems: [{
      plan: {
        appRecurringPricingDetails: {
          price: { amount: "79.00", currencyCode: USD }
          interval: EVERY_30_DAYS
        }
      }
    }]
    returnUrl: "https://app.adpriority.com/billing/callback?shop=nexus-clothes.myshopify.com"
    test: false
    trialDays: 14
  ) {
    appSubscription {
      id
      status
    }
    confirmationUrl
    userErrors {
      field
      message
    }
  }
}

Subscription Flow

1. Merchant selects plan in AdPriority settings
2. Backend creates appSubscription via GraphQL
3. Shopify returns confirmationUrl
4. App Bridge redirects merchant to confirmationUrl
5. Merchant approves charge in Shopify admin
6. Shopify redirects to returnUrl with charge_id
7. Backend verifies subscription status
8. Backend activates tier features for the store

Pricing Tiers

TierMonthlyAnnualTrialProductsFeatures
Starter$29$29014 daysUp to 500Basic rules, manual sync
Growth$79$79014 daysUp to 5,000Seasonal automation, daily sync
Pro$199$1,99014 daysUnlimitedGoogle Ads integration, hourly sync

Handling Uninstall and Cancellation

When app/uninstalled fires, the subscription is automatically cancelled by Shopify. AdPriority must:

  1. Stop all background jobs for the store
  2. Delete the offline access token
  3. Archive (not immediately delete) product data for 30 days
  4. Execute shop/redact cleanup when received

8.7 Metafield Storage

AdPriority uses Shopify product metafields to persist priority data directly on the product. This ensures data survives app reinstallation and provides a backup to the database.

Metafield Namespace and Keys

NamespaceKeyTypeExample Value
adpriorityscorenumber_integer4
adprioritysourcesingle_line_text_fieldseasonal
adprioritylockedbooleanfalse
adprioritylast_synceddate_time2026-02-10T10:00:00Z
adprioritylabelsjson{"l0":"priority-4","l1":"winter",...}

Writing Metafields (GraphQL)

mutation SetPriorityMetafield($input: ProductInput!) {
  productUpdate(input: $input) {
    product {
      id
      metafields(first: 5, namespace: "adpriority") {
        edges {
          node {
            key
            value
          }
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

Variables:

{
  "input": {
    "id": "gid://shopify/Product/8779355160808",
    "metafields": [
      {
        "namespace": "adpriority",
        "key": "score",
        "value": "4",
        "type": "number_integer"
      },
      {
        "namespace": "adpriority",
        "key": "source",
        "value": "seasonal",
        "type": "single_line_text_field"
      }
    ]
  }
}

8.8 Full Catalog Sync: Install-Time Workflow

When a merchant installs AdPriority, the app performs a complete product import. For the Nexus store, this involves 2,425 active products across approximately 10 paginated API requests.

Install-Time Catalog Sync
==========================

Step 1: Token Exchange
  Session token --> offline access token (stored encrypted)

Step 2: Register Webhooks
  products/create, products/update, products/delete,
  app/uninstalled, GDPR webhooks (3)

Step 3: Fetch Product Catalog
  GET /products.json (since_id pagination, 250/page)
  Nexus: ~10 requests, ~5,582 products total, 2,425 active

Step 4: Process Each Product
  For each active product:
    +-- Extract: id, variants, product_type, vendor, tags, created_at
    +-- Match category group (from 90 types into 20 groups)
    +-- Calculate initial priority (seasonal rules + tag modifiers)
    +-- Generate GMC IDs: shopify_US_{productId}_{variantId}
    +-- Store in product_mappings table
    +-- Write metafield (batch, async)

Step 5: Generate Supplemental Feed
  Write all rows to Google Sheet via Sheets API
  Nexus: ~15,000-20,000 variant rows

Step 6: Dashboard Ready
  Display product list with assigned priorities
  Show sync status and next steps

Nexus Store Reference Data

MetricValue
Shopify storenexus-clothes.myshopify.com
Total products5,582
Active products2,425
Archived products3,121
Draft products36
Unique product types90
Unique vendors175
Unique tags2,522
Estimated active variants~15,000-20,000
GMC variants (total)124,060
API pages needed (active, 250/page)10

8.9 Error Handling and Resilience

Common Shopify API Errors

HTTP StatusMeaningAdPriority Response
401Invalid or expired tokenRe-authenticate; if persistent, prompt reinstall
402Store frozen (unpaid)Pause sync, notify via dashboard
403Scope not grantedPrompt merchant to re-approve scopes
404Product deleted between fetch and processSkip gracefully, log warning
429Rate limit exceededExponential backoff starting at 1 second
500/502/503Shopify server errorRetry up to 3 times with backoff

Retry Configuration

const SHOPIFY_RETRY_CONFIG = {
  maxRetries: 3,
  initialDelayMs: 1000,
  backoffMultiplier: 2,
  maxDelayMs: 10000,
  retryableStatuses: [429, 500, 502, 503],
};

Idempotency

All webhook handlers are idempotent. Processing the same products/update webhook twice produces the same result. The database uses UPSERT (insert on conflict update) keyed on (store_id, shopify_product_id, shopify_variant_id).


8.10 Security Checklist

RequirementImplementationStatus
Session token validationJWT verify with client secret, audience, expiryRequired
Webhook HMAC verificationHMAC-SHA256 with timingSafeEqualRequired
Token encryption at restAES-256-GCM for offline access tokensRequired
No tokens in logsRedact all tokens from application logsRequired
HTTPS onlyAll endpoints served over TLSRequired
Scope minimizationOnly read_products, write_products, read_inventoryRequired
GDPR webhooksThree mandatory endpoints implementedRequired
Shop-scoped queriesAll database queries filtered by store_idRequired

8.11 Chapter Summary

AdPriority’s Shopify integration is built on the modern Token Exchange authentication flow, embedded via App Bridge 4.1, and uses Polaris v13.9 for a native admin experience. The app reads the full product catalog via paginated REST API calls, maintains real-time sync through webhooks, stores priority metadata in metafields, and manages subscriptions through Shopify’s Billing API. All operations are secured with HMAC verification, encrypted token storage, and shop-scoped data isolation.

Key numbers for Nexus Clothing:

  • 10 API calls to sync the full active catalog (2,425 products)
  • 7 webhooks registered (4 product/app + 3 GDPR)
  • 3 API scopes requested (minimum necessary)
  • 60-second session token lifetime (auto-refreshed by App Bridge)