Files
sickn33 1966f6a8a2 fix(skills): Restore vibeship imports
Rebuild the affected vibeship-derived skills from the pinned upstream
snapshot instead of leaving the truncated imported bodies on main.
Refresh the derived catalog and plugin mirrors so the canonical skills,
compatibility data, and generated artifacts stay in sync.

Refs #473
2026-04-07 18:25:18 +02:00

34 KiB

name, description, risk, source, date_added
name description risk source date_added
shopify-apps Expert patterns for Shopify app development including Remix/React Router apps, embedded apps with App Bridge, webhook handling, GraphQL Admin API, Polaris components, billing, and app extensions. safe vibeship-spawner-skills (Apache 2.0) 2026-02-27

Shopify Apps

Expert patterns for Shopify app development including Remix/React Router apps, embedded apps with App Bridge, webhook handling, GraphQL Admin API, Polaris components, billing, and app extensions.

Patterns

React Router App Setup

Modern Shopify app template with React Router

When to use: Starting a new Shopify app

Template

Create new Shopify app with CLI

npm init @shopify/app@latest my-shopify-app

Project structure

my-shopify-app/

├── app/

│ ├── routes/

│ │ ├── app._index.tsx # Main app page

│ │ ├── app.tsx # App layout with providers

│ │ ├── auth.$.tsx # Auth callback

│ │ └── webhooks.tsx # Webhook handler

│ ├── shopify.server.ts # Server configuration

│ └── root.tsx # Root layout

├── extensions/ # App extensions

├── shopify.app.toml # App configuration

└── package.json

// shopify.app.toml name = "my-shopify-app" client_id = "your-client-id" application_url = "https://your-app.example.com"

[access_scopes] scopes = "read_products,write_products,read_orders"

[webhooks] api_version = "2024-10"

[webhooks.subscriptions] topics = ["orders/create", "products/update"] uri = "/webhooks"

[auth] redirect_urls = ["https://your-app.example.com/auth/callback"]

// app/shopify.server.ts import "@shopify/shopify-app-remix/adapters/node"; import { LATEST_API_VERSION, shopifyApp, DeliveryMethod, } from "@shopify/shopify-app-remix/server"; import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; import prisma from "./db.server";

const shopify = shopifyApp({ apiKey: process.env.SHOPIFY_API_KEY!, apiSecretKey: process.env.SHOPIFY_API_SECRET!, scopes: process.env.SCOPES?.split(","), appUrl: process.env.SHOPIFY_APP_URL!, authPathPrefix: "/auth", sessionStorage: new PrismaSessionStorage(prisma), distribution: AppDistribution.AppStore, future: { unstable_newEmbeddedAuthStrategy: true, }, ...(process.env.SHOP_CUSTOM_DOMAIN ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } : {}), });

export default shopify; export const apiVersion = LATEST_API_VERSION; export const authenticate = shopify.authenticate; export const sessionStorage = shopify.sessionStorage;

Notes

  • React Router replaced Remix as recommended template (late 2024)
  • unstable_newEmbeddedAuthStrategy enabled by default for new apps
  • Webhooks configured in shopify.app.toml, not code
  • Run 'shopify app deploy' to apply configuration changes

Embedded App with App Bridge

Render app embedded in Shopify Admin

When to use: Building embedded admin app

Template

// app/routes/app.tsx - App layout with providers import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react"; import { AppProvider } from "@shopify/shopify-app-remix/react"; import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";

export const links = () => [{ rel: "stylesheet", href: polarisStyles }];

export async function loader({ request }: LoaderFunctionArgs) { await authenticate.admin(request); return json({ apiKey: process.env.SHOPIFY_API_KEY! }); }

export default function App() { const { apiKey } = useLoaderData();

return ( Home Products Settings ); }

export function ErrorBoundary() { const error = useRouteError(); return ( Something went wrong. Please try again. ); }

// app/routes/app._index.tsx - Main app page import { Page, Layout, Card, Text, BlockStack, Button, } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react";

export async function loader({ request }: LoaderFunctionArgs) { const { admin } = await authenticate.admin(request);

// GraphQL query const response = await admin.graphql(query { shop { name email } });

const { data } = await response.json(); return json({ shop: data.shop }); }

