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/shopifyschemareads Shopify's schema and emits the concepts, their reads and the GraphQL documents. Nothing underdsl/shopify/generated/orintegrations/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:
read_all_orders read_localesread_assigned_fulfillment_orders read_locationsread_content read_marketsread_customer_payment_methods read_marketing_eventsread_customers read_merchant_managed_fulfillment_ordersread_discounts read_metaobject_definitionsread_draft_orders read_metaobjectsread_fulfillments read_online_store_navigationread_gift_cards read_online_store_pagesread_inventory read_ordersread_legal_policies read_own_subscription_contractsread_payment_terms read_returnsread_product_feeds read_shippingread_products read_shopify_payments_disputesread_publications read_shopify_payments_payoutsread_store_credit_accounts read_themesread_usersThree of them need more than a checkbox:
read_all_ordersunlocks 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
nulland the mirror storesnull-- 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_reportsplus Level 2 is what theshopifyqlpass-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:
| Field | What it is |
|---|---|
| Domain | acme-widgets.myshopify.com. The one identifier Shopify never changes. |
| App client id | From the Dev Dashboard. Recorded for the audit trail. |
| Admin token ref | The name of a v1:platform:globalSecret row holding the Admin API token. |
| Storefront token ref | Same, for the Headless channel's Storefront token. |
| Webhook secret ref | Same, for the app's webhook signing secret. |
| API version | Must match the version the mirror was generated from -- see "The quarterly bump". |
| Protected data level | none, level1 or level2, as approved. |
| Owner | The 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):
| Act | Who |
|---|---|
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:
MEMQL_SHOPIFY_STORE_DOMAIN acme-widgets.myshopify.comMEMQL_SHOPIFY_ADMIN_TOKEN shpat_...MEMQL_SHOPIFY_STOREFRONT_TOKEN ...MEMQL_SHOPIFY_WEBHOOK_SECRET ...MEMQL_SHOPIFY_API_VERSION 2026-07MEMQL_SHOPIFY_APP_CLIENT_ID ...MEMQL_SHOPIFY_PROTECTED_DATA_LEVEL none | level1 | level2The 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:
customers/data_requestcustomers/redactshop/redactPoint 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;
shopifyStoreHealthreportsprivacyDeliveriesper store (see What the numbers mean);- each refused delivery's staged
v1:platform:inboundRequestrow isfailed, and itslastErrornames the app-level URL; - each is audited as
shopify_privacy_delivery_refused(outcomeblocked, 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_requestcollects 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 asshopify_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/redactrewrites 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 asshopify_customer_redacted, and scheduled rather than immediate so a merchant's own grace period applies.shop/redactpurges 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 asshopify_shop_redacted(orshopify_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:
| Concept | Metafield | Owner | Storefront |
|---|---|---|---|
v1:commerce:productContent | memql.description, .summary, .keywords, .blocks | Product | public_read |
v1:commerce:customerNote | memql.note | Customer | private |
v1:commerce:companyLocationNote | memql.note | Company location | private |
v1:commerce:creditLimit | memql.creditLimit, .creditLimitStatus | Company location | private |
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:
- Bump
apiVersionincmd/shopifyschema/allowlist.yaml. go run ./cmd/shopifyschema --record-- re-records the introspection fixture from Shopify's public schema proxy. No token is needed.- Review the diff: new fields, removed fields, changed enums, renamed types. This is the whole point of the exercise.
make sdk-gen && make arch-model.- Update every store row's
apiVersionand 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_contractsis 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
Checkoutobject and checkout runs only on Shopify.AbandonedCheckoutand thecheckouts/*/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.
| Concept | What it is for | What Shopify holds of it |
|---|---|---|
quote | Priced, time-boxed offer with locked prices | Nothing until accepted, then a draft order |
approvalChain | Who approves, in order, above what value | Nothing. Shopify's order-review Functions run at checkout, after the buyer has been quoted |
creditLimit | A company location's limit and outstanding balance | A memql.creditLimit metafield a checkout validation Function reads |
salesRep / territory | Who covers which companies | Nothing. Staff members are Plus-only and a rep is not a staff member |
reorderList | What a buyer buys again | Nothing |
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, andnot grantedfor 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 --
refusedis 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;lastRefusedAtis the latest;appLevelUrlis 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: truemeans the walk stopped at its bound andrefusedis 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:
- 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.
- 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.
- Subscriptions. Run
shopifyEnsureSubscriptions(). The store's health should showcreatedequal to the desired count andfailedempty. Cross-check in the Shopify admin that the subscriptions point athttps://api.<domain>/inbound/shopify-<storeId>at the pinned version. - Backfill. Run
datasyncStartBackfill(connector: "shopify", conceptId: "v1:shopify:product")to completion, thenv1:shopify:order. Confirm the row counts match the store's own product and order counts. - Webhook round trip. Change a product title in the Shopify admin. Within
seconds the mirror row's
titleshould change and itssyncedAtshould 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. - Metafield push. Write a
productContentrow for that product and wait for the drain. The metafield should appear on the product in the Shopify admin under thememqlnamespace, and its outbox entry should bedelivered. Check the same value through the Storefront API before calling public copy live;productContent.statusis not delivery evidence. - 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. - Serving. If the storefront is served BY this cluster as a
shopify_storefrontdeployable, 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_ordersto 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.