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:
| 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.
8.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 | Growth+ | “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
| Parameter | Value | Purpose |
|---|---|---|
limit | 250 | Maximum products per page |
since_id | {last_product_id} | Cursor-based pagination |
fields | id,title,product_type,vendor,tags,status,variants,created_at | Minimize payload |
status | active | Only 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:
| Field | Source | Usage |
|---|---|---|
product.id | products[].id | GMC ID construction, database key |
variant.id | products[].variants[].id | GMC ID construction (variant-level) |
title | products[].title | Display in dashboard |
product_type | products[].product_type | Category rule matching (90 types at Nexus) |
vendor | products[].vendor | Brand tier determination (175 vendors at Nexus) |
tags | products[].tags | Tag modifiers (NAME BRAND, DEAD50, Sale, etc.) |
status | products[].status | Filter to active only |
created_at | products[].created_at | New arrival detection (14-day threshold) |
inventory_quantity | variants[].inventory_quantity | Inventory 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:
| Plan | Bucket Size | Leak Rate |
|---|---|---|
| Standard | 40 requests | 2/second |
| Shopify Plus | 80 requests | 4/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
| 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 |
App Lifecycle Webhook
| Webhook | Trigger | AdPriority Action |
|---|---|---|
app/uninstalled | Merchant removes the app | Delete 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.
| Webhook | Endpoint | Response |
|---|---|---|
customers/data_request | POST /webhooks/customers-data-request | Return 200 OK with { "message": "AdPriority does not store customer personal data" } |
customers/redact | POST /webhooks/customers-redact | Return 200 OK – no customer data to delete |
shop/redact | POST /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;
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
| 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 ||
| +-----------------------------+|
+----------------------------------------------------+
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
| Tier | Monthly | Annual | Trial | Products | Features |
|---|---|---|---|---|---|
| Starter | $29 | $290 | 14 days | Up to 500 | Basic rules, manual sync |
| Growth | $79 | $790 | 14 days | Up to 5,000 | Seasonal automation, daily sync |
| Pro | $199 | $1,990 | 14 days | Unlimited | Google Ads integration, hourly sync |
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
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
| 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"
}
]
}
}
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
| 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 |
| API pages needed (active, 250/page) | 10 |
8.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 | Exponential backoff starting at 1 second |
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 (store_id, shopify_product_id, shopify_variant_id).
8.10 Security Checklist
| Requirement | Implementation | Status |
|---|---|---|
| Session token validation | JWT verify with client secret, audience, expiry | Required |
| Webhook HMAC verification | HMAC-SHA256 with timingSafeEqual | 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 store_id | Required |
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)