export default function Index() { const { shop } = useLoaderData();

return ( <Layout.Section> Welcome to {shop.name}! Your app is now connected to this store. Get Started </Layout.Section> ); }

Notes

  • App Bridge required for Built for Shopify (July 2025)
  • Polaris components match Shopify Admin design
  • TitleBar and navigation from App Bridge
  • Always authenticate requests with authenticate.admin()

Webhook Handling

Secure webhook processing with HMAC verification

When to use: Receiving Shopify webhooks

Template

// app/routes/webhooks.tsx import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; import db from "../db.server";

export const action = async ({ request }: ActionFunctionArgs) => { // Authenticate webhook (verifies HMAC signature) const { topic, shop, payload, admin } = await authenticate.webhook(request);

console.log(Received ${topic} webhook for ${shop});

// Process based on topic switch (topic) { case "ORDERS_CREATE": // Queue for async processing await queueOrderProcessing(payload); break;

case "PRODUCTS_UPDATE":
  await handleProductUpdate(shop, payload);
  break;

case "APP_UNINSTALLED":
  // Clean up shop data
  await db.session.deleteMany({ where: { shop } });
  await db.shopData.delete({ where: { shop } });
  break;

case "CUSTOMERS_DATA_REQUEST":
case "CUSTOMERS_REDACT":
case "SHOP_REDACT":
  // GDPR webhooks - mandatory
  await handleGDPRWebhook(topic, payload);
  break;

default:
  console.log(`Unhandled webhook topic: ${topic}`);

}

// CRITICAL: Return 200 immediately // Shopify expects response within 5 seconds return new Response(null, { status: 200 }); };

// Process asynchronously after responding async function queueOrderProcessing(payload: any) { // Use a job queue (BullMQ, etc.) await jobQueue.add("process-order", { orderId: payload.id, orderData: payload, }); }

async function handleProductUpdate(shop: string, payload: any) { // Quick sync operation only await db.product.upsert({ where: { shopifyId: payload.id }, update: { title: payload.title, updatedAt: new Date(), }, create: { shopifyId: payload.id, shop, title: payload.title, }, }); }

async function handleGDPRWebhook(topic: string, payload: any) { // GDPR compliance - required for all apps switch (topic) { case "CUSTOMERS_DATA_REQUEST": // Return customer data within 30 days break; case "CUSTOMERS_REDACT": // Delete customer data break; case "SHOP_REDACT": // Delete all shop data (48 hours after uninstall) break; } }

Notes

  • Respond within 5 seconds or webhook fails
  • Use job queues for heavy processing
  • GDPR webhooks are mandatory for App Store
  • HMAC verification handled by authenticate.webhook()

GraphQL Admin API

Query and mutate shop data with GraphQL

When to use: Interacting with Shopify Admin API

Template

// GraphQL queries with authenticated admin client export async function loader({ request }: LoaderFunctionArgs) { const { admin } = await authenticate.admin(request);

// Query products with pagination const response = await admin.graphql(query GetProducts($first: Int!, $after: String) { products(first: $first, after: $after) { edges { node { id title status totalInventory priceRangeV2 { minVariantPrice { amount currencyCode } } images(first: 1) { edges { node { url altText } } } } cursor } pageInfo { hasNextPage endCursor } } }, { variables: { first: 10, after: null, }, });

const { data } = await response.json(); return json({ products: data.products }); }

// Mutations export async function action({ request }: ActionFunctionArgs) { const { admin } = await authenticate.admin(request); const formData = await request.formData(); const productId = formData.get("productId"); const newTitle = formData.get("title");

const response = await admin.graphql(mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id title } userErrors { field message } } }, { variables: { input: { id: productId, title: newTitle, }, }, });

const { data } = await response.json();

if (data.productUpdate.userErrors.length > 0) { return json({ errors: data.productUpdate.userErrors, }, { status: 400 }); }

return json({ product: data.productUpdate.product }); }

// Bulk operations for large datasets async function bulkUpdateProducts(admin: AdminApiContext) { // Create bulk operation const response = await admin.graphql(mutation { bulkOperationRunMutation( mutation: "mutation call($input: ProductInput!) { productUpdate(input: $input) { product { id } } }", stagedUploadPath: "path-to-staged-upload" ) { bulkOperation { id status } userErrors { message } } });

