Home/Docs/Shopify Install
Docs

Shopify Install

How brands install Trcker tracking on their Shopify store via Ezra. OAuth handshake, ScriptTag injection, orders/create webhook, click_id matching from landing_site / note_attributes.

Overview

For Shopify brands, Trcker installs as a standard Shopify app via OAuth — no code paste, no developer required. Once installed, the brand's orders/create webhook flows to Trcker, click_ids get extracted from order metadata, and conversions land in the standard QStash → conversion pipeline.

End-to-end: brand authorizes once on shopify.com → Trcker exchanges the code for a permanent access token → registers the orders/create webhook → every Shopify order is matched against the click that drove it.

This is one of the integrations Ezra orchestrates conversationally — the brand never sees the dashboard for install. See Ezra Integration for the wider picture.

OAuth flow

Step 1 — Ezra calls POST /api/public/install/shopify/start

Service-key authenticated. Returns a Shopify OAuth install URL with a signed state token.

Request: `json { "brandId": "uuid", "shopDomain": "bedrockfit.myshopify.com" } `

Response (200 OK): `json { "data": { "installUrl": "https://bedrockfit.myshopify.com/admin/oauth/authorize?client_id=...&scope=read_orders,read_script_tags,write_script_tags&redirect_uri=...&state=...", "shopDomain": "bedrockfit.myshopify.com", "brandId": "uuid", "brandSlug": "bedrock-fitness" } } `

shopDomain must be the canonical *.myshopify.com hostname. If the brand only knows their custom domain, the caller (Ezra) must resolve it first.

Step 2 — Brand taps the install URL

Brand authorizes the requested scopes on shopify.com. Shopify redirects to:

` GET /api/public/install/shopify/callback?code=...&hmac=...&shop=...&state=...×tamp=... `

The callback (no auth — Shopify-mediated):

  1. Verifies Shopify's HMAC over the query string (sorted, hmac removed)
  2. Verifies our state token (HMAC-signed, expires in 10 min, encodes brandId)
  3. Exchanges code for a permanent access token via Shopify's /admin/oauth/access_token
  4. Registers an orders/create webhook pointed at /api/webhooks/shopify/[brandSlug]
  5. AES-256-GCM encrypts the access token (using ENCRYPTION_KEY) and persists it in the integrations table with toolkit='shopify', status='active'
  6. Notifies Ezra via POST ${EZRA_WEBHOOK_URL}/install/complete so it can text the brand
  7. Renders an HTML success page

If any step fails, the callback returns an HTML error page (since this is hit by a browser, not API code) and notifies Ezra of the failure.

Step 3 — Ezra fires test conversion

After install, Ezra calls POST /api/internal/test-conversion to verify everything works end-to-end. See Ezra Integration.

Conversion tracking

Once the webhook is registered, every Shopify order POST'd to /api/webhooks/shopify/[brandSlug] is processed:

  1. HMAC verification. Shopify sends X-Shopify-Hmac-Sha256 header. We verify against SHOPIFY_CLIENT_SECRET over the raw request body. Reject 401 on mismatch.
  2. Brand resolution. Look up by slug. If gone → log + ack 200 (Shopify retries on non-2xx).
  3. Active integration check. If the brand's Shopify integration was disconnected but webhook still firing → log + ack 200.
  4. Click_id extraction in priority order:
  5. - note_attributes[trcker_click_id] — set by ScriptTag on cart-create (most reliable; Sprint 1.5)
  6. - landing_site URL ?click_id= parameter — preserved by Shopify on first page hit
  7. - referring_site URL ?click_id= — fallback if user landed elsewhere first
  8. Organic-order skip. No click_id → respond 200 with {status: "no_click_id"}. Not affiliate-driven.
  9. Defense-in-depth click validation. Shopper-controlled metadata is hostile until proven otherwise:
  10. - Reject malformed (non-UUID) click_ids before hitting the DB → {status: "click_id_malformed"}
  11. - Reject click_ids that don't exist in the clicks table → {status: "click_id_not_found"}
  12. - Reject click_ids belonging to a different brand → {status: "click_brand_mismatch"} (prevents the cross-brand attribution attack: attacker lands on brand A's store with brand B's click_id in the URL trying to hijack commission)
  13. Enqueue. Only after click is validated against the brand: build a ConversionJob with clickId, amount=order.total_price, txnId=shopify-${order.id}, currency, etc. Push to QStash with 5 retries. The existing conversion worker handles attribution + payout calculation + fraud check + partner postback.

Click_id passthrough — what brands need

For landing_site matching to work (the primary mechanism in Sprint 1):

  • The redirect that sends a customer to the Shopify storefront must append ?click_id=... to the landing URL
  • Trcker's /api/r/[brand]/[offer] redirect handler does this automatically — buildDestinationUrl writes both canonical and Safari-mirrored click IDs
  • Shopify automatically captures the URL of the first page the customer hits as landing_site on the resulting order

No code changes required on the brand's storefront. ScriptTag-based note_attributes is a Sprint 1.5 enhancement for cases where customers browse around before adding to cart (which can blow away landing_site).

Required env vars

Set in Vercel before merging:

| Var | Source | Purpose | |---|---|---| | SHOPIFY_CLIENT_ID | Shopify Partner Dashboard → app → Client credentials | OAuth client_id | | SHOPIFY_CLIENT_SECRET | Same | OAuth code exchange + webhook HMAC | | SHOPIFY_OAUTH_STATE_SECRET | Generate — 32+ chars random | Sign our own state tokens (falls back to SIGNED_URL_SECRET) | | EZRA_WEBHOOK_URL | Ezra's Fly URL | Where to POST install-complete callbacks | | ENCRYPTION_KEY | Already set | Reused for access-token encryption (existing dub_api_key_enc pattern) |

Shopify App Store status

For production launch, Trcker needs to be submitted to the Shopify App Store. Until then, brands install via the custom app route (works the same way technically; just not discoverable via Shopify's marketplace). Submit in parallel with onboarding the first 50 brands.

Operational notes

  • Token rotation: if a brand uninstalls Trcker from Shopify and reinstalls, our callback's upsert pattern updates the existing integrations row in place (status flips disconnectedactive, new token, same row id).
  • Webhook deletion on uninstall: subscribe to app/uninstalled and call deleteWebhook from src/lib/shopify/admin-api.ts. Sprint 1 doesn't ship this — currently disconnects are detected lazily when brand re-installs.
  • API version: 2025-04 (latest stable as of May 2026). Hardcoded in src/lib/shopify/admin-api.ts.
  • Shopify webhook timeout: 5s. We do all heavy work async via QStash; the webhook handler returns 200 within ~50ms.

Troubleshooting

| Symptom | Likely cause | Fix | |---|---|---| | Callback returns 400 "couldn't verify the install request came from Shopify" | SHOPIFY_CLIENT_SECRET mismatch | Check Vercel env matches Shopify Partner Dashboard | | Callback returns 400 "install link expired" | State token >10 min old | Re-run install from Ezra | | Webhook returns 401 | HMAC mismatch — usually wrong SHOPIFY_CLIENT_SECRET | Same as above | | Orders firing but status=no_click_id for all of them | landing_site doesn't have ?click_id= | Check the redirect-to-Shopify URL — may need to verify buildDestinationUrl is writing it | | Test conversion succeeds but real orders don't track | Webhook may not be subscribed | Check Shopify Partner Dashboard → app → Webhooks |