Shopify setup

Create the Shopify app behind the store integration, set the API environment, and know what a Shopify app review will ask for.

How to create the Shopify app that backs the store integration, what to set in the API environment, and what a Shopify app review will ask for.

This was run end to end on Sep 23 2026 (MARK-KFAFM7): the production app is Marketing OS in the KeyesCode Partner organization (Dev Dashboard app 427471601665, Client ID 17d77a550ede95fd5da6adb31f95eac1), and the dev store marketing-os-test-store.myshopify.com is installed on the "Marketing OS" house project in production. See Production state for what is and is not open to merchants yet.


1. Create the app

Apps are no longer created in the Partner Dashboard; they live in the Dev Dashboard (https://dev.shopify.com/dashboard), which answers 401 until the Shopify account belongs to a Partner organization.

  1. Sign in at https://partners.shopify.com and create a Partner organization if there is none (free, separate from any merchant account).
  2. Dev Dashboard → Apps → Create app → Start from Dev Dashboard. Name it Marketing OS. This opens the first version form.
  3. Fill the version, then Release it (a version only applies once released):
    • App URL — https://<web-host>/settings/integrations, with Embed app in Shopify admin unchecked (the app is not embedded).

    • Scopes — the four in §2, comma-separated.

    • Use legacy install flow — checked. The API builds its own /admin/oauth/authorize URL and exchanges the code itself; Shopify-managed installation is for embedded apps.

    • Allowed redirection URL(s) — must contain the API callback exactly, character for character, including the scheme and any trailing path:

      http://localhost:4000/integrations/shopify/callback      # local dev
      https://<api-host>/integrations/shopify/callback          # production

      Shopify refuses the install if the redirect_uri we send is not on this list. Both entries can coexist.

  4. Copy the Client ID and Secret from the app's App settings page.

2. Scopes

Request exactly six, and no more:

ScopeWhat it is for
read_productsProducts, variants, prices, images
read_inventoryStock levels, and inventoryItem.unitCost — the margin
read_ordersThe 90-day order window that average order value is computed from
read_customersCustomer segments for audience sync (orders count, lifetime spend, last order, marketing consent)
read_discountsRequired alongside write_discounts by the discount mutations
write_discountsCreating, re-dating and ending a promotion's promo code on the store (MARK-ACRP39)

The app requests these in the authorize URL (SHOPIFY_SCOPES), and the released version must list the same six — see §6a for the discount scopes. It asks for an offline token (no grant_options[]=per-user), because the sync runs on a schedule and a per-user token dies with the merchant's session.

The 60-day order limit. Without Shopify's protected read_all_orders scope, the Admin API only returns orders from the last 60 days. The 90-day AOV window will therefore come back short on most stores. That is expected and not an error — the economics report how many orders they actually covered, and the number is still a fair average. Requesting read_all_orders means asking Shopify for it in writing and justifying the need; it is deliberately not requested here.

Protected customer data is not optional. Shopify treats the Order object itself as protected customer data, not just the email and phone on it: without approval the order query fails with "This app is not approved to access the Order object", so AOV, daily stats, server-side conversions and audience segments all come back empty while the catalog still syncs. Set it in the Partner Dashboard → the app → API access requests → Protected customer data access, Step 1:

  • Protected customer data — reasons Analytics and Marketing or advertising. This is what unlocks orders and customers.
  • Email and Phone fields — reason Marketing or advertising, so audience sync and conversions can send hashed emails and phones (without them only Meta matches, on the hashed customer id).

Step 1 takes effect at once for development stores, with no review. Step 2 (the data-protection questionnaire) is only reviewed when the app is submitted to the App Store.

write_discounts is the only write. The integration otherwise only reads. It creates a basic code discount when someone creates a promotion on a connected store, moves its dates when the promotion's window changes, and deactivates it when the promotion ends — nothing else in the store is changed. Pausing a Meta ad for a sold-out product happens on the ad platform, not in Shopify.

3. API environment

bash
SHOPIFY_API_KEY=<Client ID>
SHOPIFY_API_SECRET=<Client secret>
SHOPIFY_REDIRECT_URI=https://<api-host>/integrations/shopify/callback
SHOPIFY_API_VERSION=2026-07
  • SHOPIFY_API_SECRET is doing two jobs: it signs the token exchange and it is the key every callback's hmac parameter is verified against. Treat it as a signing secret.
  • SHOPIFY_API_VERSION pins the Admin GraphQL version (YYYY-MM). Shopify supports each release for twelve months and ships a new one quarterly, so bump this roughly once a year — it is an env change, not a deploy.
  • All four are optional. Without the key and secret the store card on /settings/integrations reads "Shopify not configured", the connect route answers 503, and nothing else in the app changes.

4. Installing on a store

  1. /settings/integrations → Connect your Shopify store.
  2. Enter the merchant's admin domain — acme-supply.myshopify.com. Not the storefront domain: Shopify's OAuth screen lives on the .myshopify.com host, and a custom domain is not an Admin API host.
  3. Approve the install in Shopify. The callback verifies the HMAC and our own signed state, stores the encrypted offline token, and queues the first sync.
  4. The first sync pulls the catalog, the order window, the derived economics and the product photos. Watch it at /admin/queues (commerce-sync), or just reload the card.

To test against a real store without touching a real merchant, create a development store (Dev Dashboard → Stores → Create store → Dev, with Generate test data) and install the app on that. The generated data has products and draft orders but no orders and no cost per item: mark a couple of drafts paid and give a product a cost, or AOV and margin stay blank.

5. What happens after

  • Hourly the commerce-sync cron re-pulls every active store. The catalog is re-read in full (it is small) and the 90-day order window is re-rolled, because recent days get restated.
  • Economics — average order value, catalog gross margin and break-even ROAS — are recomputed every sync and written onto the project only where the field is still empty. A value someone typed is never overwritten by a sync; the settings page shows the store's number beside it with a "Use this" button for taking it deliberately.
  • Product photos are copied into the asset library (top 60 by revenue per run) and become selectable in the creative studio like any upload.
  • Uninstalling the app in the Shopify admin sends app/uninstalled, which flips the connection to revoked at once; the hourly tick skips it and the card asks for a reinstall. That subscription is registered per shop by the API (§6a). If the webhook is ever missed, the next API call's 401 does the same thing. There is no token refresh — an offline token is either valid or gone. 48 hours later Shopify sends shop/redact and the store's data is deleted (§6a).

5a. Meta catalog and Advantage+ catalog ads

Once the store is connected and Meta is connected, the store can feed a Meta product catalog for Advantage+ catalog (dynamic product) ads:

  1. Create the catalog — the Create Meta catalog button on the Shopify card, or commerce_create_meta_catalog from the Terminal / MCP. The catalog is owned by a Meta Business Manager; when the connected Meta user has more than one, pass businessManagerId. Meta then fetches the feed daily from https://<api-host>/public/feeds/meta/<token>.csv (API_PUBLIC_URL must be the public API host).
  2. Create product sets on /audiences (or commerce_create_product_set): by collection handle, any of some tags, and/or a minimum gross margin. The margin filter only sees variants with a cost per item in Shopify.
  3. Launch from /wizard → Advantage+ catalog, or meta_create_catalog_campaign. It goes to Approvals, is created paused, and is switched on with meta_activate_entity on the campaign.

The feed has one row per variant, uses the variant id as the item id (the same id the server-side conversions send), skips drafts and products without a photo, and marks a variant with 0 stock out of stock — including one whose inventory Shopify does not track.

Meta permission: catalog calls need catalog_management, which is behind Meta App Review. Set META_CATALOG_MANAGEMENT=true once it is approved (or on a development-mode app whose testers hold a role) and reconnect Meta. Until then the tools answer with a message naming the permission. See docs/meta-app-review-resubmission.md.

6. App review notes

A development store in the KeyesCode organization needs none of this. Any other store needs Public distribution, which means Shopify's review even when the listing is unlisted (installable from our own connect card, absent from App Store search). That is the route the owner chose (MARK-26J2BE).

  • Why these scopes. read_products and read_inventory build the product catalog the advertising recommendations are grounded in, including cost per item, which is what makes a break-even ROAS calculable. read_orders is the 90-day average order value and the purchases sent to the ad platforms as server-side conversions. read_customers computes the four built-in customer segments that audience sync uploads, hashed, to the ad platforms the merchant switched on — a store installed before it was added reports it as a missing scope, and audience sync shows every segment as unavailable until the merchant reinstalls. Email and phone are additionally Shopify protected customer data and need Shopify's approval. No write scope is requested because the app changes nothing in the store.
  • Data handling. Stored: products, variants, inventory, cost per item, per-day order count and revenue, product photos (copied into the merchant's own asset library so ads do not hot-link a CDN URL that rotates), and, per order sent as a conversion, the order id, value and platform answer (conversion_sends). The only per-customer value stored is the numeric Shopify customer id of each audience member (audience_syncs.memberIds), so the next refresh can diff. Never stored: names, emails, phones, addresses, payment data. Emails and phones are read at send time, SHA-256 hashed in memory and sent to the ad platform (ADR 0047).
  • Token storage. The offline access token is encrypted at rest with AES-256-GCM (TOKEN_ENCRYPTION_KEY) and is never logged.

6a. Webhooks

One public route, POST https://marketing-api.keyescode.com/webhooks/shopify, takes every topic and routes on X-Shopify-Topic. It verifies X-Shopify-Hmac-Sha256 (base64 HMAC-SHA256 of the raw body, keyed with SHOPIFY_API_SECRET) in constant time and answers 401 on a bad or missing signature, 200 otherwise. What each topic does (ADR 0064):

TopicEffect
app/uninstalledEvery active connection for the shop becomes revoked at once.
shop/redactQueued on shopify-compliance. Deletes the connection (products, variants, daily stats, order discount codes and product sets cascade), the product photos still filed as product photos, the Shopify conversion sends, and every audience member id.
customers/redactThe customer's id in every audience member list is replaced by a placeholder, so the next daily refresh rebuilds the platform audience without them. Conversion sends are kept: they hold no customer data and stop the order being resent.
customers/data_requestLogged, and emailed to PLATFORM_ADMIN_EMAILS with the ids only. A person answers the merchant within 30 days (DATA_REQUESTS_POLICY.md §5a). Set PLATFORM_ADMIN_EMAILS in production, or the request is only a log line.

The compliance subscriptions can only be declared in shopify/shopify.app.toml. The Dev Dashboard's version form has no webhook fields, just "Webhooks API version". The TOML mirrors the released marketing-os-3 (4 scopes — the TOML now lists 6, see below — legacy install flow, both redirect URLs, embed off, the app URL, the three compliance topics). shopify app deploy turns it into a new version, so a key missing from it resets that setting.

app/uninstalled is registered per shop, not in the TOML. While use_legacy_install_flow = true, shopify app deploy refuses app-level subscriptions:

App-specific webhook subscriptions are not supported when use_legacy_install_flow is enabled

compliance_topics still pass. The legacy flow stays because the connect card builds its own /admin/oauth/authorize URL and exchanges the code itself. So the API calls Admin GraphQL webhookSubscriptionCreate(topic: APP_UNINSTALLED) for the shop, pointed at API_PUBLIC_URL + /webhooks/shopify (CommerceService.ensureUninstallWebhook, MARK-QSPMJ0):

  • right after each install, in the OAuth callback, and
  • on every hourly sync, which backfills stores installed before this and re-creates the subscription if a merchant deletes it.

It lists webhookSubscriptions(topics: APP_UNINSTALLED) first and creates nothing when one with our URI exists. A failure is logged as Could not register the app/uninstalled webhook for … and never fails the install or the sync: the sync's 401 still catches an uninstall, only later. A non-https API_PUBLIC_URL (local dev) skips it, since Shopify only delivers to https.

Promotions and the discount scopes (MARK-ACRP39). SHOPIFY_SCOPES and the TOML's [access_scopes] gained read_discounts and write_discounts so a promotion can create its promo code on the store (discountCodeBasicCreate, Admin API 2026-07), re-date it (discountCodeBasicUpdate) and end it (discountCodeDeactivate). Two consequences:

  • A new app version must be released (below) before the install screen offers them. Until then Shopify grants the old four, whatever the authorize URL asks for.
  • Stores installed earlier keep the old grant. The connection's missingScopes lists the two, the Integrations card shows a Reconnect button, and a promotion created meanwhile is saved without a Shopify code and says so in shopifySyncError. Setting it active again after the reconnect creates the code. A missing scope never revokes the connection.

Orders carry discountCodes. The sync's order query now reads each order's discountCodes, and every (order, code) pair is upserted into commerce_order_discounts (upper-cased, with the order total). That table is what GET …/promotions/:id/performance sums; it holds no customer data and goes with the connection.

To release a TOML change, validate it first without releasing (the CLI opens a browser to sign in as the Partner account, tanner@keyescode.com), then press Release on the new version in Dev Dashboard → Versions:

bash
cd shopify
npx @shopify/cli@latest app deploy --config shopify.app.toml --no-release

Release after any API change it depends on is deployed, or Shopify's first deliveries land on a 404. Check the new version: the six scopes, legacy install flow checked, both redirect URLs, embed unchecked. To prove app/uninstalled: on the dev store, uninstall the app and watch the card go revoked without a sync (railway logs shows Shopify app uninstalled from …). Reinstall afterwards.

6b. Review package

Billing — decide before submitting. Shopify's App Store requirement 1.2 (checked Sep 23 2026): "Apps that use off-platform billing cannot be distributed through the Shopify App store, unless you've been notified otherwise by Shopify", and 1.2.1 requires Shopify App Pricing or the Billing API "for any app charges". The Shopify app charges nothing itself; Marketing OS is a separate subscription (Stripe) that covers Meta, Google, TikTok and email as well as the store. That is off-platform billing for a product a merchant reaches through the app, and a reviewer may reject it. The options are:

  1. Submit with pricing Free and say plainly in the review notes that the app connects a store to an existing Marketing OS account, which is billed separately. Accept the rejection risk, or ask Partner Support for a written exception first. This is the cheapest option.
  2. Add Shopify billing for accounts that arrive through Shopify. That means real work: plans mirrored in Shopify App Pricing, and the Stripe gate taught a second source of truth.

Protected customer data, Step 2 (Partner Dashboard → the app → API access requests → Protected customer data access → Provide details). These are the 16 questions as Shopify asks them. "Owner" marks an answer the code cannot evidence, which the owner must confirm or change before saving.

#QuestionAnswerBasis
1Do you process the minimum personal data required to provide value to merchants?YesEmail/phone read only at send time and hashed in memory. Only numeric customer ids are stored. Name and address are not requested.
2Do you tell merchants the personal data that you process and your purposes for processing it?Yes/privacy → "Shopify store data" and the uninstall bullet (added in MARK-26J2BE), plus the connect card.
3Do you limit your use of personal data to that purpose?YesCustomer data only feeds the audiences and conversions the merchant switched on.
4Do you have privacy and data protection agreements with your merchants?OwnerTerms of Service + Privacy Policy exist; no DPA is offered (docs/security/soc2-readiness.md CC9.2). Answer Yes only if the Terms count as that agreement.
5Do you respect and apply customers' consent decisions?YesAudiences drop customers who unsubscribed from email and SMS (audienceConsentAllows); nothing is sent for a customer Shopify marked erased.
6Do you respect and apply customers' decisions to opt-out of having their data sold?YesNo data is sold. Uploads to ad platforms follow the same consent filter as #5.
7If you use personal data for automated decision-making with legal or significant effects, can customers opt-out?Not applicableSegments choose ad audiences. There is no decision with legal or similarly significant effect.
8Do you have retention periods that make sure personal data isn't kept longer than needed?YesMember ids are replaced on every refresh; shop/redact deletes the store's data 48 h after uninstall; customers/redact removes the customer's id.
9Do you encrypt data at rest and in transit?OwnerIn transit: HTTPS only. At rest: tokens are AES-256-GCM; whether Railway encrypts the Postgres volume is not verified in this repo.
10Do you encrypt your data backups?OwnerRailway volume snapshots (docs/DISASTER_RECOVERY.md); their encryption is not verified here.
11Do you separate test and production data?YesLocal and CI databases are separate; testing uses Shopify development stores; the API tests are hermetic.
12Do you have a data loss prevention strategy?OwnerPartial: no customer PII is stored, and logs and error reports are redacted (ADR 0003). There is no written DLP policy.
13Do you limit staff access to customers' personal data?OwnerOnly the founder has production access. There is no stored customer PII to access.
14Do you have strong password requirements for staff passwords?OwnerApp passwords are Argon2id-hashed with optional TOTP; staff access to Railway, GitHub and Shopify is not governed in this repo.
15Do you log access to personal data?OwnerThe audit log records tool calls and access changes, not reads of personal data. Likely "No" today.
16Do you have a security incident response policy?Owner (No)soc2-readiness.md CC7.3/CC7.4: missing. Write a one-page policy before answering Yes.
—Audits and certifications (optional, 255 chars)Leave emptyNone held.

Listing copy (unlisted, so it is for the reviewer and the install screen; the owner supplies the icon and screenshots):

  • Name: Marketing OS
  • Tagline: Your store's catalog and margins, feeding the AI that runs your ads.
  • Description: Marketing OS connects your Shopify store to your Meta, Google and TikTok advertising. It reads your products, stock, cost per item and recent orders to work out your average order value, gross margin and break-even ROAS, so budget and bid recommendations are grounded in what a sale is actually worth. It pauses ads for sold-out products, sends purchases to the ad platforms as server-side conversions, keeps consented customer segments in sync as ad audiences, and puts your product photos into the creative studio. The app only reads your store and never changes it. It connects to an existing Marketing OS account.
  • Scopes justification: §6 "Why these scopes".

Reviewer test instructions:

  1. Sign in at https://marketing.keyescode.com/login with the reviewer account (owner: create a dedicated one on a paid plan with a test project, and paste its email and password into the submission form, not into this repo).
  2. Settings → Integrations → Connect your Shopify store → enter marketing-os-test-store.myshopify.com, or the reviewer's own development store, and approve the install.
  3. The card shows the store with a product count within a minute. Settings → Business shows the derived average order value, margin and break-even ROAS (the test store has paid orders and costs set).
  4. The asset library holds the product photos under Product photos.
  5. Uninstall the app in the Shopify admin: the card reads revoked at once.
  6. Include a screencast of steps 2–5, which Shopify requires.

Submitting (owner only). Partner Dashboard → the app → Distribution → Public distribution (this choice is permanent) → complete the listing, set it to unlisted, and submit. When approved, remove the "not yet open to merchant stores" caveats from apps/web/src/app/(public)/welcome/page.tsx (FAQ and "Where we are behind") and apps/web/src/app/(public)/compare/adadvisor/page.tsx.

Production state

As of Sep 24 2026:

  • SHOPIFY_API_KEY, SHOPIFY_API_SECRET and SHOPIFY_REDIRECT_URI (https://marketing-api.keyescode.com/integrations/shopify/callback) are set on @marketing-os/api; SHOPIFY_API_VERSION uses the 2026-07 default.
  • Released version marketing-os-3 (Sep 24 2026, 07:06 UTC): the four scopes, legacy install flow, the production and localhost callbacks, and the three compliance webhooks only (customers/data_request, customers/redact, shop/redact → /webhooks/shopify). It matched shopify/shopify.app.toml until MARK-ACRP39 added read_discounts and write_discounts there; a version carrying them has not been released yet (§6a).
  • app/uninstalled is not in the app version, because the legacy install flow blocks app-level subscriptions (§6a). The API registers it per shop at install and on each sync, once MARK-QSPMJ0 is deployed.
  • Protected customer data Step 1 is selected (Level 1 + Email + Phone). Step 2 is unanswered; the drafted answers are in §6b.
  • Public distribution was selected Sep 24 2026 (unlisted; the choice is permanent). The App Store submission has not been made yet ("Manage submission" is the owner's step), so only development stores in the KeyesCode organization can install the app until it is approved. The public site says Shopify is not yet open to merchant stores until then.

Troubleshooting

SymptomCause
"Shopify not configured"SHOPIFY_API_KEY / SHOPIFY_API_SECRET are unset in the API environment.
Install ends on ?shopify=errorHMAC or state failed, or the token exchange did. Check the API log line.
Shopify refuses the install screen outrightSHOPIFY_REDIRECT_URI is not an Allowed redirection URL, exactly.
"Enter your .myshopify.com domain"A storefront domain was entered. Use the admin domain.
Connected, but gross margin is blankNo product in the store has a cost per item set. That is a Shopify field.
Connected, but AOV is blankNo orders in the window, or read_orders was not granted.
Log says "not approved to access the Order object"Protected customer data is not selected for the app (see §2).
Card says revokedThe app was uninstalled in the Shopify admin. Reinstall it.
Webhooks answer 401SHOPIFY_API_SECRET differs from the app's current secret (rotated?).

Something wrong or missing on this page? Tell us and we will fix it.