Connect Shopify end to end
Orbit’s Shopify integration covers the full commerce loop: customers, checkouts, orders, and refunds arrive in real time via an HMAC-verified webhook receiver, with an hourly poll as a safety net, and the three Shopify-mandated GDPR compliance topics are handled on the same endpoint. This guide is the numbered checklist operators run when they wire a store up. The endpoint map it links back to is/api-reference/integrations — this page is the “do this, then this” walkthrough for the same surface.
The connect step is owner/admin only — it’s the only step that touches store credentials. Everything after OAuth (subscription registration, tenant routing, idempotency) is automatic.
What you’ll set up
- A Nango OAuth connection from Orbit to your Shopify store.
- Automatic webhook registration on connect — Orbit subscribes the store to the canonical operational topic set; re-connects are idempotent.
- Real-time inbound events at the HMAC-verified receiver — contact upserts, order/refund event records, and checkout abandonments.
- A cart-recovery flow that reacts to Checkout abandonments, plus a post-purchase survey and a 30-day buyers segment.
- GDPR compliance topics handled on the same receiver — data-request, customer-redact, and shop-redact.
Step 1 — What syncs
On connect, Orbit registers the store for this operational topic set:
Separately, the receiver implements the three GDPR compliance topics every Public Shopify app must answer. Those are configured at the Partner-app level, not per-store, so they’re deliberately excluded from the per-store subscription set in Step 3 — see GDPR.
The integration catalog row also lists the poll-backed sync groups
customers, orders, products, and abandoned-checkouts, which is what the hourly fallback refreshes when a webhook delivery is missed.
Step 2 — Connect via OAuth (admin only)
Initiate the connect flow from the Integrations API. Owner or admin role required — this is the only step that touches provider credentials, so Orbit restricts it to admins.auth_url.
Redirect the browser to
auth_url. After OAuth consent in Shopify, the store is linked and the case "auth" connect-complete hook fires server-side — that’s what triggers Step 3. Check GET /api/v1/integrations for the store’s row.integration_id: "shopify". The connection object is the same either way, and the auth-complete hook runs on it identically.
Step 3 — Automatic subscription setup (idempotent on connect)
When the OAuth consent completes, Nango fires anauth event to Orbit’s connect-complete hook. That hook calls the subscription-setup helper, which:
-
Builds the public receiver URL —
POST /api/v1/integrations/webhooks/shopify. No query param is needed; the receiver resolves the tenant from theX-Shopify-Shop-Domainheader after HMAC verification. -
Calls the Nango
register-webhooksaction with the canonical operational topic set. The action lists the store’s existing subscriptions first, so re-running it is idempotent — topics already present land inexisting, not duplicated. -
Persists
shopify_webhooks_address,shopify_webhooks_topics, andshopify_webhooks_setup_atonto the tenant connection record (nango_connection_state,provider_config_key = 'shopify') for operator-side audit.
Shopify tenant setup complete carries created / existing / failed counts; a non-empty failed array means Shopify rejected specific topics — re-run the registration from the dashboard to retry those (see Failure modes).
The canonical topic set and the receiver’s dispatcher stay in lockstep. GDPR topics are intentionally excluded from the subscription set — Shopify registers them at the Partner-app level, not per-store via the Admin API.
Step 4 — The HMAC-verified receiver
Every inbound Shopify event hitsPOST /api/v1/integrations/webhooks/shopify. The receiver:
- Verifies the signature fail-closed.
X-Shopify-Hmac-Sha256carries the base64 HMAC-SHA256 of the raw body, keyed with the app shared secret (DEVOTEL_SHOPIFY_APP_SECRET). The comparison is constant-time. When the secret is unset the endpoint returns503— the integration is disabled, never silently accepting unsigned requests. - Resolves the tenant from
X-Shopify-Shop-Domain, not from a query param. The HMAC is the sole auth; the shop-domain header is only used for tenant attribution. The receiver first probes theshopify_shop_ownersreverse map (shop_domain → tenant_id) for an O(1) lookup; on a miss it falls back to a batched fan-out scan of tenant connection records, then stamps the reverse map so the next webhook from the same shop probes O(1). A shop we don’t (or no longer) integrate with returns200with no dispatch so Shopify doesn’t retry indefinitely. - Guarantees idempotency.
X-Shopify-Webhook-Idis unique per delivery. The receiver persists the event row keyed(shop_domain, webhook_id)withON CONFLICT DO NOTHING; Shopify retries no-op as duplicates. Without the header (rare) it falls back to a hash of the raw body. - Returns within Shopify’s 5s budget. All dispatchers are inline and bounded — heavy downstream work (campaign fan-out) is enqueued, not awaited on the request path.
To confirm the signature path is live: a forged POST with a wrong HMAC returns
401 INVALID_SIGNATURE; a request with the secret unset returns 503 SHOPIFY_INTEGRATION_DISABLED. Both are the expected fail-closed behavior, not errors.Step 5 — What lands in Orbit
Per topic, after signature verification and tenant resolution:customers/create → upsert an Orbit Contact via the cross-provider upserter. Projected fields: email, phone, first/last name, plus attributes shopify_state, shopify_shop_domain, and shopify_source (set to "webhook:customers/create"). Marketing state flows through so campaign filters can target it.
checkouts/create → the abandoned-cart seed. Shopify fires this the moment a buyer enters checkout — long before orders/create. The receiver:
- Upserts a Contact. When
customer.idis present it matches thecustomers/createpath (same Shopify id), so subsequent webhooks merge cleanly. For a guest checkout with only an email or phone, it upserts withexternalId = "checkout:<checkout_id>"— a stable handle so the cart-recovery drip can target the same contact row, and it merges by email/phone when the buyer later logs in. With no addressable identity at all (no customer id, no email, no phone) it skips the upsert but still emits the event for volume auditing. - Emits the structured event with
event.type = "cart.created", pluscontact_id,external_checkout_id,recovery_url(from Shopify’sabandoned_checkout_url),total_price,currency, and test-mode. The persistedshopify_inbound_eventsrow is the replayable source of truth; the structured emit is the signal your flow subscribes to.
orders/create / orders/paid / orders/fulfilled / refunds/create → persisted as order event rows (placed / paid / shipped / refunded). They live in the tenant event feed for CDP segments and downstream triggers.
app/uninstalled → the connection is marked inactive (the shop disconnected the app) and the shop-owner reverse map is cleared so a re-install from a different tenant doesn’t route to the previous owner.
Step 6 — Use it: cart recovery, post-purchase survey, buyer segment
The normalized signals from Step 5 are what you build on. Three patterns that ship out of these events: Cart-abandonment WhatsApp recovery. Everycheckouts/create emits event.type = "cart.created" against the contact row. Trigger a flow on that event: a short delay (give the buyer a chance to finish), a condition on whether the checkout closed (an orders/create with the same checkout token exits the branch early), then a WhatsApp message carrying the recovery URL, cart total, and currency. The guest-checkout upsert in Step 5 is what makes the drip reachable for buyers who never logged in — the recovery message addresses the same contact the upsert created. This is the flows-recipes order/updates pattern, now with the concrete Shopify trigger and payload field names. For the full flow-definition shape, see Five flow recipes and substitute the cart.created trigger plus the checkout fields (recovery_url, total_price, currency).
Post-purchase survey trigger. orders/fulfilled (shipped) is a clean survey fire point: trigger a satisfaction survey a fixed delay after the order ships, on the contact row the order upsert created. Because orders/create/paid/fulfilled all share the customer id, they land on the same contact, so the survey addresses the right person.
Segment: “placed an order in the last 30 days.” The persisted order event rows are the segment source. Build a CDP segment on customers with an orders/create event row in the trailing 30 days — that drives the “recent buyers” audience for a win-back or repeat-purchase campaign.
Step 7 — Failure modes
The integration runs two inbound paths, and they cover each other’s failure modes:- Webhook path (real-time) — the HMAC receiver in Step 4. This is the primary, low-latency source.
- Hourly poll (safety net) — a scheduled sync pulls
customers,orders,products, andabandoned-checkoutsvia Nango every hour. If a webhook delivery is missed, dropped, or the registration partially failed, the poll keeps contact/order freshness within an hour.
failed topic set, when the integration status reads partial, or when the store’s webhooks were removed outside Orbit (e.g. a Partner-side app re-install). Re-running the connect flow re-invokes the idempotent register-webhooks action, so it reconciles rather than duplicates. The poll catches up regardless; re-registration restores the real-time path.
Subscription state to audit — read metadata.shopify_webhooks_setup_at and metadata.shopify_webhooks_topics on the tenant connection row for what was registered and when. app/uninstalled sets the connection inactive; reconnect or re-register to restore.
GDPR — compliance topics
Three topics are mandatory for a Public Shopify app (Shopify rejects App-Store submission if any return non-200 in their harness). They are configured at the Partner-app level, not per-store, so they aren’t in the per-store subscription set — but the same receiver handles them:
All three return 200 regardless of internal outcome — GDPR webhooks for unknown or previously-disconnected shops are accepted without dispatch so Shopify doesn’t retry a terminal request.
Troubleshooting
Related reading
- Integrations API reference — endpoint map for the connect/status/disconnect surface.
- Five flow recipes — flow-definition shapes the cart-recovery trigger plugs into.
- HubSpot & Salesforce integration — the same OAuth-connect + webhook-receiver model applied to CRMs.