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:
| Claim | Description | Example |
|---|---|---|
iss | Shop admin URL | https://nexus-clothes.myshopify.com/admin |
dest | Shop URL | https://nexus-clothes.myshopify.com |
aud | App client ID | a1b2c3d4e5f6... |
sub | Shopify user ID | 12345678 |
exp | Expiry timestamp | 1707500000 |
nbf | Not before timestamp | 1707499940 |
iat | Issued at timestamp | 1707499940 |
jti | Unique token ID | abc-123-def |
sid | Session ID | session_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.
| Scope | Purpose | Tier | Justification for App Store Review |
|---|---|---|---|
read_products | Fetch 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_products | Write metafields for priority storage and sync status | All | “AdPriority stores priority scores and sync status in product metafields for persistent data across sessions.” |
read_inventory | Check stock levels per location | AdPriority 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:
| Field | Source | Usage |
|---|---|---|
product.id | products.edges[].node.id | GMC ID construction, database key |
variant.id | variants.edges[].node.id | GMC ID construction (variant-level) |
title | node.title | Display in dashboard |
productType | node.productType | Category rule matching (90 types at Nexus) |
vendor | node.vendor | Brand tier determination (175 vendors at Nexus) |
tags | node.tags | Tag modifiers (NAME BRAND, DEAD50, Sale, etc.) |
status | node.status | Filter to active only |
createdAt | node.createdAt | New arrival detection (14-day threshold) |
inventoryQuantity | variants.edges[].node.inventoryQuantity | Inventory 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:
| Plan | Bucket Size | Restore Rate |
|---|---|---|
| Standard | 1,000 points | 50 points/second |
| Shopify Plus | 2,000 points | 100 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
| Webhook | Trigger | AdPriority Action |
|---|---|---|
PRODUCTS_CREATE | New product added in Shopify | Apply category rules, calculate initial priority, add to sync queue |
PRODUCTS_UPDATE | Product edited (title, type, tags, etc.) | Re-evaluate priority if product_type or tags changed; skip if only price/description changed |
PRODUCTS_DELETE | Product deleted from Shopify | Mark as deleted in database, remove from Google Sheet on next sync |
Inventory and Collection Webhooks
| Webhook | Trigger | AdPriority Action |
|---|---|---|
INVENTORY_LEVELS_UPDATE | Stock quantity changes at any location | Recalculate inventory status label (in-stock, low-inventory, dead-stock); update priority if threshold crossed |
COLLECTIONS_UPDATE | Collection membership changes | Re-evaluate category group if product moved between collections |
App Lifecycle Webhooks
| Webhook | Trigger | AdPriority Action |
|---|---|---|
APP_UNINSTALLED | Merchant removes the app | Delete all store data: products, rules, calendars, sync logs. Cancel subscription. Revoke tokens. Mandatory. |
APP_SUBSCRIPTIONS_UPDATE | Subscription status changes (activated, expired, cancelled) | Update tenant plan_tier and feature flags; downgrade features if plan cancelled |
SHOP_UPDATE | Store settings change (name, domain, currency) | Update tenant record with new shop metadata |
BULK_OPERATIONS_FINISH | A bulk GraphQL operation completes | Process 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.
| Webhook | Endpoint | Response |
|---|---|---|
customers/data_request | POST /api/v1/webhooks/customers-data-request | Return 200 OK with { "message": "AdPriority does not store customer personal data" } |
customers/redact | POST /api/v1/webhooks/customers-redact | Return 200 OK – no customer data to delete |
shop/redact | POST /api/v1/webhooks/shop-redact | Delete 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
| Feature | Usage in AdPriority |
|---|---|
| Session tokens | Authentication for all API calls |
| Navigation | App pages within Shopify Admin sidebar |
| Toast notifications | Success/error messages after sync operations |
| Modal | Confirmation dialogs for bulk priority changes |
| Loading bar | Progress indication during catalog sync |
| Redirect | Navigate 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
| Tier | Monthly | Annual | Trial | Products | Features |
|---|---|---|---|---|---|
| AdPriority | $49 | $490 | 14 days | Up to 5,000 | Category rules, seasonal calendar, tag modifiers, Google Sheets sync, new arrival boost |
| AdPriority Pro | $149 | $1,490 | 14 days | Unlimited | Everything 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:
- Stop all background jobs for the store
- Delete the offline access token
- Archive (not immediately delete) product data for 30 days
- Execute
shop/redactcleanup 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
| Namespace | Key | Type | Example Value |
|---|---|---|---|
adpriority | score | number_integer | 4 |
adpriority | source | single_line_text_field | seasonal |
adpriority | locked | boolean | false |
adpriority | last_synced | date_time | 2026-02-10T10:00:00Z |
adpriority | labels | json | {"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
| Metric | Value |
|---|---|
| Shopify store | nexus-clothes.myshopify.com |
| Total products | 5,582 |
| Active products | 2,425 |
| Archived products | 3,121 |
| Draft products | 36 |
| Unique product types | 90 |
| Unique vendors | 175 |
| Unique tags | 2,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 Status | Meaning | AdPriority Response |
|---|---|---|
401 | Invalid or expired token | Re-authenticate; if persistent, prompt reinstall |
402 | Store frozen (unpaid) | Pause sync, notify via dashboard |
403 | Scope not granted | Prompt merchant to re-approve scopes |
404 | Product deleted between fetch and process | Skip gracefully, log warning |
429 | Rate limit exceeded (GraphQL: THROTTLED error) | Exponential backoff; check extensions.cost.throttleStatus for restore timing |
500/502/503 | Shopify server error | Retry 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
| Requirement | Implementation | Status |
|---|---|---|
| Token Exchange only | No legacy OAuth code exchange; session tokens exchanged for offline tokens | Required |
| Session token validation | JWT verify with client secret, audience, expiry | Required |
| Webhook HMAC verification | HMAC-SHA256 with timingSafeEqual | Required |
| Webhook deduplication | X-Shopify-Webhook-Id checked against Redis/cache before processing | Required |
| Token encryption at rest | AES-256-GCM for offline access tokens | Required |
| No tokens in logs | Redact all tokens from application logs | Required |
| HTTPS only | All endpoints served over TLS | Required |
| Scope minimization | Only read_products, write_products, read_inventory | Required |
| GDPR webhooks | Three mandatory endpoints implemented | Required |
| Shop-scoped queries | All database queries filtered by tenant_id | Required |
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)