// Poll for completion or use webhook // BULK_OPERATIONS_FINISH webhook }

Notes

  • GraphQL required for new public apps (April 2025)
  • Rate limit: 1000 points per 60 seconds
  • Use bulk operations for >250 items
  • Direct API access available from App Bridge

Billing API Integration

Implement subscription billing for your app

When to use: Monetizing Shopify app

Template

// app/routes/app.billing.tsx import { json, redirect } from "@remix-run/node"; import { Page, Card, Button, BlockStack, Text } from "@shopify/polaris"; import { authenticate } from "../shopify.server";

const PLANS = { basic: { name: "Basic", amount: 9.99, currencyCode: "USD", interval: "EVERY_30_DAYS", }, pro: { name: "Pro", amount: 29.99, currencyCode: "USD", interval: "EVERY_30_DAYS", }, };

export async function loader({ request }: LoaderFunctionArgs) { const { admin, billing } = await authenticate.admin(request);

// Check current subscription const response = await admin.graphql(query { currentAppInstallation { activeSubscriptions { id name status lineItems { plan { pricingDetails { ... on AppRecurringPricing { price { amount currencyCode } interval } } } } } } });

const { data } = await response.json(); return json({ subscription: data.currentAppInstallation.activeSubscriptions[0], }); }

export async function action({ request }: ActionFunctionArgs) { const { admin, session } = await authenticate.admin(request); const formData = await request.formData(); const planKey = formData.get("plan") as keyof typeof PLANS; const plan = PLANS[planKey];

// Create subscription charge const response = await admin.graphql(mutation CreateSubscription($name: String!, $lineItems: [AppSubscriptionLineItemInput!]!, $returnUrl: URL!, $test: Boolean) { appSubscriptionCreate( name: $name lineItems: $lineItems returnUrl: $returnUrl test: $test ) { appSubscription { id status } confirmationUrl userErrors { field message } } }, { variables: { name: plan.name, lineItems: [ { plan: { appRecurringPricingDetails: { price: { amount: plan.amount, currencyCode: plan.currencyCode, }, interval: plan.interval, }, }, }, ], returnUrl: https://${session.shop}/admin/apps/${process.env.SHOPIFY_API_KEY}, test: process.env.NODE_ENV !== "production", }, });

const { data } = await response.json();

if (data.appSubscriptionCreate.userErrors.length > 0) { return json({ errors: data.appSubscriptionCreate.userErrors, }, { status: 400 }); }

// Redirect merchant to approve charge return redirect(data.appSubscriptionCreate.confirmationUrl); }

export default function Billing() { const { subscription } = useLoaderData(); const submit = useSubmit();

return ( {subscription ? ( Current plan: {subscription.name} Status: {subscription.status} ) : ( Choose a Plan <Button onClick={() => submit({ plan: "basic" }, { method: "post" })}> Basic - $9.99/month <Button onClick={() => submit({ plan: "pro" }, { method: "post" })}> Pro - $29.99/month )} ); }

Notes

  • Use test: true for development stores
  • Merchant must approve subscription
  • One recurring + one usage charge per app max
  • 30-day billing cycle for recurring charges

App Extension Development

Extend Shopify checkout, admin, or storefront

When to use: Building app extensions

Template

shopify.extension.toml (in extensions/my-extension/)

api_version = "2024-10"

extensions type = "ui_extension" name = "Product Customizer" handle = "product-customizer"

extensions.targeting target = "admin.product-details.block.render" module = "./src/AdminBlock.tsx"

[extensions.capabilities] api_access = true

[extensions.settings] extensions.settings.fields key = "show_preview" type = "boolean" name = "Show Preview"

// extensions/my-extension/src/AdminBlock.tsx import { reactExtension, useApi, useSettings, BlockStack, Text, Button, InlineStack, } from "@shopify/ui-extensions-react/admin";

export default reactExtension( "admin.product-details.block.render", () => );

function ProductCustomizer() { const { data, extension } = useApi<"admin.product-details.block.render">(); const settings = useSettings();

const productId = data?.selected?.[0]?.id;

const handleCustomize = async () => { // API calls from extension const result = await fetch("/api/customize", { method: "POST", body: JSON.stringify({ productId }), }); };

return ( Product Customizer Customize product: {productId} {settings.show_preview && ( Preview enabled )} Apply Customization ); }

// Checkout UI Extension // extensions.targeting // target = "purchase.checkout.block.render"

// extensions/checkout-ext/src/Checkout.tsx import { reactExtension, Banner, useCartLines, useTotalAmount, } from "@shopify/ui-extensions-react/checkout";

export default reactExtension( "purchase.checkout.block.render", () => );

function CheckoutBanner() { const cartLines = useCartLines(); const total = useTotalAmount();

if (total.amount > 100) { return ( You qualify for free shipping! ); }

return null; }

Notes

  • Extensions run in sandboxed iframe
  • Use @shopify/ui-extensions-react for React
  • Limited APIs compared to full app
  • Deploy with 'shopify app deploy'

Sharp Edges

Webhook Must Respond Within 5 Seconds

Severity: HIGH

Situation: Receiving webhooks from Shopify

Symptoms: Webhook deliveries marked as failed. "Your app didn't respond in time" in Shopify logs. Missing order/product updates. Webhooks retried repeatedly then cancelled.

Why this breaks: Shopify expects a 2xx response within 5 seconds. If your app processes the webhook data before responding, you'll timeout.

Shopify retries failed webhooks up to 19 times over 48 hours. After continued failures, webhooks may be cancelled entirely.

Heavy processing (API calls, database operations) must happen after the response is sent.

Recommended fix:

Respond immediately, process asynchronously

// app/routes/webhooks.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, shop, payload } = await authenticate.webhook(request);

  // Queue for async processing
  await jobQueue.add("process-webhook", {
    topic,
    shop,
    payload,
  });

  // CRITICAL: Return 200 immediately
  return new Response(null, { status: 200 });
};

// Worker process handles the actual work
// workers/webhook-processor.ts
import { Worker } from "bullmq";

const worker = new Worker("process-webhook", async (job) => {
  const { topic, shop, payload } = job.data;

  switch (topic) {
    case "ORDERS_CREATE":
      await processOrder(shop, payload);
      break;
    // ... other handlers
  }
});

For simple operations, be quick

// Simple database update is OK if fast
export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, payload } = await authenticate.webhook(request);

  // Quick database update (< 1 second)
  await db.product.update({
    where: { shopifyId: payload.id },
    data: { title: payload.title },
  });

  return new Response(null, { status: 200 });
};

