docsOperate

The Shopify connector

A complete, generated mirror of a Shopify store, the origins a wholesale business needs on top of it, and the boundary of what nobody can mirror.

This page is the operator's view: what to create in Shopify, what to enter in MemQL, what happens next, and what to look at when it stops happening. The design record is the connector design; the storefront's side is the storefront completeness checklist, whose sections 6 and 7 are the SERVING half: which hosts this cluster's edge admits for a storefront, where that list comes from, and why a storefront bundle may not bake store data in at build time.


What it is

Shopify owns the store's data. MemQL holds a mirror of it: 65 concepts under v1:shopify:*, generated from the Admin GraphQL schema at a pinned version, kept current by webhooks and repaired by reconciliation. Nothing in the mirror is authored in MemQL and nothing writes to it but the connector.

Alongside the mirror, v1:commerce:* carries the things Shopify does not model at all -- quotes, approval chains, credit limits, reps and territories, reorder lists. MemQL is the origin of those, and a closed set of them is pushed back into Shopify as metafields.

Three properties are worth knowing before anything else:

  • The model is generated. cmd/shopifyschema reads Shopify's schema and emits the concepts, their reads and the GraphQL documents. Nothing under dsl/shopify/generated/ or integrations/shopify/generated/ is edited by hand; a hand edit fails the build and would be lost at the next regeneration anyway. No mirror WRITE is generated: the connector returns MirrorWrites and the data-origins runtime performs the write, behind the version guard and under the connector actor.
  • A webhook is a trigger, not a payload. A delivery tells the connector which object changed. The object itself is then read back through the Admin API with the generated selection set. Webhook payloads lose fields, truncate, arrive out of order and are not guaranteed; the API does not.
  • Reconciliation is a requirement. Shopify states that webhook delivery is not guaranteed. Every domain is therefore re-checked on a cadence, and the rows a pass finds are counted as drift -- which is the only evidence anyone gets that deliveries are being lost.

Step 1 -- inventory the store's apps

Do this first, before creating anything. Walk the store's installed apps and write down what each one does, because the mirror will not carry any of them.

App-owned metafields lost public access on 2025-05-19 ("No other apps have access"), and data an app keeps in its own database never reaches Shopify at all. A reviews app, a bundle builder, a wishlist, a page builder, a form tool -- their data lives with them.

"Lose no function" is met by integrating those apps in the storefront through their own APIs and embeds, not by mirroring them. Each app gets a line in the storefront checklist: an integration plan, or an explicit "not carried". An app nobody wrote a line for is the one that goes missing on launch day.


Step 2 -- create the app

A custom-distribution app, created in the Shopify Dev Dashboard. Admin-created custom apps cannot be created since 2026-01-01, so the Dev Dashboard is the only route.

Request these scopes. The list is derived from the allowlist, so the authoritative copy is generated.Scopes in integrations/shopify/generated/model.go and the console shows the store's grant against it:

text
read_all_orders read_locales
read_assigned_fulfillment_orders read_locations
read_content read_markets
read_customer_payment_methods read_marketing_events
read_customers read_merchant_managed_fulfillment_orders
read_discounts read_metaobject_definitions
read_draft_orders read_metaobjects
read_fulfillments read_online_store_navigation
read_gift_cards read_online_store_pages
read_inventory read_orders
read_legal_policies read_own_subscription_contracts
read_payment_terms read_returns
read_product_feeds read_shipping
read_products read_shopify_payments_disputes
read_publications read_shopify_payments_payouts
read_store_credit_accounts read_themes
read_users

Three of them need more than a checkbox:

  • read_all_orders unlocks orders older than 60 days. It needs Shopify's approval, requested from the app's configuration. Without it the mirror's order history begins 60 days ago and nothing says so except a thin backfill.
  • Protected customer data is a separate approval with two levels. Level 1 covers customer records; Level 2 covers the fields marked as protected (name, email, phone, address). Below the level a field needs, Shopify returns null and the mirror stores null -- so the store row records the approved level and the console shows it, because "the customer has no phone number" and "we are not approved to see it" look identical in the data.
  • read_reports plus Level 2 is what the shopifyql pass-through needs. The connector refuses below that with a message naming the level; Shopify would answer 403 naming nothing.

