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):
- Verifies Shopify's HMAC over the query string (sorted,
hmacremoved) - Verifies our state token (HMAC-signed, expires in 10 min, encodes brandId)
- Exchanges
codefor a permanent access token via Shopify's/admin/oauth/access_token - Registers an
orders/createwebhook pointed at/api/webhooks/shopify/[brandSlug] - AES-256-GCM encrypts the access token (using
ENCRYPTION_KEY) and persists it in theintegrationstable withtoolkit='shopify', status='active' - Notifies Ezra via
POST ${EZRA_WEBHOOK_URL}/install/completeso it can text the brand - 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:
- HMAC verification. Shopify sends
X-Shopify-Hmac-Sha256header. We verify againstSHOPIFY_CLIENT_SECRETover the raw request body. Reject 401 on mismatch. - Brand resolution. Look up by slug. If gone → log + ack 200 (Shopify retries on non-2xx).
- Active integration check. If the brand's Shopify integration was disconnected but webhook still firing → log + ack 200.
- Click_id extraction in priority order:
- -
note_attributes[trcker_click_id]— set by ScriptTag on cart-create (most reliable; Sprint 1.5) - -
landing_siteURL?click_id=parameter — preserved by Shopify on first page hit - -
referring_siteURL?click_id=— fallback if user landed elsewhere first - Organic-order skip. No click_id → respond 200 with
{status: "no_click_id"}. Not affiliate-driven. - Defense-in-depth click validation. Shopper-controlled metadata is hostile until proven otherwise:
- - Reject malformed (non-UUID) click_ids before hitting the DB →
{status: "click_id_malformed"} - - Reject click_ids that don't exist in the
clickstable →{status: "click_id_not_found"} - - 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) - Enqueue. Only after click is validated against the brand: build a
ConversionJobwithclickId,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 —buildDestinationUrlwrites both canonical and Safari-mirrored click IDs - Shopify automatically captures the URL of the first page the customer hits as
landing_siteon 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
integrationsrow in place (status flipsdisconnected→active, new token, same row id). - Webhook deletion on uninstall: subscribe to
app/uninstalledand calldeleteWebhookfromsrc/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 insrc/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 |