Monitor webhook performance

// Log response times
const start = Date.now();

await handleWebhook(payload);

const duration = Date.now() - start;
console.log(`Webhook processed in ${duration}ms`);

// Alert if approaching timeout
if (duration > 3000) {
  console.warn("Webhook processing taking too long!");
}

API Rate Limits Cause 429 Errors

Severity: HIGH

Situation: Making API calls to Shopify

Symptoms: HTTP 429 Too Many Requests errors. "Throttled" responses. App becomes unresponsive. Operations fail silently or partially.

Why this breaks: Shopify enforces strict rate limits:

  • REST: 2 requests per second per store
  • GraphQL: 1000 points per 60 seconds

Exceeding limits causes immediate 429 errors. Continuous violations can result in temporary bans.

Bulk operations count against limits.

Recommended fix:

Check rate limit headers

// REST API
// X-Shopify-Shop-Api-Call-Limit: 39/40

// GraphQL - check response extensions
const response = await admin.graphql(`...`);
const { data, extensions } = await response.json();

const cost = extensions?.cost;
// {
//   "requestedQueryCost": 42,
//   "actualQueryCost": 42,
//   "throttleStatus": {
//     "maximumAvailable": 1000,
//     "currentlyAvailable": 958,
//     "restoreRate": 50
//   }
// }

Implement retry with exponential backoff

async function shopifyRequest(
  fn: () => Promise<Response>,
  maxRetries = 3
): Promise<Response> {
  let lastError: Error;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fn();

      if (response.status === 429) {
        // Get retry-after header or default
        const retryAfter = parseInt(
          response.headers.get("Retry-After") || "2"
        );
        await sleep(retryAfter * 1000 * Math.pow(2, attempt));
        continue;
      }

      return response;
    } catch (error) {
      lastError = error as Error;
    }
  }

  throw lastError!;
}