Also install the Headless channel on the store and take its Storefront token. That is what the headless storefront uses, and it is a different credential from the Admin token.

Token expiry. Offline access tokens for public apps become expiring from 2027-01-01. A custom-distribution app's token does not expire today; the connector stores every token by reference so a rotation is a secret change rather than a code change.


Step 3 -- attach the store to its storefront

Connect Shopify does this step. On the storefront's Store panel, save the app from step 2 and press Connect Shopify: the shop's staff approve on Shopify, and MemQL seals the credentials, writes the store row, mints the Storefront token, attaches the store and registers the webhooks. A developer can do it. The whole flow, and every result it can come back with, is Connect Shopify.

The form below is the CLUSTER OWNER's manual route, for a store whose credentials are already sealed as globalSecret rows -- the environment seed's, for instance. MemQL OS → Deployables → the storefront → Store → Attach a store. Everything about a storefront is configured on its deployable, so attaching the store it talks to is done there rather than in an app of its own. It asks for:

FieldWhat it is
Domainacme-widgets.myshopify.com. The one identifier Shopify never changes.
App client idFrom the Dev Dashboard. Recorded for the audit trail.
Admin token refThe name of a v1:platform:globalSecret row holding the Admin API token.
Storefront token refSame, for the Headless channel's Storefront token.
Webhook secret refSame, for the app's webhook signing secret.
API versionMust match the version the mirror was generated from -- see "The quarterly bump".
Protected data levelnone, level1 or level2, as approved.
OwnerThe MemQL user a customers/data_request export is filed under.

The three token fields are REFERENCES, not tokens. The store row is read by the console and returned to a browser; a token on it would be a token on a screen. No screen seals a token by hand: Connect Shopify seals them on the server, and the environment seed below does for a cluster's first store. Name a row one of them wrote.

A store row and a site binding are one record. The storefront's binding NAMES this row -- binding: {storeId} on v1:platform:site -- and does not copy it. The edge resolves the myshopify.com domain and the Storefront token reference through the store at serve time, so an edit here reaches every storefront bound to it with no second write, and there is no second place to keep in step. It used to carry its own {storeDomain, storefrontTokenRef}, which meant one store was recorded twice, edited in two places, at two authorization tiers.

Who may read and change a store row. v1:shopify:store is @rowAuthz(clusterOwner, rankFloor="developer") (design decision D3):

ActWho
read a store row (storeById, storeByDomain, stores, developmentStoresFor)developer, cluster owner
register a store (createStore)cluster owner, or server code (Connect Shopify)
change a store that exists (updateStore, setStoreStatus)cluster owner
attach a store to a storefront, or change which one it fronts (updateSiteStoreBinding)developer, cluster owner: the store part, a store they can read, and a storefront they can write

Below developer a store read answers zero rows, not an error: the binding guard and the deploy path read "not readable" off an empty result. The read floor widens reads only, so a developer who can see a store still cannot change it.

Binding is a developer's or a cluster owner's act, and the rule is narrow. Attaching a store needs execute on app:deployables/store, which the owner and developer roles hold; beside it, the engine refuses a binding that names a store the caller cannot read. Binding a storefront to a store you may not read would publish that store's Storefront token under that storefront's hostname. Reading is not enough on its own: CHANGING which store a site is bound to -- a create that binds, a rewrite that re-points, updateSiteStoreBinding -- also needs the store part, and so does changing the store a preview binding names, through updateSitePreviewBinding or a rewrite that re-points it. So an admin, below the read floor, binds no store even when granted the part, and a developer an owner has denied the part by name binds none either, on either binding.

