You are helping integrate LinkJolt affiliate tracking for a Shopify app that bills via Shopify's app_subscriptions Billing API (NOT a Shopify store selling physical goods).

## Context
- Platform: Shopify App Billing (app_subscriptions, app_purchases_one_time)
- Attribution model: PartnerStack-style. The Shopify app catches its OWN billing webhook, then POSTs a conversion to LinkJolt's Developer API.
- LinkJolt does NOT subscribe to the Shopify webhook directly (Shopify payloads lack the charge amount; you must call the Admin GraphQL API with the per-shop access token, which lives in your app).

## Critical gotcha: DO NOT use the apps.shopify.com URL as your install endpoint

Shopify strips ALL query parameters from `apps.shopify.com/<handle>` URLs before initiating the OAuth install flow. If you set that as your install endpoint, the `?state=<token>` we append for affiliate attribution gets silently dropped and zero conversions will attribute. Confirmed Shopify limitation, no workaround on the App Store side.

You MUST own the install entry point. Build a small `/install` route on your app backend that captures the state, then forwards to Shopify OAuth.

## Setup overview

1. Build a custom install endpoint on your app backend (e.g. `https://your-app.com/install`). It reads `?shop=&state=` from the URL, persists the state in a cookie or DB, then redirects to Shopify's OAuth authorize URL with the same state echoed back.
2. In LinkJolt: create one campaign per Shopify app at `https://www.linkjolt.io/merchant/campaigns`. For each Shopify-app campaign, set:
   - **Shopify app handle**: the slug from your App Store listing URL (e.g. `apps.shopify.com/your-app` → handle is `your-app`). Used only by LinkJolt's lookup; does not appear in URLs visitors see.
   - **Your install endpoint URL**: the route you built in step 1, e.g. `https://your-app.com/install`. NOT an `apps.shopify.com` URL, since LinkJolt rejects those at save time.
3. Generate an **Ultimate-tier API key** at `https://www.linkjolt.io/merchant/api-keys`. Save it as `LINKJOLT_KEY` in your app's secrets.
4. Affiliates promote a hosted link: `https://www.linkjolt.io/s/{appHandle}/{affShortCode}`. LinkJolt sets a click cookie, signs a state token carrying the affiliate + campaign IDs, and 302s to your install endpoint with `?state=<token>` appended.
5. Your install endpoint verifies the state via HMAC-SHA256 (snippet below), stores attribution, then builds the Shopify OAuth authorize URL with the same state.
6. Shopify's OAuth handshake echoes `state` back to your callback (`redirect_uri`). Persist `{shop, affiliateId, campaignId}` keyed by the new install.
7. Subscribe to the `app_subscriptions/update` webhook in your Shopify Partner Dashboard. On `status: ACTIVE` events, look up the affiliate by shop_id from your install table, then POST to LinkJolt.

## Code: verifying the state token in your OAuth callback

Token format: `base64url(jsonPayload) + "." + base64url(hmacSha256(payload, secret))`. Works in any language with stdlib HMAC + base64. Algorithm:

1. Split token on `.`
2. HMAC-SHA256 the body with your shared secret
3. base64url-encode the digest (no padding)
4. Timing-safe compare with the provided signature
5. base64url-decode the body, parse JSON
6. Check `iat` is within your max-age window (recommend 1 hour)

Node reference implementation below. Equivalent samples for Python, Ruby, Go, and PHP live in our public guide at `https://www.linkjolt.io/developers/guides/track-shopify-app-billing` (tabbed code block).

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyLinkjoltState(token, secret) {
  const [body, sig] = (token || '').split('.');
  if (!body || !sig) return null;
  const expected = base64urlEncode(createHmac('sha256', secret).update(body).digest());
  const a = Buffer.from(sig); const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
  if (Math.floor(Date.now() / 1000) - payload.iat > 60 * 60) return null;
  return payload; // { affiliateId, campaignId, clickId?, iat }
}
```

LinkJolt's signing secret for your account is exposed in your dashboard at `https://www.linkjolt.io/merchant/api-keys`. The unsigned JSON payload contains `{ affiliateId, campaignId, clickId?, iat }`.

## Code: firing conversions from your Shopify billing webhook

```javascript
app.post('/webhooks/app-subscriptions-update', verifyShopifyHmac, async (req, res) => {
  const sub = req.body.app_subscription;
  if (sub.status !== 'ACTIVE') return res.sendStatus(200);

  // Look up the install you persisted during OAuth callback.
  const install = await db.installs.findOne({ shopGid: sub.admin_graphql_api_shop_id });
  if (!install?.affiliateId) return res.sendStatus(200); // organic install, not affiliate-driven

  // Shopify webhook payload has no amount, so call GraphQL to get it.
  const { amount, currency } = await fetchSubscriptionAmount(sub.admin_graphql_api_id, install.shopAccessToken);

  // Is this the first charge on this subscription, or a renewal?
  const existing = await db.linkjoltConversions.findOne({ subscriptionId: sub.admin_graphql_api_id });

  await fetch('https://www.linkjolt.io/api/v1/conversions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LINKJOLT_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      campaignId: install.campaignId,
      affiliateId: install.affiliateId,
      amount,
      currency,                       // ISO-4217 code, e.g. 'USD'
      // dedup key: subscriptionId + YYYY-MM. One row per billing cycle.
      orderId: `${sub.admin_graphql_api_id}-${new Date().toISOString().slice(0,7)}`,
      conversionType: existing ? 'recurring' : 'one_time',
      parentConversionId: existing?.linkjoltConversionId,
    }),
  });

  res.sendStatus(200);
});
```

## What to subscribe to in your Shopify Partner Dashboard

- `app_subscriptions/update`: fires on PENDING → ACTIVE (new charge), ACTIVE → CANCELLED (uninstall), ACTIVE → FROZEN (failed payment)
- `app_purchases_one_time/update`: only if you sell one-time charges; same pattern, no recurring chain needed
- `app/uninstalled`: optional; if you want to mark active subscriptions as cancelled in your own DB

## Test mode

Use a test API key (prefix `lj_pk_test_`) during integration. Test conversions skip the earnings limit, do not increment campaign revenue, and do not fire outbound webhooks. Flip to a live key (`lj_pk_live_`) once verified.

## Merchant ID for reference
YOUR_MERCHANT_ID