Use bulk operations for large datasets

// Instead of 1000 individual calls, use bulk mutation
const response = await admin.graphql(`
  mutation {
    bulkOperationRunMutation(
      mutation: "mutation($input: ProductInput!) {
        productUpdate(input: $input) { product { id } }
      }",
      stagedUploadPath: "..."
    ) {
      bulkOperation { id status }
      userErrors { message }
    }
  }
`);

Queue requests

import { RateLimiter } from "limiter";

// 2 requests per second for REST
const limiter = new RateLimiter({
  tokensPerInterval: 2,
  interval: "second",
});

async function rateLimitedRequest(fn: () => Promise<any>) {
  await limiter.removeTokens(1);
  return fn();
}

Protected Customer Data Requires Special Permission

Severity: HIGH

Situation: Accessing customer PII in webhooks or API

Symptoms: Webhook deliveries fail for orders/customers. Customer data fields are null or empty. App works in development but fails in production. "Protected customer data access" errors.

Why this breaks: Since April 2024, accessing protected customer data (PII) requires explicit approval from Shopify. This is separate from OAuth scopes.

Protected data includes:

  • Customer names, emails, addresses
  • Order customer information
  • Subscription customer details

Even with read_orders scope, you won't receive customer data in webhooks without protected data access.

Recommended fix:

Request protected customer data access

  1. Go to Partner Dashboard > App > API access
  2. Under "Protected customer data access"
  3. Request access for needed data types
  4. Justify your use case
  5. Wait for Shopify approval (can take days)

Check your data access level

// Query your app's data access
const response = await admin.graphql(`
  query {
    currentAppInstallation {
      accessScopes {
        handle
      }
    }
  }
`);

Handle missing data gracefully

// Webhook payload may have redacted fields
async function processOrder(payload: any) {
  const customerEmail = payload.customer?.email;

  if (!customerEmail) {
    // Customer data not available
    // Either no protected access or data redacted
    console.log("Customer data not available");
    return;
  }

  await sendOrderConfirmation(customerEmail);
}

Use customer account API for direct access

// If customer is logged in, can access their data
// through Customer Account API (different from Admin API)

Duplicate Webhook Definitions Cause Conflicts

Severity: MEDIUM

Situation: Configuring webhooks in both TOML and code

Symptoms: Duplicate webhook deliveries. Some webhooks fire twice. Webhook subscriptions fail to register. Unpredictable webhook behavior.

Why this breaks: Shopify apps can define webhooks in two places:

  1. shopify.app.toml (declarative, recommended)
  2. afterAuth hook in code (imperative, legacy)

If you define the same webhook in both places, you get:

  • Duplicate subscriptions
  • Race conditions during registration
  • Conflicts during app updates

Recommended fix:

# shopify.app.toml
[webhooks]
api_version = "2024-10"

[webhooks.subscriptions]
topics = [
  "orders/create",
  "orders/updated",
  "products/create",
  "products/update",
  "app/uninstalled"
]
uri = "/webhooks"

Remove code-based registration

// DON'T do this if using TOML
const shopify = shopifyApp({
  // ...
  hooks: {
    afterAuth: async ({ session }) => {
      // Remove webhook registration from here
      // Let TOML handle it
    },
  },
});

Deploy to apply TOML changes

# Webhooks registered on deploy
shopify app deploy

Check current subscriptions

const response = await admin.graphql(`
  query {
    webhookSubscriptions(first: 50) {
      edges {
        node {
          id
          topic
          endpoint {
            ... on WebhookHttpEndpoint {
              callbackUrl
            }
          }
        }
      }
    }
  }
`);

Webhook URL Trailing Slash Causes 404

Severity: MEDIUM

Situation: Setting up webhook endpoints

Symptoms: Webhooks return 404 Not Found. Webhook delivery fails immediately. Works in local dev but fails in production. Logs show request to /webhooks/ not /webhooks.

