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

# Shopify for Developers

> The developer reference for Hiveku's Shopify integration: the bring-your-own-app OAuth model, token encryption at rest, what persists vs. what stays in Shopify, webhook + order ingestion, GDPR compliance, and the reliability crons.

This is the reference page for how the Shopify integration works under the hood — the OAuth model, the data it stores (and deliberately doesn't), how webhooks are verified and routed, and the crons that keep a connection healthy. If you just want to connect a store, start with [Connecting Shopify](/integrations/shopify/connecting).

<Info>
  Everything here is grounded in the source: `src/lib/shopify/` in `hiveku_builder`, the webhook route at `src/app/api/webhooks/shopify/route.ts`, and the cron registry in `hiveku_cron_worker/src/registry.ts`. Env vars and routes are quoted verbatim.
</Info>

## Design principles at a glance

<CardGroup cols={2}>
  <Card title="Bring your own Shopify app" icon="plug">
    Each account registers its own Shopify app. The `client_id` + `client_secret` live per-account in `oauth_apps` (`provider='shopify'`), not in a shared platform env var.
  </Card>

  <Card title="Tokens encrypted at rest" icon="lock">
    Admin and Storefront access tokens are AES-256-GCM enveloped with `SHOPIFY_TOKEN_ENCRYPTION_KEY`. They never appear in API responses or logs.
  </Card>

  <Card title="Shopify stays the source of truth" icon="shopify">
    Products, inventory, payments, taxes, and fulfillment stay in Shopify and render live. Hiveku persists only the few rows it needs to operate.
  </Card>

  <Card title="Self-healing by cron" icon="clock-rotate-left">
    A set of reliability sweeps recover missed orders, dead webhooks, scope drift, and stale endpoint URLs instead of failing silently.
  </Card>
</CardGroup>

## The bring-your-own-app OAuth model

Hiveku does not ship a single platform-wide Shopify app. Every account registers its **own** Shopify app (via the Shopify Partner dashboard) and pastes its credentials into Hiveku. Those credentials live per-account in the `oauth_apps` table with `provider='shopify'`, and every store connected on that account reuses the same app.

| Field           | Where it lives                      | Storage                                  |
| --------------- | ----------------------------------- | ---------------------------------------- |
| `client_id`     | `oauth_apps` (`provider='shopify'`) | Plain                                    |
| `client_secret` | `oauth_apps` (`provider='shopify'`) | **Plaintext — load-bearing** (see below) |

The connect dialog at `/dashboard/commerce/settings/shopify` can register the app inline. From there, OAuth follows Shopify's standard authorize flow:

* The authorize URL is `https://{shop}.myshopify.com/admin/oauth/authorize`, built by `buildAuthorizeUrl` with the app's `client_id`, the comma-separated scopes, the redirect URI, and a `state` value.
* Hiveku requests an **offline** token — `buildAuthorizeUrl` intentionally omits `grant_options[]=per-user`, so the resulting Admin API token is long-lived ("until the app is uninstalled"), which is what server-to-server Admin API calls need.
* The Shopify API version is pinned to `2025-04` (`SHOPIFY_API_VERSION` in `src/lib/shopify/auth.ts`).

<Warning>
  **`oauth_apps.client_secret` MUST stay plaintext in the database.** Shopify signs shop-scoped webhooks (the ones created via the OAuth flow) with the OAuth app's raw `client_secret`, and the webhook receiver reads that column directly to verify each delivery. If a future migration encrypts this column, HMAC verification breaks silently and every webhook delivery returns `401`. This invariant is documented directly in `src/app/api/webhooks/shopify/route.ts`.
</Warning>

### Two distinct HMACs

`src/lib/shopify/auth.ts` deliberately keeps two separate HMAC verifiers, because mixing them up would silently pass signature verification on the wrong message shape:

| Verifier             | Verifies                                                 | Signed over                                                                      | Encoding                |
| -------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------- |
| `verifyCallbackHmac` | The `?hmac=...` on the OAuth callback URL                | The sorted query params (excluding `hmac` and `signature`), joined `key=value&…` | HMAC-SHA256, **hex**    |
| `verifyWebhookHmac`  | The `X-Shopify-Hmac-SHA256` header on a webhook delivery | The **raw request body**, byte-verbatim                                          | HMAC-SHA256, **base64** |

Both compare with `timingSafeEqual`, and both are keyed by the OAuth app's `client_secret` (for shop-scoped webhooks created via OAuth, the webhook secret *equals* `client_secret`).

## Token encryption at rest

Access tokens are wrapped with a Shopify-scoped envelope so a leak of the Shopify key can't compromise other integrations (for example voice SIP passwords, which use their own key).

<Steps>
  <Step title="Encryption">
    `src/lib/shopify/crypto.ts` builds `encryptShopifyToken` / `decryptShopifyToken` via the generic envelope helper (`makeEnvelopeCrypto`). The stored format is an AES-256-GCM envelope (`v1:iv:ct:tag`). The Admin token lands in `shopify_connections.admin_access_token_enc`, the Storefront token in `storefront_access_token_enc`, and (when present) a per-shop webhook signing secret in `webhook_secret_enc`.
  </Step>

  <Step title="Key configuration">
    The primary key is `SHOPIFY_TOKEN_ENCRYPTION_KEY` (a 32-byte base64 value, e.g. `openssl rand -base64 32`). Lose it and every connection's tokens are unrecoverable.
  </Step>

  <Step title="Rotation">
    Set `SHOPIFY_TOKEN_ENCRYPTION_KEY_PREVIOUS` to the old key and `SHOPIFY_TOKEN_ENCRYPTION_KEY` to the new one. Decryption falls back to the previous key automatically, and each token re-wraps with the new key on its next write. Remove `_PREVIOUS` once everything has been re-saved.
  </Step>
</Steps>

## Data model: what persists vs. what stays in Shopify

The integration is intentionally light on stored state. Shopify remains the source of truth; Hiveku persists only the rows it needs to route webhooks, run automation, and give you a CRM view of buyers.

### Rows Hiveku persists

| Table                           | What it holds                                                                                                                                                                                                                                                                                                     | Notes                                                                                                                  |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `shopify_connections`           | One row per connected store: encrypted Admin/Storefront tokens, granted `scope`, `shop_domain`/`shop_id`/`shop_name`, `purpose` (`account_default` \| `project_override`), lifecycle timestamps (`installed_at`, `disconnected_at`, `last_synced_at`), the site `revalidation_token`, and Customer Account fields | `shop_domain` is `@unique` and used to route inbound webhooks                                                          |
| `project_shopify_settings`      | Per-project connection binding: `override_mode` (`inherit` \| `override` \| `disabled`), the chosen `shopify_connection_id`, and the pinned `storefront_api_version`                                                                                                                                              | `project_id` is `@unique` — lets an agency point individual projects at different stores                               |
| `shopify_orders_log`            | Lightweight per-order observability: `order_id`, `order_number`, `total_amount_cents`, `currency`, `customer_email`, the `raw_payload`, and a `crm_contact_id` backlink                                                                                                                                           | Keyed `@@unique([shopify_connection_id, order_id])` so ingestion is idempotent. Full order data still lives in Shopify |
| `shopify_compliance_requests`   | The mandatory GDPR webhook log: `topic`, `shop_domain`, `customer_id`, `customer_email`, `raw_payload`, `fulfilled_at`                                                                                                                                                                                            | Required for Shopify App Store compliance                                                                              |
| `shopify_webhook_subscriptions` | One row per registered topic per store: `topic`, `endpoint_url`, `api_version`, `last_received_at`, `is_active`                                                                                                                                                                                                   | Used to reconcile on disconnect and detect missed deliveries                                                           |

Orders also fan into the CRM: `crm_contacts` gains rollup columns (`shopify_total_spent_cents`, `shopify_order_count`, `shopify_first_order_at`/`_id`, `shopify_last_order_at`) and a `crm_activities` note per order.

### What stays in Shopify (never mirrored)

Products, collections, inventory, the full order record, payments, taxes, shipping, fulfillment, and customer payment methods (PCI) all stay in Shopify and are queried live at render time. Hiveku deliberately does not copy them into its database — that avoids the classic sync-drift problem and keeps your Shopify admin authoritative.

## Webhook handling

All webhooks from every connected store land on a single endpoint: **`POST /api/webhooks/shopify`**.

<Steps>
  <Step title="Read the raw body">
    The handler reads `request.text()` — HMAC requires byte-verbatim bytes, and a `JSON.parse` → `JSON.stringify` round-trip does not preserve them.
  </Step>

  <Step title="Look up the connection">
    It finds the `shopify_connections` row by the `X-Shopify-Shop-Domain` header. An unknown shop returns `404` (Shopify treats 4xx as no-retry — correct, we don't recognize it).
  </Step>

  <Step title="Verify the signature">
    `verifyWebhookHmac` checks the base64 `X-Shopify-Hmac-SHA256` header against the connection's OAuth-app `client_secret`. A mismatch returns `401`.
  </Step>

  <Step title="Deduplicate">
    A 10-minute in-memory set keyed by `connection.id` + `X-Shopify-Webhook-Id` drops duplicates (Shopify retries a delivery up to 19× over 48h). Dedup runs **after** verification so an attacker can't seed the cache to suppress real events.
  </Step>

  <Step title="Route the topic and respond 200">
    Once verified, the handler routes by `X-Shopify-Topic` and returns `200`. Handler failures are logged but never 5xx — a 5xx just triggers Shopify retries that won't help a broken handler.
  </Step>
</Steps>

### Topic routing

| Topic(s)                                                                                        | Handling                                                                                                                                                |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app/uninstalled`                                                                               | Marks the connection `disconnected_at` and sets its webhook subscriptions inactive                                                                      |
| `products/create`, `products/update`, `products/delete`, `collections/update`                   | Fire-and-forget cache-tag invalidation to every deployed site (`dispatchShopifyCacheInvalidation` with tags `shopify-products` / `shopify-collections`) |
| `orders/create`, `orders/updated`                                                               | `ingestShopifyOrder` — log the order + run the CRM bridge                                                                                               |
| `customers/data_request`, `customers/redact`, `shop/redact`                                     | Insert a `shopify_compliance_requests` row (GDPR — see below)                                                                                           |
| `subscription_contracts/create` / `update`, `subscription_billing_attempts/success` / `failure` | Normalize the payload and fire subscription workflow triggers; seed an urgent inbox item on billing failure                                             |

<Note>
  On every accepted delivery, the handler best-effort stamps `last_received_at` on the matching `shopify_webhook_subscriptions` row so the health sweeps can spot a topic that has gone quiet.
</Note>

## Order ingestion and the CRM bridge

Order ingestion is a **single shared path** so the live webhook and the backfill cron produce byte-identical rows and rollups.

`ingestShopifyOrder` (`src/lib/shopify/order-ingest.ts`) is called by both the `orders/*` webhook handler and the `shopify-order-backfill` cron. Both hand it a REST-shaped order payload — which is exactly what the webhook body *and* Admin REST `orders.json` return. It:

1. Upserts a `shopify_orders_log` row keyed by `(shopify_connection_id, order_id)`.
2. Calls `linkOrderToCRM` to unify the buyer into the CRM.

<Accordion title="Under the hood: what linkOrderToCRM does">
  `src/lib/shopify/order-to-crm.ts` runs five steps. The rollup recompute (step 2) runs inside a single Prisma transaction so the first-order flag forwarded to the trigger reflects the customer's state *before* the increment; the contact upsert, activity, backlink, and trigger fan-out each run in their own error-isolated block:

  1. **Contact upsert** — `upsertContactByEmail` keyed by email, `source: 'shopify'`. Skipped for guest orders with no email.
  2. **Rollups** — `shopify_total_spent_cents`, `shopify_order_count`, and first/last order timestamps + ids. Incremented on `orders/create`; **recomputed** from the order log on `orders/updated` so refunds/adjustments stay correct.
  3. **Activity** — a `crm_activities` note row, deduped by `external_id` = `shopify:order:<orderId>` so re-deliveries don't duplicate the timeline entry.
  4. **Backlink** — sets `shopify_orders_log.crm_contact_id` on the ingested row.
  5. **Triggers** — fires `shopify.order.created` / `.updated` workflow triggers. These fire **even for guest orders without a contact**, so ops-style automations ("alert me on every order") still run.

  The CRM bridge swallows its own errors: a failed bridge must never 5xx the webhook (retry storm) or abort the backfill batch.
</Accordion>

The backfill cron always ingests with topic `orders/updated`, which drives the idempotent recompute path — so recovering a missed order never re-fires first-order automations.

## GDPR compliance webhooks

Shopify's three mandatory privacy topics are logged and then fulfilled asynchronously. Logging (never dropping) an inbound request is what keeps the app App-Store-compliant.

| Topic                    | Logged as                         | Fulfillment (by `shopify-compliance-sweep`)                          |
| ------------------------ | --------------------------------- | -------------------------------------------------------------------- |
| `customers/data_request` | `shopify_compliance_requests` row | Packages the customer's Hiveku-stored data into an urgent inbox item |
| `customers/redact`       | `shopify_compliance_requests` row | Anonymizes / disconnects Hiveku-stored PII                           |
| `shop/redact`            | `shopify_compliance_requests` row | Anonymizes / disconnects Hiveku-stored PII                           |

The sweep marks `fulfilled_at` when done and raises an urgent alert for anything left unfulfilled past 30 days (the Shopify App Store deadline).

## Reliability crons

The Shopify integration is backstopped by five sweeps registered in `hiveku_cron_worker/src/registry.ts`. They emit deduped `agent_ops_inbox_items` suggestions rather than failing silently.

| Cron `name`                     | Schedule (UTC)        | What it does                                                                                                                                                                                                  |
| ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shopify-order-backfill`        | `0 * * * *` (hourly)  | Recovers orders whose `orders/*` webhook was never delivered: polls Admin REST `orders.json` since the newest order on file (or last 48h) and runs each through `ingestShopifyOrder` (topic `orders/updated`) |
| `shopify-webhook-health`        | `0 * * * *` (hourly)  | Flags connections installed >72h that have received no webhook across any active subscription in 72h+ (or have none) — a likely dead/unregistered webhook. Detection only                                     |
| `shopify-compliance-sweep`      | `30 * * * *` (hourly) | Fulfills unfulfilled GDPR rows and backstops anything unfulfilled >30 days                                                                                                                                    |
| `shopify-scope-drift`           | `0 6 * * *` (daily)   | Compares each active connection's granted scope vs. `DEFAULT_SHOPIFY_SCOPES`; seeds a reconnect suggestion when Hiveku now requests scopes the store never granted (prevents silent 403s)                     |
| `shopify-webhook-url-reconcile` | `0 5 * * *` (daily)   | After a `NEXT_PUBLIC_APP_URL`/domain change, finds subscriptions whose `endpoint_url` no longer matches the current webhook URL and re-registers all standard topics at the new URL                           |

<Note>
  These crons live in the separate `hiveku_cron_worker` service, which fires HTTPS calls at the builder's `/api/cron/*` routes. Each call is authenticated with an `Authorization: Bearer ${CRON_SECRET}` header (the cron worker's default auth mode), so `CRON_SECRET` must match on both the worker and the builder.
</Note>

## Requested scopes

`DEFAULT_SHOPIFY_SCOPES` in `src/lib/shopify/auth.ts` is the single source of truth for the Admin API scopes Hiveku requests at connect time. The OAuth start route joins them comma-separated for the authorize URL, and `shopify-scope-drift` compares each store's *granted* scope against this *configured* set. The scopes configured on your Shopify app must match this list.

| Scope                                | Purpose                                            |
| ------------------------------------ | -------------------------------------------------- |
| `read_products` / `write_products`   | Read and manage products                           |
| `read_orders`                        | Read orders (drives order ingestion + CRM rollups) |
| `read_customers` / `write_customers` | Read and manage customers                          |
| `read_content` / `write_content`     | Read and manage store content                      |
| `read_themes` / `write_themes`       | Read and manage themes                             |

## Required environment variables

Set these on the builder web service (unless noted).

| Env var                                 | Required for                                                                                           | Notes                                                                                            |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `SHOPIFY_TOKEN_ENCRYPTION_KEY`          | Encrypting/decrypting Admin + Storefront tokens                                                        | 32-byte base64. **Lose it and every connection's tokens are unrecoverable.**                     |
| `SHOPIFY_TOKEN_ENCRYPTION_KEY_PREVIOUS` | Key rotation only                                                                                      | Old key; decrypt falls back to it, tokens re-wrap on next write. Remove once fully rotated       |
| `NEXT_PUBLIC_APP_URL`                   | The OAuth callback (`/api/oauth/shopify/callback`) and webhook endpoint (`/api/webhooks/shopify`) URLs | Change it and existing webhooks point at the dead URL until `shopify-webhook-url-reconcile` runs |
| `SHOPIFY_PARTNER_API_TOKEN`             | Dev-store provisioning only                                                                            | Without it, the provisioning path returns `501`                                                  |
| `SHOPIFY_PARTNER_ORG_ID`                | Dev-store provisioning only                                                                            | Numeric organization id (e.g. `"1234567"`)                                                       |
| `CRON_SECRET`                           | Authenticating the reliability crons                                                                   | Must match on both the cron worker and the builder                                               |

<Info>
  `SHOPIFY_PARTNER_API_TOKEN` + `SHOPIFY_PARTNER_ORG_ID` are only needed if you provision Shopify **development stores** from Hiveku. Everyday connect/webhook/order flows do not use them.
</Info>

## What's next

<CardGroup cols={2}>
  <Card title="Connecting Shopify" icon="plug" href="/integrations/shopify/connecting">
    The OAuth flow, account-default vs. project-override, and what gets stored.
  </Card>

  <Card title="Storefront scaffolding" icon="hammer" href="/integrations/shopify/storefront">
    What the AI agent generates, and the env vars wired in at deploy time.
  </Card>

  <Card title="Subscriptions" icon="rotate" href="/integrations/shopify/subscriptions">
    Subscription contracts, billing-attempt webhooks, and customer self-service.
  </Card>

  <Card title="Overview" icon="store" href="/integrations/shopify/overview">
    The architectural split: what Shopify owns vs. what Hiveku owns.
  </Card>
</CardGroup>