The consequence, stated (D3). A developer may bind ANY storefront they can write to ANY store on the cluster, and that is not only the storefronts they own. v1:platform:site's account grant admits staff (developer and above) to write every account-tied site, so every client storefront -- live ones included -- can be re-pointed at any store by any developer holding the store part. That is the same reach that already lets a developer pause, archive or delete those sites. Every store row is registered by a cluster owner or by server code (D15), so the token references a binding exposes are ones an owner or Connect chose. An owner who wants one developer kept out denies them execute app:deployables/store by name. The Store panel in MemQL OS draws a developer the store slot and the attach, and not the register form, pause and resume, or the subscription reconcile -- the first three the engine would refuse them, and the last walks every store on the cluster.

The environment seed

A cluster with no store row and these variables set seeds its first store at boot:

text
MEMQL_SHOPIFY_STORE_DOMAIN acme-widgets.myshopify.com
MEMQL_SHOPIFY_ADMIN_TOKEN shpat_...
MEMQL_SHOPIFY_STOREFRONT_TOKEN ...
MEMQL_SHOPIFY_WEBHOOK_SECRET ...
MEMQL_SHOPIFY_API_VERSION 2026-07
MEMQL_SHOPIFY_APP_CLIENT_ID ...
MEMQL_SHOPIFY_PROTECTED_DATA_LEVEL none | level1 | level2

The tokens are sealed into globalSecret rows and the store row references them; the variables are then never read again. Editing them later changes nothing -- the row is the configuration. That is deliberate: an env var that silently overrode a row would make the console's view of a store a lie, and would do it only on the nodes carrying the variable.


The development store

A storefront is exercised against a development store before it serves shoppers. It is attached exactly like any other store -- a second v1:shopify:store row -- and mirrored like any other, so an order placed against it lands in the mirror under that store's own storeId and is already excluded from every read scoped to the live one. Two fields say what it is: isDevelopment, and developmentOfStoreId naming the live store it stands in for. Attach it from the same panel, under Development store.

It is not a store this program provisions, and specifically not a Shopify store created with generated test data -- such a store cannot be transferred to a merchant.


Step 4 -- what happens next

Subscriptions. On boot and daily at 03:15, the connector registers every mirrored topic for every ingesting store: HTTPS delivery to https://api.<your-domain>/inbound/shopify-<storeId>, at the pinned API version, with includeFields trimmed to id, admin_graphql_api_id and updated_at. It updates subscriptions whose URL, version or fields have drifted, and removes its own that the allowlist no longer wants. Another app's subscriptions are not visible to this app and are never touched.

The daily pass is not tidiness. Shopify retries a failed delivery eight times over four hours and then deletes the subscription after eight consecutive failures. A deployment that was unreachable for an afternoon comes back with its webhooks silently gone: the mirror simply stops changing, and nothing anywhere says so. The daily reconcile is what brings them back, and the store's health is where you see that it happened.

Backfill. Each domain's initial load is a Bulk Operation: Shopify runs the query, writes a JSONL file, and the connector streams it in resumable batches, persisting the line offset as the domain's cursor. Bulk queries are exempt from the cost limit, which is why a hundred thousand orders are affordable at all. It is OPERATOR-DRIVEN -- nothing schedules it -- from the MemQL OS Cluster app's Data origins section or with datasyncStartBackfill(connector: "shopify", conceptId: "v1:shopify:order").

Domains are applied parents first (generated.ApplyOrder), so a line item never lands before its order.

Live delivery. A webhook arrives at the inbound receiver, is verified against the store's own secret, and is staged. The runtime's dispatcher automation hands the staged row to the connector, which dedupes on the webhook id, fetches the object by GID with the generated selection set, and returns the rows; the runtime applies them under the version guard, so a write whose updatedAt is older than the stored row's is recorded as stale and dropped.

Reconciliation. Every ten minutes the runtime sweeps the domains whose own interval has elapsed. Domains whose root connection accepts a query: filter are paged by updated_at:>; the rest -- gift cards, price lists and catalogs, menus, pages, blogs, articles, policies, marketing, payouts, redirects, carrier services, store credit -- are re-listed whole on their own cadence and rows the origin no longer returns are tombstoned. Metaobjects and metafield definitions are not swept at all: metaobjects(type:) and metafieldDefinitions(ownerType:) each require an argument a generic listing cannot supply, so those two domains are kept current by their webhooks alone (listing them per type / per owner type is future work).