Why this breaks: Shopify automatically adds a trailing slash to webhook URLs. If your server doesn't handle both /webhooks and /webhooks/, the webhook will 404.

Common with frameworks that are strict about trailing slashes.

Recommended fix:

Handle both URL formats

// Remix/React Router - both work by default
// app/routes/webhooks.tsx handles /webhooks

// Express - add middleware
app.use((req, res, next) => {
  if (req.path.endsWith('/') && req.path.length > 1) {
    const query = req.url.slice(req.path.length);
    const safePath = req.path.slice(0, -1);
    res.redirect(301, safePath + query);
  }
  next();
});

Configure web server

# Nginx - strip trailing slashes
location ~ ^(.+)/$ {
  return 301 $1;
}

# Or rewrite to handler
location /webhooks {
  try_files $uri $uri/ @webhooks;
}
location @webhooks {
  proxy_pass http://app:3000/webhooks;
}

Test both formats

# Test without slash
curl -X POST https://your-app.com/webhooks

# Test with slash
curl -X POST https://your-app.com/webhooks/

REST API Required Migration to GraphQL (April 2025)

Severity: HIGH

Situation: Building new public apps or maintaining existing

Symptoms: App store submission rejected for REST API usage. Deprecation warnings in console. Some REST endpoints stop working. Missing features only in GraphQL.

Why this breaks: As of October 2024, REST Admin API is legacy. Starting April 2025, new public apps MUST use GraphQL.

REST endpoints will continue working for existing apps, but new features are GraphQL-only.

Metafields, bulk operations, and many new features require GraphQL.

Recommended fix:

Use GraphQL for all new code

// REST (legacy)
const response = await fetch(
  `https://${shop}/admin/api/2024-10/products.json`,
  {
    headers: { "X-Shopify-Access-Token": token },
  }
);

// GraphQL (recommended)
const response = await admin.graphql(`
  query {
    products(first: 10) {
      edges {
        node {
          id
          title
        }
      }
    }
  }
`);

Migrate existing REST calls

// REST: GET /products/{id}.json
// GraphQL equivalent:
const response = await admin.graphql(`
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      title
      status
      variants(first: 10) {
        edges {
          node {
            id
            price
            inventoryQuantity
          }
        }
      }
    }
  }
`, {
  variables: { id: `gid://shopify/Product/${productId}` },
});

Use GraphQL for webhooks too

# shopify.app.toml
[webhooks]
api_version = "2024-10"  # Use latest GraphQL version

App Bridge Required for Built for Shopify (July 2025)

Severity: HIGH

Situation: Building embedded Shopify apps

Symptoms: App rejected from "Built for Shopify" program. App not appearing correctly in admin. Navigation and chrome issues. Warning about App Bridge version.

Why this breaks: Effective July 2025, all apps seeking "Built for Shopify" status must use the latest version of App Bridge and be embedded.

Apps using old App Bridge versions or not embedded will lose built for Shopify benefits (better placement, badges).

Shopify now serves App Bridge and Polaris via unversioned script tags that auto-update.

Recommended fix:

Use latest App Bridge via script tag

<!-- Automatically stays up to date -->
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>

Use AppProvider in React

// app/routes/app.tsx
import { AppProvider } from "@shopify/shopify-app-remix/react";

export default function App() {
  return (
    <AppProvider isEmbeddedApp apiKey={apiKey}>
      <Outlet />
    </AppProvider>
  );
}

Enable embedded auth strategy

// shopify.server.ts
const shopify = shopifyApp({
  // ...
  future: {
    unstable_newEmbeddedAuthStrategy: true,
  },
});

Check embedded status

import { useAppBridge } from "@shopify/app-bridge-react";

function MyComponent() {
  const app = useAppBridge();
  const isEmbedded = app.hostOrigin !== window.location.origin;
}

Missing GDPR Webhooks Block App Store Approval

Severity: HIGH

Situation: Submitting app to Shopify App Store

Symptoms: App submission rejected. "GDPR webhooks not implemented" error. Manual review fails for compliance. Data request webhooks not handled.

Why this breaks: Shopify requires all apps to handle three GDPR webhooks:

  1. customers/data_request - Provide customer data
  2. customers/redact - Delete customer data
  3. shop/redact - Delete all shop data

