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.


10.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. There is no /auth/callback endpoint and no authorization code grant – Token Exchange is the only supported flow.

+-------------------+        +-------------------+        +-------------------+
|                   |        |                   |        |                   |
|   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.


10.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 locationAdPriority Pro“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 Token Exchange:

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

10.3 Shopify Admin GraphQL API Usage

AdPriority uses the Admin GraphQL API (version 2025-01 or latest stable) as the primary interface for all Shopify data retrieval. GraphQL is preferred over REST for its precise field selection, built-in cursor pagination, and lower rate-limit cost per query.

Products Query

# Fetch active products with cursor-based pagination
query FetchProducts($first: Int!, $after: String) {
  products(first: $first, after: $after, query: "status:active") {
    edges {
      cursor
      node {
        id
        title
        productType
        vendor
        tags
        status
        createdAt
        variants(first: 100) {
          edges {
            node {
              id
              title
              sku
              inventoryQuantity
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Variables:

{
  "first": 50,
  "after": null
}

Product Data We Extract

For each product and variant, AdPriority captures:

FieldSourceUsage
product.idproducts.edges[].node.idGMC ID construction, database key
variant.idvariants.edges[].node.idGMC ID construction (variant-level)
titlenode.titleDisplay in dashboard
productTypenode.productTypeCategory rule matching (90 types at Nexus)
vendornode.vendorBrand tier determination (175 vendors at Nexus)
tagsnode.tagsTag modifiers (NAME BRAND, DEAD50, Sale, etc.)
statusnode.statusFilter to active only
createdAtnode.createdAtNew arrival detection (14-day threshold)
inventoryQuantityvariants.edges[].node.inventoryQuantityInventory status label

Cursor-Based Pagination

AdPriority uses GraphQL cursor-based pagination for all product queries. Each response includes a pageInfo object with hasNextPage and endCursor. The endCursor value is passed as the after variable in the next request.

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

Page 1:  query { products(first: 50, query: "status:active") { ... } }
         -> Returns products 1-50, note endCursor

Page 2:  query { products(first: 50, after: "eyJsYXN0...", query: "status:active") { ... } }
         -> Returns products 51-100

...

Page 49: query { products(first: 50, after: "eyJsYXN0...", query: "status:active") { ... } }
         -> Returns products 2,401-2,425 (final page, hasNextPage = false)

Total API calls: ceil(2,425 / 50) = 49 requests
Estimated cost:  49 queries * ~10 cost points = ~490 points (well within 2,000/sec throttle)

Implementation

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

interface ShopifyProduct {
  id: string;
  title: string;
  productType: string;
  vendor: string;
  tags: string[];
  status: string;
  createdAt: string;
  variants: ShopifyVariant[];
}

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

const PRODUCTS_QUERY = `
  query FetchProducts($first: Int!, $after: String) {
    products(first: $first, after: $after, query: "status:active") {
      edges {
        cursor
        node {
          id
          title
          productType
          vendor
          tags
          status
          createdAt
          variants(first: 100) {
            edges {
              node {
                id
                title
                sku
                inventoryQuantity
              }
            }
            pageInfo {
              hasNextPage
              endCursor
            }
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

export async function fetchAllProducts(
  shop: string,
  accessToken: string
): Promise<ShopifyProduct[]> {
  const allProducts: ShopifyProduct[] = [];
  let hasNextPage = true;
  let cursor: string | null = null;

  while (hasNextPage) {
    const response = await shopifyGraphQL(shop, accessToken, PRODUCTS_QUERY, {
      first: 50,
      after: cursor,
    });

    const { edges, pageInfo } = response.data.products;

    for (const edge of edges) {
      const node = edge.node;
      allProducts.push({
        id: node.id,
        title: node.title,
        productType: node.productType,
        vendor: node.vendor,
        tags: node.tags,
        status: node.status,
        createdAt: node.createdAt,
        variants: node.variants.edges.map((ve: any) => ve.node),
      });
    }

    hasNextPage = pageInfo.hasNextPage;
    cursor = pageInfo.endCursor;
  }

  return allProducts;
}

GraphQL Rate Limits

Shopify GraphQL uses a calculated query cost system:

PlanBucket SizeRestore Rate
Standard1,000 points50 points/second
Shopify Plus2,000 points100 points/second

Each query returns its actual cost in the extensions.cost field. AdPriority monitors currentlyAvailable and throttles when the bucket is below 20% capacity.

// Example extensions.cost response
{
  "requestedQueryCost": 12,
  "actualQueryCost": 10,
  "throttleStatus": {
    "maximumAvailable": 1000,
    "currentlyAvailable": 985,
    "restoreRate": 50
  }
}

10.4 Webhooks

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

Webhook Registration

Webhooks are registered during app installation via the GraphQL Admin API:

mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
  webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
    webhookSubscription {
      id
      topic
      endpoint {
        ... on WebhookHttpEndpoint {
          callbackUrl
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}
// backend/src/integrations/shopify/webhooks.ts
const WEBHOOK_TOPICS = [
  { topic: 'PRODUCTS_CREATE',          address: '/api/v1/webhooks/products-create' },
  { topic: 'PRODUCTS_UPDATE',          address: '/api/v1/webhooks/products-update' },
  { topic: 'PRODUCTS_DELETE',          address: '/api/v1/webhooks/products-delete' },
  { topic: 'INVENTORY_LEVELS_UPDATE',  address: '/api/v1/webhooks/inventory-update' },
  { topic: 'COLLECTIONS_UPDATE',       address: '/api/v1/webhooks/collections-update' },
  { topic: 'APP_SUBSCRIPTIONS_UPDATE', address: '/api/v1/webhooks/subscriptions-update' },
  { topic: 'APP_UNINSTALLED',          address: '/api/v1/webhooks/app-uninstalled' },
  { topic: 'SHOP_UPDATE',             address: '/api/v1/webhooks/shop-update' },
  { topic: 'BULK_OPERATIONS_FINISH',   address: '/api/v1/webhooks/bulk-operations-finish' },
];

export async function registerWebhooks(
  shop: string,
  accessToken: string,
  appUrl: string
): Promise<void> {
  for (const webhook of WEBHOOK_TOPICS) {
    await shopifyGraphQL(shop, accessToken, WEBHOOK_SUBSCRIPTION_CREATE, {
      topic: webhook.topic,
      webhookSubscription: {
        callbackUrl: `${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

Inventory and Collection Webhooks

WebhookTriggerAdPriority Action
INVENTORY_LEVELS_UPDATEStock quantity changes at any locationRecalculate inventory status label (in-stock, low-inventory, dead-stock); update priority if threshold crossed
COLLECTIONS_UPDATECollection membership changesRe-evaluate category group if product moved between collections

App Lifecycle Webhooks

WebhookTriggerAdPriority Action
APP_UNINSTALLEDMerchant removes the appDelete all store data: products, rules, calendars, sync logs. Cancel subscription. Revoke tokens. Mandatory.
APP_SUBSCRIPTIONS_UPDATESubscription status changes (activated, expired, cancelled)Update tenant plan_tier and feature flags; downgrade features if plan cancelled
SHOP_UPDATEStore settings change (name, domain, currency)Update tenant record with new shop metadata
BULK_OPERATIONS_FINISHA bulk GraphQL operation completesProcess bulk operation results (used for large catalog imports)

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 /api/v1/webhooks/customers-data-requestReturn 200 OK with { "message": "AdPriority does not store customer personal data" }
customers/redactPOST /api/v1/webhooks/customers-redactReturn 200 OK – no customer data to delete
shop/redactPOST /api/v1/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;
  const webhookId = req.headers['x-shopify-webhook-id'] 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;
  req.shopifyWebhookId = webhookId;
  next();
}

Webhook Deduplication

Shopify may deliver the same webhook more than once. AdPriority uses the X-Shopify-Webhook-Id header to deduplicate. Each webhook ID is stored in a short-lived cache (Redis or in-memory with TTL) and checked before processing.

// backend/src/integrations/shopify/webhook-dedup.ts
import { redis } from '../../lib/redis';

const DEDUP_TTL_SECONDS = 300; // 5 minutes

export async function isWebhookDuplicate(webhookId: string): Promise<boolean> {
  const key = `webhook:dedup:${webhookId}`;
  const exists = await redis.get(key);
  if (exists) return true;

  await redis.set(key, '1', 'EX', DEDUP_TTL_SECONDS);
  return false;
}

// Usage in webhook handler
export async function handleWebhook(req: Request, res: Response) {
  // Always return 200 immediately
  res.status(200).json({ received: true });

  const webhookId = req.shopifyWebhookId;
  if (!webhookId || await isWebhookDuplicate(webhookId)) {
    return; // Skip duplicate
  }

  // Enqueue for async processing
  await webhookQueue.add(req.shopifyTopic, {
    shop: req.shopifyShop,
    topic: req.shopifyTopic,
    webhookId,
    payload: req.body,
  });
}

Webhook Processing Flow

+------------------+     +-------------------+     +------------------+
| Shopify Platform |     | AdPriority API    |     | Background Queue |
+--------+---------+     +--------+----------+     +--------+---------+
         |                        |                         |
         | POST /api/v1/webhooks/ |                         |
         +----------------------->|                         |
         |                        |                         |
         |                 Verify HMAC                      |
         |                 Check dedup (webhook ID)         |
         |                 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.


10.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/v1${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              ||
|                      +-----------------------------+|
+----------------------------------------------------+

10.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"
    lineItems: [{
      plan: {
        appRecurringPricingDetails: {
          price: { amount: "49.00", currencyCode: USD }
          interval: EVERY_30_DAYS
        }
      }
    }]
    returnUrl: "https://app.adpriority.com/api/v1/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
AdPriority$49$49014 daysUp to 5,000Category rules, seasonal calendar, tag modifiers, Google Sheets sync, new arrival boost
AdPriority Pro$149$1,49014 daysUnlimitedEverything in AdPriority + Google Ads performance data, automated recommendations, Content API sync, inventory-level scoring

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

10.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"
      }
    ]
  }
}

Bulk Metafield Operations

For large catalog updates (e.g., seasonal transitions affecting thousands of products), AdPriority uses the metafieldsSet mutation to batch metafield writes:

mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields {
      key
      namespace
      value
      ownerType
    }
    userErrors {
      field
      message
    }
  }
}

Variables (batch of up to 25 metafields per mutation):

{
  "metafields": [
    {
      "ownerId": "gid://shopify/Product/8779355160808",
      "namespace": "adpriority",
      "key": "score",
      "value": "5",
      "type": "number_integer"
    },
    {
      "ownerId": "gid://shopify/Product/9128994570472",
      "namespace": "adpriority",
      "key": "score",
      "value": "4",
      "type": "number_integer"
    },
    {
      "ownerId": "gid://shopify/Product/9057367064808",
      "namespace": "adpriority",
      "key": "score",
      "value": "3",
      "type": "number_integer"
    }
  ]
}

For full catalog metafield updates, AdPriority chunks the mutations into batches of 25 and processes them sequentially, respecting GraphQL rate limits.


10.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 49 paginated GraphQL requests.

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

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

Step 2: Register Webhooks (GraphQL)
  PRODUCTS_CREATE, PRODUCTS_UPDATE, PRODUCTS_DELETE,
  INVENTORY_LEVELS_UPDATE, COLLECTIONS_UPDATE,
  APP_SUBSCRIPTIONS_UPDATE, APP_UNINSTALLED,
  SHOP_UPDATE, BULK_OPERATIONS_FINISH,
  GDPR webhooks (3)

Step 3: Fetch Product Catalog (GraphQL)
  query { products(first: 50, after: cursor, query: "status:active") }
  Nexus: ~49 requests, ~5,582 products total, 2,425 active

Step 4: Process Each Product
  For each active product:
    +-- Extract: id, variants, productType, vendor, tags, createdAt
    +-- 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 via metafieldsSet, 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
GraphQL pages needed (active, 50/page)49

10.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 exceeded (GraphQL: THROTTLED error)Exponential backoff; check extensions.cost.throttleStatus for restore timing
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 (tenant_id, shopify_product_id, shopify_variant_id). Webhook deduplication via X-Shopify-Webhook-Id provides an additional layer of protection against duplicate processing.


10.10 Security Checklist

RequirementImplementationStatus
Token Exchange onlyNo legacy OAuth code exchange; session tokens exchanged for offline tokensRequired
Session token validationJWT verify with client secret, audience, expiryRequired
Webhook HMAC verificationHMAC-SHA256 with timingSafeEqualRequired
Webhook deduplicationX-Shopify-Webhook-Id checked against Redis/cache before processingRequired
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 tenant_idRequired

10.11 Chapter Summary

AdPriority’s Shopify integration is built on the modern Token Exchange authentication flow (no legacy OAuth code exchange), embedded via App Bridge 4.1, and uses Polaris v13.9 for a native admin experience. The app reads the full product catalog via cursor-paginated GraphQL queries, maintains real-time sync through 9 webhooks (plus 3 GDPR), stores priority metadata in metafields using bulk metafieldsSet mutations, and manages subscriptions through Shopify’s Billing API. All operations are secured with HMAC verification, webhook deduplication, encrypted token storage, and shop-scoped data isolation.

Key numbers for Nexus Clothing:

  • 49 GraphQL queries to sync the full active catalog (2,425 products at 50/page)
  • 12 webhooks registered (9 operational + 3 GDPR)
  • 3 API scopes requested (minimum necessary)
  • 2 pricing tiers: AdPriority ($49/mo) and AdPriority Pro ($149/mo)
  • 60-second session token lifetime (auto-refreshed by App Bridge)