A full re-list that hits its page cap tombstones nothing. Tombstoning is an argument from absence, and the argument is only valid if the walk was complete.


Step 5 -- the compliance topics

Three topics are declared in the app's configuration rather than created through the API, because they are not members of WebhookSubscriptionTopic:

text
customers/data_request
customers/redact
shop/redact

Point all three at the app-level endpoint https://api.<your-domain>/inbound/shopify -- the connector's own name, with no store id -- and not at a store's /inbound/shopify-<storeId>. The reason is when Shopify sends them: for a shop that never finished installing, for a shop that uninstalled 48 hours ago, and before the first store has connected at all. A per-store URL exists only while that store row does; the app-level one exists as soon as the Connect flow has sealed the app's client id and secret (SHOPIFY_CONNECT_CLIENT_ID / SHOPIFY_CONNECT_CLIENT_SECRET), and every delivery to it is verified against that secret. A bad HMAC on any of them answers 401, which is Shopify's requirement.

Because one secret covers every store, neither the URL nor a header can say which store a privacy request is about. The connector reads the signed shop_domain in the body, refuses a delivery whose X-Shopify-Shop-Domain header disagrees with it, and queues a job only for a store this app installed (appClientId); any other shop is dropped without a job. A privacy delivery on a per-store URL is held to the same body check, and a paused store still queues its privacy jobs -- a pause stops the mirror, not the merchant's obligations.

Upgrading from the per-store compliance URL

Earlier versions of this step pointed the three topics at the per-store /inbound/shopify-<storeId>. For a store connected through Connect Shopify (the managed app), that URL now refuses all three: the managed app signs a store's per-store deliveries with the same secret as the app-level ones, so a captured privacy delivery would replay onto it. A cluster still configured the old way therefore refuses every privacy request for those stores -- and Shopify, having had its 202 from the receiver, does not send a refused one again.

You cannot miss it:

  • the storefront's Store panel opens with an error naming how many were refused and the exact URL to configure;
  • shopifyStoreHealth reports privacyDeliveries per store (see What the numbers mean);
  • each refused delivery's staged v1:platform:inboundRequest row is failed, and its lastError names the app-level URL;
  • each is audited as shopify_privacy_delivery_refused (outcome blocked, target the store) and logged at ERROR.

The fix is on Shopify's side only: in the app's configuration (Dev Dashboard, or Partner Dashboard for an app created there), set customers/data_request, customers/redact and shop/redact to https://api.<your-domain>/inbound/shopify. Nothing on the cluster changes. Every refusal counted before the fix is a request the automation did NOT honour; its staged row keeps the signed body (the customer id and Shopify's request id), which is what following it up by hand starts from.

A store connected with its own custom app, rather than through Connect, is unaffected: its per-store URL keeps accepting the three topics, verified by that store's own webhook secret.

What each one does:

The three arrive as ordinary webhooks and are turned into v1:shopify:complianceJob rows -- the connector's own queue, deliberately not the outbox: the outbox's drain hands every entry to Propagate, and a privacy job handed to Propagate would try to write a customer's export into Shopify. An hourly automation runs the ones whose hold has elapsed.

  • customers/data_request collects every mirror row that mentions the customer's GID -- across every mirrored concept, at any nesting depth -- into a JSON artifact in the Library, filed under the store's owner, with the Shopify request id recorded. Audited as shopify_data_request_exported. A store with no owner fails the export rather than filing one merchant's customer data in a stranger's library.
  • customers/redact rewrites the customer's PII fields to [redacted] across every version of every row that mentions them, keeping the opaque GID and the commercial facts -- quantities, totals, dates. The merchant's books are not the customer's personal data, and destroying them would be a different compliance failure. Audited as shopify_customer_redacted, and scheduled rather than immediate so a merchant's own grace period applies.
  • shop/redact purges the store's whole mirror and its sync state after a 48-hour hold, then pauses the store row. Before purging it re-checks whether the store is reachable again: an uninstall an operator reverses within the hold must not cost the mirror. Audited as shopify_shop_redacted (or shopify_shop_redact_skipped).