These are automatically subscribed when you create an app. You MUST implement handlers even if you don't store data.

Recommended fix:

Implement all GDPR handlers

// app/routes/webhooks.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, payload, shop } = await authenticate.webhook(request);

  switch (topic) {
    case "CUSTOMERS_DATA_REQUEST":
      await handleDataRequest(shop, payload);
      break;

    case "CUSTOMERS_REDACT":
      await handleCustomerRedact(shop, payload);
      break;

    case "SHOP_REDACT":
      await handleShopRedact(shop, payload);
      break;
  }

  return new Response(null, { status: 200 });
};

async function handleDataRequest(shop: string, payload: any) {
  const customerId = payload.customer.id;

  // Return customer data within 30 days
  // Usually send to data_request.destination_url
  const customerData = await db.customer.findUnique({
    where: { shopifyId: customerId, shop },
  });

  if (customerData) {
    // Send to provided URL or email
    await sendDataToMerchant(payload.data_request, customerData);
  }
}

async function handleCustomerRedact(shop: string, payload: any) {
  const customerId = payload.customer.id;

  // Delete customer's personal data
  await db.customer.deleteMany({
    where: { shopifyId: customerId, shop },
  });

  await db.order.updateMany({
    where: { customerId, shop },
    data: { customerEmail: null, customerName: null },
  });
}

async function handleShopRedact(shop: string, payload: any) {
  // Shop uninstalled 48+ hours ago
  // Delete ALL data for this shop
  await db.session.deleteMany({ where: { shop } });
  await db.customer.deleteMany({ where: { shop } });
  await db.order.deleteMany({ where: { shop } });
  await db.settings.deleteMany({ where: { shop } });
}

Even if you store nothing

// You must still respond 200
case "CUSTOMERS_DATA_REQUEST":
case "CUSTOMERS_REDACT":
case "SHOP_REDACT":
  // No data stored, but must acknowledge
  console.log(`GDPR ${topic} for ${shop} - no data stored`);
  break;

Validation Checks

Hardcoded Shopify API Secret

Severity: ERROR

API secrets must never be hardcoded

Message: Hardcoded Shopify API secret. Use environment variables.

Hardcoded Shopify API Key

Severity: ERROR

API keys should use environment variables

Message: Hardcoded Shopify API key. Use environment variables.

Missing HMAC Verification

Severity: ERROR

Webhook endpoints must verify HMAC signature

Message: Webhook handler without HMAC verification. Use authenticate.webhook().

Synchronous Webhook Processing

Severity: WARNING

Webhook handlers should respond quickly

Message: Multiple await calls in webhook handler. Consider async processing.

Missing Webhook Response

Severity: ERROR

Webhooks must return 200 status

Message: Webhook handler may not return proper response.

Duplicate Webhook Registration

Severity: WARNING

Webhooks should be defined in TOML only

Message: Code-based webhook registration. Define webhooks in shopify.app.toml.

REST API Usage

Severity: INFO

REST API is deprecated, use GraphQL

Message: REST API usage detected. Consider migrating to GraphQL.

Missing Rate Limit Handling

Severity: WARNING

API calls should handle 429 responses

Message: API call without rate limit handling. Implement retry logic.

In-Memory Session Storage

Severity: WARNING

In-memory sessions don't scale

Message: In-memory session storage. Use PrismaSessionStorage or similar.

Missing Session Validation

Severity: ERROR

Routes should validate session

Message: Loader without authentication. Use authenticate.admin(request).

Collaboration

Delegation Triggers

  • user needs payment processing -> stripe-integration (Shopify Payments or Stripe integration)
  • user needs custom authentication -> auth-specialist (Beyond Shopify OAuth)
  • user needs email/SMS notifications -> twilio-communications (Customer notifications outside Shopify)
  • user needs AI features -> llm-architect (Product descriptions, chatbots)
  • user needs serverless deployment -> aws-serverless (Lambda or Vercel deployment)

When to Use

  • User mentions or implies: shopify app
  • User mentions or implies: shopify
  • User mentions or implies: embedded app
  • User mentions or implies: polaris
  • User mentions or implies: app bridge
  • User mentions or implies: shopify webhook