Shopify apps
Apps/Shopify/Shopify apps

Shopify apps

A Shopify app gets its own Shopify app per environment, created in your Partner organization with the URLs, webhooks and app proxy already set, a copy of the store data it chooses, and the Admin API from its actions.

Custom or public

A Shopify app is custom or public, chosen when it's created and shown on Settings → Shopify.

Custom (the default)Public
MerchantsOne brand: one store, or the stores of one Plus organization. An agency makes one app per merchant.Any number
Shopify's reviewNoneTo list it on the Shopify App Store
Charging merchantsOutside ShopifyShopify Billing (below)
Distribution in ShopifyCustom distributionPublic distribution

Choose Public on New app in the dashboard, or send distribution: "public" to POST /v1/apps; ib-apps create makes custom apps only. Shopify sets the production app's distribution in the Partner Dashboard, once; for a public app, Settings links to that page. The type can change until production is first installed on a store, since by then Shopify's choice is made. After that, fork the app to start one of the other type. PATCH /v1/apps/<app> with { "distribution": "public" } does the same from a key with Apps Permissions. Every install gets an expiring offline token, which public apps require; Inference Bureau renews it before each call.

For a public app, the deploy plan adds Review readiness: what Shopify's review checks, each read from the app as it stands. Shopify managed installation, privacy webhooks (and any data request still pending), session tokens on the embedded pages' routes, frame-ancestors on its pages (new Shopify apps' web/proxy.ts sends it), whether the admin pages use Polaris web components, uninstall cleanup, stores still holding a non-expiring token, charging through Shopify Billing and protected customer data. Then the steps it can't check: performance in merchants' admins, and the listing and submission. It never blocks a deploy.

Connect to Shopify

Creating a Shopify app in the dashboard leads into guided setup: create the Shopify app, install it on a store, then sync files. Each step is checked from what has actually happened, so you can leave and come back.

Sign in to Shopify

Sign in once for the team: Shopify shows a code to confirm with an account in the Partner organization the apps should belong to.

Create the Shopify apps

Pick the Partner organization and name the apps. Inference Bureau creates development's app and, unless you untick it, production's, with the app URL, OAuth redirect, webhooks, privacy webhooks and app proxy set. Nothing is copied by hand. Production's app can also be created later, from Settings → Shopify; the deploy plan reminds you.

Install on a development store

Search the stores your Shopify sign-in can see, pick a development store, and open its install link. Shopify asks for the scopes, then sends the merchant back to the app.

Start building

In the browser, open Files and Preview: nothing to install. Or on your machine, ib-apps pull --app <app> && cd <app> && ib-apps dev. The step is done once the first change is saved from either.

Each environment has its own Shopify app and its own client ID. The secret never leaves Inference Bureau: the app's code receives only the client ID, as NEXT_PUBLIC_SHOPIFY_API_KEY.

Install on a store

Settings → Installs lists the stores each environment is installed on, with the scopes each granted. Development installs on development stores; production on the merchant's. Install on a store searches the stores your Shopify sign-in can see. Inference Bureau keeps an encrypted offline token per store; it never reaches the app's code.

Apps use Shopify managed installation: Shopify installs the app with the scopes released from the dashboard, with no OAuth redirect or second approval screen, and opens it in the store's admin. That first load carries Shopify's session token, which Inference Bureau trades for the store's offline token (token exchange); that records the install and runs the install action. An app that needs the redirect keeps it with shopify: { install: "oauth" } in settings.ib.ts; installs then go through Inference Bureau's OAuth callback, and a public app's Review readiness warns, since Built for Shopify looks for managed installation.

On the Free plan, the team's production apps can be installed on five stores in all; a sixth store is refused before Shopify's approval page. Development installs and reinstalls are never refused. See Free's limits.

When the app asks for scopes a store hasn't granted, the store shows needs approval. Shopify asks the merchant to approve them the next time they open the app, and that load records the new grant and runs the reinstall action. The Update scopes link opens Shopify's approval directly; it's the way for apps that keep the OAuth redirect. Scopes a feature needs only once a merchant uses it can be asked for then instead: see optional scopes. A store shows token out of date when Shopify refused one of the app's calls for access after the install; Update scopes renews its token, and the failed call's event in Logs says which scopes the token has against the ones the app asks for.

Scopes and data models

Settings → Shopify → Edit scopes and data models is one page. Each Admin API resource has Read and Write (write implies read). Reading a resource offers its Shopify models to keep in the app's database. A summary lists the resulting scopes, the webhook topics and the models. Saving updates both Shopify apps.

Customers, orders, draft orders and checkouts are protected customer data: development stores work without approval, but production needs Shopify's approval before a merchant can install it. Settings and the deploy plan link to the app's page in the Partner Dashboard to request it. Names, emails, phone numbers and addresses need Shopify's second level of approval; without it, Shopify sends those fields empty. Orders older than 60 days need read_all_orders (All orders), which Shopify approves separately.

Synced models

A synced model is an ordinary model whose schema Inference Bureau writes and keeps filled from Shopify. Choosing one writes its api/models/<model>/schema.ib.ts into development; a model is synced exactly when that file exists, and a deploy carries it to production.

ModelScopeFields
shopifyProductread_productsshopifyId, shop, title, handle, status, vendor, productType, tags, bodyHtml, publishedAt, shopifyCreatedAt, shopifyUpdatedAt
shopifyProductVariantread_productsshopifyId, shop, product (belongs to shopifyProduct), title, sku, price, compareAtPrice, inventoryQuantity, position, shopifyUpdatedAt. Choosing it brings shopifyProduct.
shopifyCollectionread_productsshopifyId, shop, title, handle, bodyHtml, sortOrder, shopifyUpdatedAt
shopifyMetaobjectread_metaobjectsshopifyId, shop, type, handle, displayName, fields (json), shopifyUpdatedAt
shopifyCustomerread_customersshopifyId, shop, email, phone, firstName, lastName, state, tags, note, verifiedEmail, taxExempt, numberOfOrders, amountSpent, currency, shopifyCreatedAt, shopifyUpdatedAt
shopifyCustomerAddressread_customersshopifyId, shop, customer (belongs to shopifyCustomer), firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, zip, phone, isDefault. Choosing it brings shopifyCustomer.
shopifyOrderread_ordersshopifyId, shop, name, email, phone, customerId (the customer's Shopify id), financialStatus, fulfillmentStatus, totalPrice, subtotalPrice, currency, tags, note, test, cancelledAt, closedAt, processedAt, shopifyCreatedAt, shopifyUpdatedAt
shopifyOrderLineItemread_ordersshopifyId, shop, order (belongs to shopifyOrder), title, variantTitle, sku, quantity, price, variantId, productId. Choosing it brings shopifyOrder.
shopifyFulfillmentread_ordersshopifyId, shop, order (belongs to shopifyOrder), name, status, displayStatus, trackingCompany, trackingNumber, trackingUrl, trackingNumbers, locationId, shopifyCreatedAt, shopifyUpdatedAt. Arrives with its order; with read_fulfillments too, its own topics bring changes at once. Choosing it brings shopifyOrder.
shopifyRefundread_ordersshopifyId, shop, order (belongs to shopifyOrder), note, totalRefunded, currency, lineItems (json: lineItemId, quantity, restockType, subtotal), processedAt, shopifyCreatedAt, shopifyUpdatedAt. Arrives with its order and on refunds/create. Choosing it brings shopifyOrder.
shopifyDraftOrderread_draft_ordersshopifyId, shop, name, status, email, customerId, totalPrice, subtotalPrice, currency, tags, note, invoiceUrl, completedAt, orderId (the order it became), shopifyCreatedAt, shopifyUpdatedAt
shopifyDraftOrderLineItemread_draft_ordersshopifyId, shop, draftOrder (belongs to shopifyDraftOrder), title, variantTitle, sku, quantity, price, variantId, productId. Choosing it brings shopifyDraftOrder.
shopifyLocationread_locationsshopifyId, shop, name, isActive, fulfillsOnlineOrders, address1, address2, city, province, provinceCode, country, countryCode, zip, phone, deactivatedAt, shopifyCreatedAt, shopifyUpdatedAt. Every pass lists them all (Shopify can't list locations by when they changed).
shopifyInventoryItemread_inventoryshopifyId, shop, sku, tracked, requiresShipping, cost, costCurrency, countryCodeOfOrigin, harmonizedSystemCode, variantId, shopifyCreatedAt, shopifyUpdatedAt
shopifyInventoryLevelread_inventoryshopifyId, shop, inventoryItem (belongs to shopifyInventoryItem), inventoryItemId, locationId, available, onHand, committed, incoming, reserved, shopifyUpdatedAt. One per item and location. Its webhooks carry available only; the other quantities come from the hourly reconcile. Choosing it brings shopifyInventoryItem.
shopifyFulfillmentOrderread_merchant_managed_fulfillment_ordersshopifyId, shop, orderId, status, requestStatus, locationId, locationName, fulfillAt, fulfillBy, shopifyCreatedAt, shopifyUpdatedAt. For the store's own locations. Shopify sends no create or update topic: the hourly reconcile lists the ones that changed, and holds, moves, splits, merges, cancellations and requests fetch the ones they name at once.
shopifyFulfillmentOrderLineItemread_merchant_managed_fulfillment_ordersshopifyId, shop, fulfillmentOrder (belongs to shopifyFulfillmentOrder), lineItemId, variantId, sku, totalQuantity, remainingQuantity. Choosing it brings shopifyFulfillmentOrder.
shopifyDiscountread_discountsshopifyId, shop, title, status, method (code or automatic), type (basic, bxgy, free_shipping, app), summary, discountClasses, startsAt, endsAt, usageLimit, appliesOncePerCustomer, usageCount, combinesWith, tags, shopifyCreatedAt, shopifyUpdatedAt. Its create and update topics carry only the title, so each fetches the discount again.
shopifyDiscountCoderead_discountsshopifyId, shop, discount (belongs to shopifyDiscount), code, usageCount, shopifyCreatedAt. A discount brings up to 1,000 of its codes; codes added or removed later arrive on their own topics. Choosing it brings shopifyDiscount.
shopifyReturnread_returnsshopifyId, shop, order (belongs to shopifyOrder), name, status, totalQuantity, declineReason, declineNote, requestApprovedAt, closedAt, shopifyCreatedAt. Arrives with its order; the returns/* topics fetch it again. Choosing it brings shopifyOrder.
shopifyReturnLineItemread_returnsshopifyId, shop, return (belongs to shopifyReturn), lineItemId, quantity, refundableQuantity, refundedQuantity, processedQuantity, returnReason (its handle), returnReasonNote, customerNote. Choosing it brings shopifyReturn.
shopifyCheckoutread_ordersshopifyId, shop, name, email, phone, customerId, totalPrice, subtotalPrice, currency, note, discountCodes, lineItems (json: title, variantTitle, sku, quantity, price, variantId, productId), abandonedCheckoutUrl, completedAt, shopifyCreatedAt, shopifyUpdatedAt. Its topics cover every checkout; the hourly reconcile lists abandoned ones.
shopifySellingPlanGroupread_productsshopifyId, shop, name, merchantCode, description, summary, options, position, appId, shopifyCreatedAt. The app's own groups only (Shopify lists no others); every pass lists them all.
shopifySellingPlanread_productsshopifyId, shop, sellingPlanGroup (belongs to shopifySellingPlanGroup), name, description, options, position, category, billingPolicy, deliveryPolicy, pricingPolicies (json: { kind: "recurring", interval: "month", intervalCount: 1 }, adjustments as { percentage } or { amount, currency }), shopifyCreatedAt. Choosing it brings shopifySellingPlanGroup.
shopifySubscriptionContractread_own_subscription_contractsshopifyId, shop, status, customerId, originOrderId, currency, nextBillingDate, billingPolicy, deliveryPolicy, deliveryPrice, lastPaymentStatus, lastBillingErrorType, note, revisionId, shopifyCreatedAt, shopifyUpdatedAt. The app's own contracts; each topic fetches the contract again.
shopifySubscriptionLineread_own_subscription_contractsshopifyId, shop, subscriptionContract (belongs to shopifySubscriptionContract), title, variantTitle, sku, quantity, price, discountedPrice, variantId, productId, sellingPlanId, sellingPlanName. Choosing it brings shopifySubscriptionContract.
shopifySubscriptionBillingAttemptread_own_subscription_contractsshopifyId, shop, subscriptionContract (belongs to shopifySubscriptionContract), state (success, failed, pending, action_required), orderId, errorCode, errorMessage, nextActionUrl, idempotencyKey, originTime, completedAt, shopifyCreatedAt. A contract brings its latest five; each attempt's outcome arrives on its own topic. Never removed.
shopifyMarketread_marketsshopifyId, shop, name, handle, status, type, currency, localCurrencies, regions (country codes). Every pass lists them all.
shopifyCompanyread_customersshopifyId, shop, name, note, externalId, mainContactId, customerSince, totalSpent, currency, shopifyCreatedAt, shopifyUpdatedAt. B2B (Shopify Plus).
shopifyCompanyContactread_customersshopifyId, shop, company (belongs to shopifyCompany), customerId, title, locale, isMainContact, shopifyCreatedAt, shopifyUpdatedAt. Choosing it brings shopifyCompany.
shopifyCompanyLocationread_customersshopifyId, shop, company (belongs to shopifyCompany), name, externalId, phone, locale, note, currency, billingAddress, shippingAddress, taxExempt, taxExemptions, taxRegistrationId, shopifyCreatedAt, shopifyUpdatedAt. Choosing it brings shopifyCompany.
shopifyPageread_contentshopifyId, shop, title, handle, body, isPublished, publishedAt, templateSuffix, shopifyCreatedAt, shopifyUpdatedAt. Shopify has no webhooks for content: the hourly reconcile brings changes and deletes.
shopifyBlogread_contentshopifyId, shop, title, handle, tags, commentPolicy, templateSuffix, shopifyCreatedAt, shopifyUpdatedAt. Hourly, like pages.
shopifyArticleread_contentshopifyId, shop, blog (belongs to shopifyBlog), title, handle, body, summary, author, tags, imageUrl, isPublished, publishedAt, templateSuffix, shopifyCreatedAt, shopifyUpdatedAt. Hourly, like pages. Choosing it brings shopifyBlog.
shopifyAppSubscriptionNone (public apps)shopifyId, shop, name, status, test, trialDays, price, currency, interval, cappedAmount, planHandle, currentPeriodEnd, shopifyCreatedAt, shopifyUpdatedAt. Each store's subscriptions to the app, from Shopify Billing or managed pricing, kept current by app_subscriptions/update.
shopifyShopNoneThe starter's model. Install fills the domain, state, scopes and dates; shop/update fills name, email, currency and plan.
import type { ModelSchema } from "@inferencebureau/apps-sdk";

/**
 * Synced from Shopify. Inference Bureau keeps these fields current from webhooks and an hourly reconcile; rename them
 * or add fields of your own as you like (the sync finds its fields by storageKey). Removing this file stops the sync.
 */
export const schema: ModelSchema = {
  type: "ib/model-schema/v1",
  fields: {
    shopifyId: { type: "string", storageKey: "sh_id", validations: { required: true, unique: true } },
    shop: { type: "string", storageKey: "sh_shop" },
    title: { type: "string", storageKey: "sh_title" },
    handle: { type: "string", storageKey: "sh_handle" },
    status: { type: "string", storageKey: "sh_status" },
    // …vendor, productType, tags, bodyHtml, publishedAt, shopifyCreatedAt
    shopifyUpdatedAt: { type: "dateTime", storageKey: "sh_updated_at" },
  },
};

Shopify's fields have fixed storage keys (sh_*), so you can rename them or add fields of your own; a later sync only adds back Shopify fields that are missing. Deleting the schema file stops the sync and hides the records. customers/redact also deletes the customer's draft orders, checkouts, subscription contracts and B2B contacts, and returns go with their orders.

HowWhenRuns actions
WebhooksShopify's create, update and delete topics for each synced model the app's scopes allow, released with the app's configuration. Records are upserted by shopifyId. A repeated delivery is dropped, and one older than the stored shopifyUpdatedAt is skipped. A product's variants arrive with it, as do a customer's addresses and an order's line items.Yes: the model's create, update or delete, when it has one. If the app's code is down, the write is applied directly so the copy stays right.
BackfillWhen a store installs the app, and when a model is chosen. Pages through every record and writes quietly: one event per pass, not per record. A big store's big models go in bulk.No
ReconcileHourly. Fetches what changed since each store's last pass and sweeps for deletes, so a missed webhook is corrected within the hour. Customers and orders are swept once a day (04:00 UTC), and orders only within the last 59 days, since Shopify lists older ones only with read_all_orders.Yes, for what it corrects
A sync runOn demand: below.Yes, unless runActions: false

A synced field can say when it's written, with fetch in its schema. Unset, a webhook writes what its payload carries and the reconcile fills the rest. fetch: "later" isn't written from webhooks at all: the reconcile and sync runs write it, even when nothing else on the record changed. fetch: "onWebhook" has the record fetched from the Admin API when a webhook's payload doesn't carry the field, at one call per such webhook (products, collections, customers, orders and draft orders).

export const schema: ModelSchema = {
  type: "ib/model-schema/v1",
  fields: {
    // Shopify's customer webhooks don't carry these: fetch the customer when one arrives.
    numberOfOrders: { type: "number", storageKey: "sh_number_of_orders", fetch: "onWebhook" },
    amountSpent: { type: "number", storageKey: "sh_amount_spent", fetch: "onWebhook" },
    // Tags change often; leave them to the hourly reconcile.
    tags: { type: "json", storageKey: "sh_tags", fetch: "later" },
    // …
  },
};

Metafields as fields

A field of your own on a synced model can hold one of the record's metafields: give it metafield: "namespace.key" ("$app:reviews.rating" for the app's namespace). Products, variants, collections, customers, orders, draft orders, locations, companies, company locations, markets, pages, blogs and articles have them.

// Beside the synced fields: the product's metafields, refreshed by sync passes.
careGuide: { type: "string", metafield: "custom.care_guide", fetch: "onWebhook" },
rating: { type: "number", metafield: "reviews.rating" },
colors: { type: "json", metafield: "custom.colors" },
material: { type: "belongsTo", parent: { model: "shopifyMetaobject" }, metafield: "custom.material" },
MetafieldField typeReads as
Text, URL, color, date, date and timestring, dateTimeThe value as Shopify stores it
Integer, decimal, rating, money, weight…numberThe number (a rating's value, money's amount)
True or falsebooleantrue or false
A list, JSON, rich textjsonThe parsed JSON: a list for list.* types
A reference (metaobject, product, variant…)belongsToThe synced record it points at, when its model is synced; null otherwise. A string field keeps the gid instead.

Shopify sends no webhook when only a metafield changes, and the record's updatedAt doesn't move, so sync passes read metafields on their own: every backfill and sync run, the hourly reconcile for models with up to 2,500 records, and the daily one (04:00 UTC) for bigger models, in bulk past 10,000. A field with fetch: "onWebhook" is also read whenever the record's own webhook comes, at one Admin API call per webhook. Only changed values are written, through the model's update action when it has one. The fields are copies: write a metafield with metafieldsSet, and the next refresh brings its value back.

Big stores, in bulk

Products, collections, customers, orders, draft orders and inventory items are listed with Shopify's bulk operations when paging them would take more than 100 pages: about 1,000 products or orders, 2,500 customers or inventory items, or 10,000 collections. Shopify assembles the whole list on its side without using the store's rate limit, and Inference Bureau reads the file it makes, so a catalog of a million products backfills in hours instead of days. Everything else is paged as usual, at the same time.

  • A bulk pass carries on across calls until it's done, from where it got to in the file. The pass's event in Logs comes when it finishes, with each bulk query's object count. The paged part has its own sync part event.
  • Records are written as a paged pass writes them: the same fields, stale checks, children and actions. An order's refunds and returns are fetched by id afterwards, a few at a time.
  • The hourly reconcile pages what changed since the last pass, unless that's more than 100 pages, which then goes in bulk too. A store that big isn't swept for deletes every hour. Its delete webhooks keep it current, and the 04:00 UTC pass sweeps it with a bulk query of ids.
  • A store has one bulk pass at a time, and its reconcile waits for it to finish. A sync run that goes in bulk reads as running until the pass finishes it, with bulkPass naming the pass. abort stops the pass and cancels Shopify's operation.

Sync on demand

Sync a store again after fixing a synced model's action, or after an incident: Sync on the store's row in Settings → Installs, ib-apps shopify sync, or api.shopifySync.run in the app's code. A run takes one store, and:

OptionDefault
modelsEvery synced modelModel names. A model that arrives inside another brings it: shopifyProductVariant syncs products with their variants.
sinceEverythingOnly records Shopify changed after this date. Without it the run lists everything and removes records Shopify no longer has, as a backfill does.
runActionstrueEach model's create, update and delete run for what the run writes, as for webhooks.
forcefalseWrite records Shopify hasn't changed since the stored copy too, so a fixed update action runs over them.
// After fixing api/models/shopifyOrder/actions/update.ts: run it again over October's orders.
const run = await api.shopifySync.run({
  shop: "demo.myshopify.com",
  models: ["shopifyOrder"],
  since: "2026-10-01",
  force: true,        // also the orders Shopify hasn't changed since they were stored
  wait: true,         // resolve when it's done, not at once
});
console.log(run.state, run.counts?.shopifyOrder); // "succeeded", { created, updated, deleted, skipped, failed, errors }

run returns the run at once, running, unless you pass wait; follow it with api.shopifySync.get(id) or list(), and stop it with abort(id), which takes effect before its next page. Installs lists recent runs with their counts and an Abort button, and each run is a sync run event in Logs. A store runs one sync at a time. A run stops after about 13 minutes and is marked failed; start another with since for the rest. Ctrl-C in ib-apps shopify sync aborts the run it's following.

Install, reinstall, uninstall

The shopifyShop model's actions run on Shopify's events, with shop and shopify set to the store:

EventActionParams
A store installs the appinstall (a create)myshopifyDomain, state: "installed", installedAt, grantedScopes
A store installs it againreinstall, on the record it already hasThe same
Shopify's app/uninstalled webhookuninstall, on the store's recordstate: "uninstalled", uninstalledAt

Without the action file, the record is created or updated directly. Only fields the schema has are passed. In development, an install waits for the dev server to start before the action runs; an install whose action couldn't run is caught up when the app's code is next up. After an install, the store is backfilled.

import { applyParams, save, type ActionOnSuccess, type ActionOptions, type ActionRun } from "@inferencebureau/apps-sdk";

/** Runs when a store installs the app: creates its shopifyShop record. */
export const run: ActionRun = async ({ params, record }) => {
  applyParams(params, record);
  await save(record);
};

/** After the record is saved: write the storefront block's message to an app-data metafield. */
export const onSuccess: ActionOnSuccess = async ({ record, shopify, logger }) => {
  if (!shopify) return;
  const { currentAppInstallation } = await shopify.graphql<{ currentAppInstallation: { id: string } }>(`{ currentAppInstallation { id } }`);
  const { metafieldsSet } = await shopify.graphql<{ metafieldsSet: { userErrors: { message: string }[] } }>(
    `mutation SetBlock($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { userErrors { message } } }`,
    { metafields: [{ ownerId: currentAppInstallation.id, namespace: "ib_app", key: "block", type: "json", value: JSON.stringify({ message: `Installed on ${String(record.myshopifyDomain)}.` }) }] },
  );
  if (metafieldsSet.userErrors.length) logger.warn("The block's metafield wasn't saved", metafieldsSet.userErrors);
};

export const options: ActionOptions = {
  actionType: "create",
};

Privacy webhooks

Every Shopify app is subscribed to Shopify's three privacy (GDPR) webhooks, and Inference Bureau answers each with 200 as soon as it's recorded, then does the work. Each also runs a global action in the app when the file exists, with Shopify's payload as params and shop set (types: ShopifyShopRedactParams, ShopifyCustomersRedactParams, ShopifyCustomersDataRequestParams).

TopicApp actionInference Bureau
shop/redact, 48 hours after a store uninstallsapi/actions/shopifyShopRedact.ts, run firstDeletes the store's data in that environment: every synced record (also of a synced model removed since), the store's shopifyShop record, records of any model with a shop text field holding the store's domain, records that point at any of those (belongsTo, however deep), their vectors, and the install with its token. What Logs and the Queue kept of the store's runs goes too: action runs' params, log lines and stacks, storefront requests' customer ids, and the store's jobs' params (queued ones are canceled). So does the record history of those records and of any record holding the store's domain, so a restore can't bring them back. Safe to repeat.
customers/redactapi/actions/shopifyCustomersRedact.tsDeletes the customer's synced shopifyCustomer record and the shopifyOrder records Shopify names, with their addresses and line items, records that point at them (belongsTo, however deep) and their vectors, then the record history of those records and any old values holding the customer's id or email (also once the app's own action has run). Safe to repeat.
customers/data_requestapi/actions/shopifyCustomersDataRequest.tsWithout the action, logged as pending with error = action_needed, with a count of what the synced models hold on the customer. Owners and admins download it from the event in Logs as JSON: the customer's synced records, all of their orders, what belongs to those, and the app's own records pointing at any of them (belongsTo, however deep), read when downloaded. Send it to the store within 30 days, then Mark as sent. Each download is in the audit log.

Each is a privacy <topic> event in Logs, under its webhook, with the customer's id, the orders named and the purge's counts; the customer's email and phone stay out of Logs. The Privacy requests view lists them. They run while the team's apps are paused (development's dev server starts for them, unbilled) and aren't billed as runs. A repeated delivery is dropped like any webhook. Shopify sends each only once, so an action that fails is tried again as a background job (source privacy, under the privacy event) for about seven hours; the event says how it ended, and one that still fails can be retried from the Queue once the app is fixed.

import type { GlobalActionRun, ShopifyCustomersRedactParams } from "@inferencebureau/apps-sdk";

/** Shopify's customers/redact: erase what the app keeps on this customer. */
export const run: GlobalActionRun = async ({ params, shop, api, logger }) => {
  const { customer } = params as unknown as ShopifyCustomersRedactParams;
  const page = await api.review.findMany({ filter: { shop: { equals: shop }, customerId: { equals: String(customer.id) } }, first: 250 });
  for (const r of page.records) await api.review.delete(r.id);
  logger.info("Erased the customer's reviews", { count: page.records.length });
};

Give every model that holds a store's data a shop field set to the store's domain, and shop/redact deletes it. Data the app keeps outside Inference Bureau, and customer data in the app's own models, are the app's to delete, in the actions above. Log rows themselves stay, emptied, and age out with the plan's retention.

The Admin API

Every action's context has shopify, the Admin GraphQL API for the store the run is about: the store that installed, the store a webhook came from, or the shop the caller passed. Elsewhere in the app's code, createShopifyClient(shop) gives the same client. Calls go through Inference Bureau, which adds the store's offline token, so the app's code never holds it. The store must have this environment's app installed.

import type { ActionOnSuccess } from "@inferencebureau/apps-sdk";

/** After a review is saved: read its product's title through the store the run is about. */
export const onSuccess: ActionOnSuccess = async ({ record, shopify, logger }) => {
  if (!shopify) return;
  const data = await shopify.graphql<{ product: { title: string } | null }>(
    `query Product($id: ID!) { product(id: $id) { title } }`,
    { id: record.productGid },
  );
  logger.info("Product", data.product);
};

graphql(query, variables) returns data and throws ShopifyError on GraphQL errors. Each call is a shopify event in Logs, named after the operation (graphql PublishReviews).

Shopify limits each store to a bucket of query cost points that refills every second. Calls are paced against that bucket for you:

  • A throttled call is tried again after the time Shopify says the missing points take (or its Retry-After), up to 5 times. Logs shows the retries and the wait on the call's event.
  • Actions run by jobs, webhooks and schedules are background work: before each call they wait until half the store's bucket is free, so merchants using the embedded admin at the same time aren't slowed down. Background calls wait up to 60 seconds; others up to 8.
  • A call still throttled after that throws ShopifyError with throttled set. Pass { priority: "background" } or maxRetries to createShopifyClient to change this for your own routes.

API version

Each environment's Shopify app calls the Admin API, and gets its webhooks, at one version. Shopify releases a version each quarter (January, April, July, October) and supports it for a year; after that it answers with the oldest version it still supports, whatever the app expects. Settings → Shopify → API version shows each environment's version and when its support ends, and changes it:

  • Development moves at once: its configuration is released with the new version, so its webhooks move too, and its calls follow.
  • Production moves when its next deploy goes live, so the version and the code written for it arrive together. Until then it shows as waiting, and choosing its current version cancels it.

Before you switch, the page shows the upgrade plan, worked out from Shopify's schemas for both versions:

  • Synced models: fields Shopify added, removed or deprecated (the reason usually names the replacement) on the types the app syncs, and whether the sync's own queries still work.
  • Webhooks: topics the app subscribes to that are removed or deprecated. Payload fields aren't in the schema; the plan links Shopify's release notes for those.
  • The app's code: each Admin GraphQL document in the app (string and template literals, and .graphql files, outside extensions/), checked against the new version: fields, arguments, types and enum values it refuses, and deprecated ones it still answers, with the file and line. A document built at runtime (with ${…} in it) is listed as not checked when it can't be read without its parts. For production, the code checked is development's, which the next deploy brings.
  • Extensions: UI extensions and functions keep the api_version in their own shopify.extension.toml, listed with when its support ends.

From 90 days before an environment's version leaves support, Problems shows a warning with the date, and an error once it has. Each change is an api version event in Logs.

Shopify Billing

Public apps charge merchants through Shopify. The SDK's helpers run through the action's shopify client (or createShopifyClient(shop) in a route):

HelperWhat it does
requestSubscription(shopify, plan)Starts a subscription with a recurring price, usage charges up to a cap, or both, and trialDays. Returns Shopify's confirmationUrl: send the merchant there (at the top level, from an embedded page) to approve.
activeSubscriptions(shopify)The store's active subscriptions, with each line's price, or its usage cap and balance.
recordUsage(shopify, charge)A usage charge on the active subscription's usage line. Pass an idempotencyKey so a retried action doesn't charge twice.
cancelSubscription(shopify, id)Cancels it; prorate refunds the rest of the period.
managedPricingUrl(shop, appHandle)Shopify's plan page, for apps whose plans are set in the Partner Dashboard (managed pricing) instead.
currentPlan(shopify, { api })The store's plan: name, inTrial, trialEndsAt, test, or null without one. With api, read from the synced shopifyAppSubscription when the app syncs it, without an Admin API call.
requirePlan(shopify, plans, { api })The plan, when it's one of plans (by name; none named means any); else throws PlanRequiredError, a 402 whose toResponse() answers it as JSON. allowTrial: false refuses a plan still in its trial.
trialDaysLeft(shopify, days)What's left of the store's first trial. requestSubscription offers only that, so a reinstall doesn't start another trial; trackTrial: false turns it off.
requestPurchase(shopify, purchase)A one-time charge (a setup fee, a pack of credits): returns a confirmationUrl. hasPurchased(shopify, name) and oneTimePurchases(shopify) say what's been paid.
grantCredit(shopify, credit)A credit against the store's future app charges (a refund, a goodwill gesture). Never paid out.

A plan page's Subscribe button posts to a route, which checks the session token and starts the subscription for that store. name and returnUrl are required, with a recurring price, usage charges, or both; Shopify sends the merchant to returnUrl after they approve or decline, so point it back into the app in their admin. It returns { confirmationUrl, subscriptionId, test }. replacementBehavior says what happens to a plan the store already has: APPLY_IMMEDIATELY, APPLY_ON_NEXT_BILLING_CYCLE, or STANDARD (Shopify decides: an upgrade now, a downgrade at the period's end; the default). When Shopify refuses (its userErrors, such as a usage charge past the cap) the helpers throw ShopifyBillingError, with userErrors on it.

import { createShopifyClient, requestSubscription, ShopifyBillingError, verifyShopifySession } from "@inferencebureau/apps-sdk";

/** The app's handle in the Partner Dashboard: Shopify sends the merchant back into the app in their admin. */
const APP_HANDLE = "reviews";

/** The plan page's Subscribe button: starts Pro and answers with Shopify's approval page. */
export async function POST(request: Request) {
  const session = await verifyShopifySession(request);
  if (!session) return Response.json({ error: "Open this page from the Shopify admin." }, { status: 401 });
  const store = session.shop.replace(/\.myshopify\.com$/, "");
  try {
    const { confirmationUrl, subscriptionId, test } = await requestSubscription(createShopifyClient(session.shop), {
      name: "Pro", // shown on Shopify's approval page and the merchant's bill
      returnUrl: `https://admin.shopify.com/store/${store}/apps/${APP_HANDLE}`,
      recurring: { price: { amount: 19, currencyCode: "USD" }, interval: "EVERY_30_DAYS" },
      usage: { cappedAmount: { amount: 100, currencyCode: "USD" }, terms: "$0.05 per review request email" },
      trialDays: 7,
      replacementBehavior: "APPLY_IMMEDIATELY", // a store on another plan switches now, not at the period's end
    });
    // test is true outside production and on development stores: nothing is really charged.
    return Response.json({ confirmationUrl, subscriptionId, test });
  } catch (e) {
    // Shopify refused it (userErrors), or answered without a confirmation URL.
    if (e instanceof ShopifyBillingError) return Response.json({ error: e.message, userErrors: e.userErrors }, { status: 400 });
    throw e;
  }
}

A store's trial is recorded the first time Inference Bureau sees a subscription with trial days active, from the shopifyAppSubscription sync or from currentPlan, and kept per environment through uninstalls (and shop/redact, as a billing record). A trial runs from that first start: a store that reinstalls 3 days into a 7-day trial gets 4 days, and none after a week. Gate features in the route or action that does the work with requirePlan, not only in the page. New public apps start with a plan picker: the plans in web/lib/plans.ts, the route web/app/api/billing/route.ts and the page web/app/plans/page.tsx.

Charges are test charges unless the app is in production and the store isn't a development store; development refuses a real charge outright, whatever sends it. On Scopes and data models, public apps can sync shopifyAppSubscription: each store's subscriptions to the app (name, status, test, price, interval, cappedAmount, planHandle, currentPeriodEnd), kept current by app_subscriptions/update, whether they came from the helpers or from managed pricing.

Releases

A release sends a Shopify app its configuration (app URL, scopes, webhooks, app proxy) and the extensions in extensions/ (every type it releases), as shopify app deploy would. It happens:

  • right after the Shopify apps are created;
  • after a sync changes development's extensions/ or settings.ib.ts (its webhooks);
  • when a production deploy goes live, with production's files (and the API version chosen for it);
  • when you choose Update Shopify configuration on Settings → Shopify.

Each release is a config release event in Logs. A failed one shows in Problems until a later release goes through: an error when the configuration wasn't taken (often a Shopify sign-in that needs renewing), a warning when Shopify took the configuration but refused the storefront blocks.