Redaction is the one write to a mirror that is not an apply. Everything else converges the mirror onto what the origin says; redaction deliberately makes it differ, by rewriting rows the origin has already forgotten. It runs under the connector actor and goes through raw SQL, because "every version" is exactly what an append-only model will not let you touch.


The push channel

The first thing MemQL writes into a live store is a metafield in the memql namespace. Additive, invisible to shoppers unless a theme asks for it, and impossible to break a price or an order with. B2B catalogs and price lists are the next flip and get their own review.

Four MemQL-origin concepts project today:

ConceptMetafieldOwnerStorefront
v1:commerce:productContentmemql.description, .summary, .keywords, .blocksProductpublic_read
v1:commerce:customerNotememql.noteCustomerprivate
v1:commerce:companyLocationNotememql.noteCompany locationprivate
v1:commerce:creditLimitmemql.creditLimit, .creditLimitStatusCompany locationprivate

A row change appends a v1:platform:outboxEntry in the write's own transaction; the runtime's drain worker hands its row reference to the connector. The connector reads the current source row under its own identity on every attempt, including retries, and maps it to metafieldsSet (or, above 250 metafields, a staged bulkOperationRunMutation).

A Shopify userError dead-letters. It does not retry. A validation failure arrives inside a 200 response and will fail identically forever; retrying it is how a queue stops draining while every individual attempt looks transient -- so the connector returns it as sync.Permanent and the drain dead-letters it immediately rather than spending its attempt budget. Dead-lettered entries are in outboxDeadLetters(target: "shopify") and on the MemQL OS Cluster app's Data origins section.

Use the outbox entry's delivery outcome and the actual Shopify metafields as delivery evidence. The current drain does not update productContent.status, so a row may still say draft after delivery. For public product copy, also verify that the Storefront API returns the new memql metafields; a successful Admin write alone does not establish storefront visibility.

Accepting a quote is the other write: draftOrderCreate with the company as purchasingEntity, the company's payment terms, a PO number, and priceOverride on every line -- which is what makes a quote a quote rather than a re-quote at today's prices.


Rate limits and cost

The Admin API is limited by a leaky bucket of query-cost points, not by requests. The connector reads extensions.cost.throttleStatus on every response and waits before the call that would overdraw the bucket, rather than after being refused. A 429 or a THROTTLED error still retries with the vendor's own Retry-After where one is given.

  • Single queries stay under 1,000 points; the generated selections are bounded to two levels of nesting and reference connections page at 25.
  • Bulk queries are exempt from the bucket. At most five run per shop at once, they must finish within ten days, and the signed result URL is valid for one week -- past that the runner restarts the operation.
  • The store's current bucket is on the storefront's Store panel.

The quarterly bump

The mirror is pinned to one Admin API version. Bumping it is a reviewed regeneration, never a configuration change:

  1. Bump apiVersion in cmd/shopifyschema/allowlist.yaml.
  2. go run ./cmd/shopifyschema --record -- re-records the introspection fixture from Shopify's public schema proxy. No token is needed.
  3. Review the diff: new fields, removed fields, changed enums, renamed types. This is the whole point of the exercise.
  4. make sdk-gen && make arch-model.
  5. Update every store row's apiVersion and let the daily reconcile re-register every subscription at the new version. A store pinned to a version the mirror was not generated from is refused rather than attempted: it would return fields the concepts do not declare and omit fields they require.

cmd/shopifyschema/shopify_schema_drift_test.go fails the build when the checked-in tree and the generator disagree, which is what makes step 1 the only hand edit.


What cannot be mirrored -- by anyone

