> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect Shopify: events, cart recovery, and order records in one walkthrough

> Task-style operator walkthrough for the Shopify integration: connect via OAuth, let auto-registration subscribe the store to webhooks, understand the HMAC-verified receiver, build an abandoned-cart WhatsApp recovery flow, and handle GDPR and failure modes.

# 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`](/api-reference/integrations) — this page is the "do this, then this" walkthrough for the same surface.

<Note>
  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.
</Note>

## What you'll set up

1. A Nango OAuth connection from Orbit to your Shopify store.
2. **Automatic webhook registration** on connect — Orbit subscribes the store to the canonical operational topic set; re-connects are idempotent.
3. Real-time inbound events at the HMAC-verified receiver — contact upserts, order/refund event records, and checkout abandonments.
4. A cart-recovery flow that reacts to Checkout abandonments, plus a post-purchase survey and a 30-day buyers segment.
5. 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**:

| Topic              | What arrives in Orbit                                                             |
| ------------------ | --------------------------------------------------------------------------------- |
| `customers/create` | Contact upsert for the new shop customer                                          |
| `checkouts/create` | Checkout abandonment seed — contact upsert + the normalized `cart.created` signal |
| `orders/create`    | Order event record (placed)                                                       |
| `orders/paid`      | Order event record (paid)                                                         |
| `orders/fulfilled` | Order event record (shipped)                                                      |
| `refunds/create`   | Order event record (refunded)                                                     |
| `app/uninstalled`  | Connection marked inactive when the shop disconnects                              |

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](#gdpr-compliance).

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.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/integrations/connect \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "integration_id": "shopify" }'
```

The response returns a JSON envelope with an `auth_url`.

<Check>
  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.
</Check>

**Manual fallback** — if you can't run OAuth from this environment, use the Nango dashboard's "Connect" flow directly for `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 an `auth` event to Orbit's connect-complete hook. That hook calls the subscription-setup helper, which:

1. Builds the public receiver URL — `POST /api/v1/integrations/webhooks/shopify`. No query param is needed; the receiver resolves the tenant from the `X-Shopify-Shop-Domain` header after HMAC verification.

2. Calls the Nango `register-webhooks` action 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 in `existing`, not duplicated.

3. Persists `shopify_webhooks_address`, `shopify_webhooks_topics`, and `shopify_webhooks_setup_at` onto the tenant connection record (`nango_connection_state`, `provider_config_key = 'shopify'`) for operator-side audit.

The setup is **non-blocking by contract**: a registration failure never fails the connection. If it fails (transient Nango outage, missing store scope) the hourly poll remains as the safety net, and re-running the connect flow re-attempts registration. The log line `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](#step-7--failure-modes)).

<Info>
  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.
</Info>

***

## Step 4 — The HMAC-verified receiver

Every inbound Shopify event hits `POST /api/v1/integrations/webhooks/shopify`. The receiver:

* **Verifies the signature fail-closed.** `X-Shopify-Hmac-Sha256` carries 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 returns `503` — 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 the `shopify_shop_owners` reverse 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 returns `200` with no dispatch so Shopify doesn't retry indefinitely.
* **Guarantees idempotency.** `X-Shopify-Webhook-Id` is unique per delivery. The receiver persists the event row keyed `(shop_domain, webhook_id)` with `ON 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.

<Check>
  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.
</Check>

***

## 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:

1. **Upserts a Contact.** When `customer.id` is present it matches the `customers/create` path (same Shopify id), so subsequent webhooks merge cleanly. For a **guest checkout** with only an email or phone, it upserts with `externalId = "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.
2. **Emits the structured event** with `event.type = "cart.created"`, plus `contact_id`, `external_checkout_id`, `recovery_url` (from Shopify's `abandoned_checkout_url`), `total_price`, `currency`, and test-mode. The persisted `shopify_inbound_events` row 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.** Every `checkouts/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](/guides/flows-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`, and `abandoned-checkouts` via Nango every hour. If a webhook delivery is missed, dropped, or the registration partially failed, the poll keeps contact/order freshness within an hour.

When to **re-run registration from the dashboard**: re-run it when the auth-complete setup log shows a non-empty `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:

| Topic                    | What Orbit does                                                                                                                                                                 |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customers/data_request` | Accept and log the request; the export is fulfilled offline against the published privacy policy (30-day SLA).                                                                  |
| `customers/redact`       | Soft-delete the matched contact row (sets `deleted_at` by matching the Shopify customer id). The tenant-scoped data-retention sweeper completes the hard-redact asynchronously. |
| `shop/redact`            | Shopify tells us a shop's data must be deleted (fires 48h after `app/uninstalled`). Marks the connection inactive and enqueues a tenant-scoped hard-redact.                     |

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

| Symptom                                     | Cause                                         | Fix                                                                                                                                                                                                                                                 |
| ------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 INVALID_SIGNATURE` on inbound          | Wrong or rotated `DEVOTEL_SHOPIFY_APP_SECRET` | Confirm the app shared secret matches the Shopify App's client secret and correct it. A sustained spike with only warn logs looks identical to scanner noise — check the `shopify_webhook.hmac_invalid` counter / Sentry `kind: signature_invalid`. |
| `503 SHOPIFY_INTEGRATION_DISABLED`          | `DEVOTEL_SHOPIFY_APP_SECRET` unset            | Set the app shared secret in the API environment — the receiver is disabled until it's present.                                                                                                                                                     |
| `200 ... reason: UNKNOWN_SHOP`              | Webhook from a shop no longer integrated      | Expected — no tenant owns the shop. Reconnect the store; the reverse map re-stamps on the next webhook.                                                                                                                                             |
| `setup_at` missing, real-time events absent | Auth-complete registration failed             | Re-run the connect flow from the dashboard (registration is idempotent). The hourly poll keeps data fresh in the meantime.                                                                                                                          |
| Duplicate downstream events                 | `shopify_inbound_events` not yet migrated     | The receiver degrades to at-least-once while the table is absent; land the tenant migration to restore exactly-once via the `(shop_domain, webhook_id)` idempotency key.                                                                            |
| Stale connection (`inactive`)               | Shop uninstalled the app                      | Reconnect; `shop/redact` will enqueue hard-redact 48h after uninstall if the shop stays disconnected.                                                                                                                                               |

***

## Related reading

* [Integrations API reference](/api-reference/integrations) — endpoint map for the connect/status/disconnect surface.
* [Five flow recipes](/guides/flows-recipes) — flow-definition shapes the cart-recovery trigger plugs into.
* [HubSpot & Salesforce integration](/guides/hubspot-salesforce-integration) — the same OAuth-connect + webhook-receiver model applied to CRMs.
