Skip to main content
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.
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.

Design principles at a glance

Bring your own Shopify app

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.

Tokens encrypted at rest

Admin and Storefront access tokens are AES-256-GCM enveloped with SHOPIFY_TOKEN_ENCRYPTION_KEY. They never appear in API responses or logs.

Shopify stays the source of truth

Products, inventory, payments, taxes, and fulfillment stay in Shopify and render live. Hiveku persists only the few rows it needs to operate.

Self-healing by cron

A set of reliability sweeps recover missed orders, dead webhooks, scope drift, and stale endpoint URLs instead of failing silently.

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. 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).
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.

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: 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).
1

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.
2

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.
3

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.

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

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.
1

Read the raw body

The handler reads request.text() — HMAC requires byte-verbatim bytes, and a JSON.parseJSON.stringify round-trip does not preserve them.
2

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).
3

Verify the signature

verifyWebhookHmac checks the base64 X-Shopify-Hmac-SHA256 header against the connection’s OAuth-app client_secret. A mismatch returns 401.
4

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.
5

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.

Topic routing

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.

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.
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 upsertupsertContactByEmail keyed by email, source: 'shopify'. Skipped for guest orders with no email.
  2. Rollupsshopify_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.
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. 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.
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.

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.

Required environment variables

Set these on the builder web service (unless noted).
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.

What’s next

Connecting Shopify

The OAuth flow, account-default vs. project-override, and what gets stored.

Storefront scaffolding

What the AI agent generates, and the env vars wired in at deploy time.

Subscriptions

Subscription contracts, billing-attempt webhooks, and customer self-service.

Overview

The architectural split: what Shopify owns vs. what Hiveku owns.