This is the boundary. It is not a limitation of this connector; none of it is available to any app.

  • Other apps' private data. App-owned metafields and metaobjects lost public access on 2025-05-19, and an app's own database was never exposed. Reviews, bundles, wishlists, form submissions, page-builder content: their data lives with them. Integrate them in the storefront.
  • Other apps' subscription contracts. read_own_subscription_contracts is own-app only, so the mirror carries this connector's contracts and no one else's.
  • Saved analytics reports. Only ad-hoc ShopifyQL exists (shopifyqlQuery); there is no API for saved report definitions. The mirror answers most analytics questions itself and the pass-through covers the rest.
  • Checkout internals. There is no Checkout object and checkout runs only on Shopify. AbandonedCheckout and the checkouts/* / carts/* topics are what exists.
  • Gift-card codes (masked by Shopify), raw payment instruments (tokenised references only).
  • Orders older than 60 days without read_all_orders, staff members without Plus, payouts and balance transactions without Shopify Payments, dispute evidence without approval.

What "lose no function" means in light of that list: every function the store has today is either mirrored, integrated through the owning app, or named here as not carried. There is no fourth category and nothing is left to be discovered on launch day.


The wholesale foundation

dsl/commerce/ carries what Shopify does not model. Since 2026-04-02 companies, payment terms, volume pricing and up to three catalogs are available below Plus; approval chains, quotes, credit limits, reps, territories and reorder lists are not available at all.

ConceptWhat it is forWhat Shopify holds of it
quotePriced, time-boxed offer with locked pricesNothing until accepted, then a draft order
approvalChainWho approves, in order, above what valueNothing. Shopify's order-review Functions run at checkout, after the buyer has been quoted
creditLimitA company location's limit and outstanding balanceA memql.creditLimit metafield a checkout validation Function reads
salesRep / territoryWho covers which companiesNothing. Staff members are Plus-only and a rep is not a staff member
reorderListWhat a buyer buys againNothing

The credit-limit Function is not in this repository. Shopify Functions are authored and deployed in the product repository; what MemQL owns is the number, its history and its projection. The Function reads the metafield.

All of commerce is cluster-owner tier today. The wholesale application will define the per-user model -- which reps see which companies, which buyers see which quotes -- and that record will re-tier these concepts. Declaring a per-user tier before that model exists would be guessing at an authorization boundary.


Operating it

The storefront's Store panel

MemQL OS → Deployables → the storefront → Store. Per store: status, the granted scopes against the allowlist's needs, the protected-data level, the last subscription reconcile and what it changed, the cost bucket, and every domain's sync state with its drift counters. The development store paired to it, when there is one, reads beneath. Privacy deliveries refused on the store's own URL, when there are any, read FIRST, as an error naming the URL to configure.

Backfilling, reconciling and pausing an individual DOMAIN are not here: they belong to every connector, so the Data origins surface owns them and this panel does not repeat the same three buttons. What is here is what is Shopify's alone -- the credentials, the scopes, the subscriptions, the cost bucket, and the per-STORE pause, which is a different switch: it stops ingestion for one merchant while their deliveries keep being staged.

What the numbers mean

  • driftLast / driftTotal -- rows a reconciliation pass wrote that live delivery should have carried. A steady non-zero number means deliveries are being lost; look at the subscription list and the store's health next.
  • staleWrites -- writes refused because the stored row was newer. Ones and twos are normal (Shopify redelivers). A climbing number means something is replaying old deliveries.
  • tombstoned -- rows the origin no longer has.
  • phase -- idle, backfilling, reconciling, paused, error, and not granted for a domain the store's grant does not reach (a standing fact about the connection, not a fault; the reason is on the row).
  • lastError on a domain reading not granted: shopify needs read_content -- the store was connected without any scope that domain reads under. The sweep does not ask the origin about it (the answer is known here) and records the reason once; widening the grant and reconnecting is what changes it. The store's health lists these as domainsNotGranted.
  • fieldsDenied -- per domain, the fields the grant does not cover (a variant's publication counts without read_publications). Shopify refuses such a field with ACCESS_DENIED and, because most of them are non-null, blanks the whole page with it; the connector removes the field from that store's copy of the document and asks again, so the rows land without it. Learned from the origin's answers since the node started, so it is empty until a sweep or a delivery has run, and each replica learns it once.
  • privacyDeliveries -- refused is how many privacy deliveries arrived on the store's per-store URL and were refused because a managed-app store takes them only at the app-level URL; lastRefusedAt is the latest; appLevelUrl is the URL to configure instead. Counted from the store's audit trail (shopify_privacy_delivery_refused), so it is the same on every replica. capped: true means the walk stopped at its bound and refused is a floor. Anything above zero is a legal request Shopify will not resend -- see Upgrading from the per-store compliance URL.
  • A domain whose generated query the origin rejects outright, or whose page costs more than the 1,000-point ceiling, is not retried on the next tick: a rejection is reported once and the domain waits its own cadence; a cost refusal shrinks the page to fit and remembers the size.

Pausing a store

Set its status to paused. Deliveries are still staged and simply not applied, so a pause loses telemetry rather than events and resuming does not need a backfill.

The dev-store smoke

Run this against a Shopify development store before pointing the connector at a real one. It is the end-to-end proof, in the order things can break:

  1. Install. Create the custom-distribution app on the dev store with the scopes above. Note the Admin token, the Storefront token and the webhook secret.
  2. Configure. Connect the store from the storefront deployable's Store panel (Connect Shopify), and confirm the panel shows it connected with no missing scopes. Mark it a development store if that is what it is, and name the live store it stands in for.
  3. Subscriptions. Run shopifyEnsureSubscriptions(). The store's health should show created equal to the desired count and failed empty. Cross-check in the Shopify admin that the subscriptions point at https://api.<domain>/inbound/shopify-<storeId> at the pinned version.
  4. Backfill. Run datasyncStartBackfill(connector: "shopify", conceptId: "v1:shopify:product") to completion, then v1:shopify:order. Confirm the row counts match the store's own product and order counts.
  5. Webhook round trip. Change a product title in the Shopify admin. Within seconds the mirror row's title should change and its syncedAt should move. If it does not, check that the store's front door is reachable from the internet -- a delivery Shopify cannot make is the most common failure and it is invisible from inside the cluster.
  6. Metafield push. Write a productContent row for that product and wait for the drain. The metafield should appear on the product in the Shopify admin under the memql namespace, and its outbox entry should be delivered. Check the same value through the Storefront API before calling public copy live; productContent.status is not delivery evidence.
  7. Storefront. Work the first section of the storefront checklist: the Headless channel's tokens, a product query, a cart, and the hand-off to cart.checkoutUrl.
  8. Serving. If the storefront is served BY this cluster as a shopify_storefront deployable, its content security policy is written from the site's binding and admits the bound store, Shopify's asset CDN and the Customer Account API host -- and nothing else. A storefront whose calls and images are all refused with no status is almost always a policy that does not name the store it is calling. The admitted set, and the rule that a storefront bundle binds at RUNTIME rather than at build time, are What the edge admits and Bound at runtime.

Record the results in the PR that changes the connector. A smoke nobody wrote down is a smoke nobody ran.


Unverified

Carried from the research and not confirmed against a Shopify page. Each is harmless if wrong in the direction assumed, and each is worth confirming before it matters:

  • Whether authenticated-endpoint introspection is documented as supported.
  • Cost-bucket sizes above the Standard plan.
  • Bulk-operation support for theme files and payout sub-connections.
  • Scope gating for menus, events, app installations and tender transactions.
  • Whether a custom-distribution app must request read_all_orders to reach orders older than 60 days, or whether the window does not apply to it.
  • A hard byte limit on a webhook payload.
  • Whether an explicit compliance-topic exemption exists for custom- distribution apps. The connector implements them either way.
  • Whether merchant-configured pixels run on a non-Hydrogen SPA.