LinkJoltDevelopers

Manage API Keys

← Back to LinkJolt.io

← All guides

Track Shopify app billing conversions

Wire app_subscriptions billing events to LinkJolt. Hosted install redirect, signed state, recurring commissions.

Intermediate

20 min read

Who this guide is for

Developers shipping apps on the Shopify App Store that bill via Shopify's app_subscriptions or app_purchases_one_time Billing API. If you sell physical products on a Shopify store, you want the Shopify store affiliate tracking guide instead.

What you'll get

  • One LinkJolt account covering every Shopify app you ship
  • Hosted affiliate landing pages at linkjolt.io/s/<handle>/<code>
  • Signed state token that survives Shopify's OAuth install flow
  • Recurring commission chain on every billing cycle
  • Test mode keys for safe wiring before going live

Architecture

Shopify's app_subscriptions/update webhook does not include the charge amount. Recipients must call the Admin GraphQL API with the per-shop access token to look up the price. That token only exists inside your app. So we use the same architecture Klaviyo and Recharge use on PartnerStack: your app catches its own billing webhook, fetches the amount, then posts a conversion to LinkJolt.

Flow:

  1. Affiliate clicks linkjolt.io/s/your-app-handle/AFF_CODE
  2. LinkJolt records the click, mints a signed state token, 302s to your Shopify install URL with ?state=<token> appended
  3. Shopify's OAuth preserves the state param all the way to your app's OAuth callback
  4. Your callback verifies the state via HMAC-SHA256, persists { shop, affiliateId, campaignId }
  5. Shopify fires app_subscriptions/update with status: ACTIVE
  6. Your handler fetches the charge amount via GraphQL, then POSTs to /api/v1/conversions

Step 1 - Create a campaign per app

In LinkJolt, create one campaign per Shopify app you sell. For each Shopify-app campaign, fill in two extra fields:

  • Shopify app handle - the slug from your App Store listing URL. For apps.shopify.com/seo-toolkit, the handle is seo-toolkit. Used by the hosted redirect to look up the campaign.
  • Shopify install URL - your app's OAuth authorize URL or the App Store listing URL. The hosted redirect appends ?state=<token>and 302s the user there.

Step 2 - Generate an Ultimate API key

Go to Settings → API keys. Generate a test key for development (lj_pk_test_...) and a live key for production (lj_pk_live_...). Store as environment variables in your app.

Test keys skip the monthly earnings limit, do not increment campaign revenue, and do not fire outbound webhooks. Safe to wire up production-shaped requests without polluting your dashboard.

Step 3 - Verify the state token in your OAuth callback

When Shopify redirects to your app's OAuth callback after a merchant approves the install, the state query param contains LinkJolt's signed token. Verify it before persisting the install:

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

const LINKJOLT_STATE_SECRET = process.env.LINKJOLT_STATE_SECRET; // shared with LinkJolt

function base64urlDecode(s) {
  const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
  return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64');
}

function verifyLinkjoltState(token, maxAgeSeconds = 60 * 60) {
  if (typeof token !== 'string') return null;
  const [body, sig] = token.split('.');
  if (!body || !sig) return null;

  const expected = createHmac('sha256', LINKJOLT_STATE_SECRET).update(body).digest();
  const expectedB64 = expected.toString('base64')
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

  const a = Buffer.from(sig);
  const b = Buffer.from(expectedB64);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;

  const payload = JSON.parse(base64urlDecode(body).toString('utf8'));
  if (Math.floor(Date.now() / 1000) - payload.iat > maxAgeSeconds) return null;
  return payload; // { affiliateId, campaignId, clickId?, iat }
}

// In your Shopify OAuth callback (e.g. /shopify/auth/callback):
app.get('/shopify/auth/callback', async (req, res) => {
  const { shop, code, state } = req.query;

  // Standard Shopify HMAC check, code-for-token exchange, etc.
  const accessToken = await exchangeCodeForAccessToken(shop, code);

  // Recover LinkJolt attribution.
  const lj = verifyLinkjoltState(state);

  await db.installs.create({
    shop,
    shopAccessToken: accessToken,
    affiliateId: lj?.affiliateId || null,
    campaignId: lj?.campaignId || null,
    installedAt: new Date(),
  });

  res.redirect(`https://${shop}/admin/apps/${YOUR_APP_HANDLE}`);
});

Note: organic installs (no affiliate referral) arrive with an empty or invalid state. verifyLinkjoltState returns null; you record the install with no affiliate attribution and proceed normally.

Step 4 - Subscribe to billing webhooks

In your Shopify Partner Dashboard for each app, register webhook subscriptions to your own backend. The relevant topics:

  • app_subscriptions/update - fires on PENDING → ACTIVE (new sale), ACTIVE → CANCELLED (uninstall), ACTIVE → FROZEN (failed payment), etc.
  • app_purchases_one_time/update - only if you sell one-time charges
  • app/uninstalled - optional, useful if you want to mark active subscriptions as cancelled in your own DB

Verify Shopify's HMAC signature on every webhook (header X-Shopify-Hmac-SHA256) using your app's Client Secret from the Partner Dashboard. This is per-app, not per-shop.

Step 5 - Forward billing events to LinkJolt

On every app_subscriptions/update with status: ACTIVE, look up the install attribution, fetch the charge amount via GraphQL, then POST a conversion:

// Helper: fetch the current subscription amount via Shopify Admin GraphQL.
async function fetchSubscriptionAmount(subscriptionGid, shop, accessToken) {
  const query = `
    query GetSub($id: ID!) {
      node(id: $id) {
        ... on AppSubscription {
          name
          status
          lineItems {
            plan {
              pricingDetails {
                ... on AppRecurringPricing {
                  price { amount currencyCode }
                }
              }
            }
          }
        }
      }
    }
  `;
  const resp = await fetch(`https://${shop}/admin/api/2025-01/graphql.json`, {
    method: 'POST',
    headers: {
      'X-Shopify-Access-Token': accessToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, variables: { id: subscriptionGid } }),
  });
  const json = await resp.json();
  const price = json.data?.node?.lineItems?.[0]?.plan?.pricingDetails?.price;
  if (!price) throw new Error('No price on subscription ' + subscriptionGid);
  return { amount: Number(price.amount), currency: price.currencyCode };
}

// The webhook handler.
app.post('/shopify/webhooks/app-subscriptions-update', verifyShopifyHmac, async (req, res) => {
  const sub = req.body.app_subscription;

  // Only fire conversions on ACTIVE. Other status changes (FROZEN, CANCELLED) can be
  // handled separately if you want to mark conversions as rejected.
  if (sub.status !== 'ACTIVE') return res.sendStatus(200);

  const install = await db.installs.findOne({ shop: sub.shop_domain });
  if (!install?.affiliateId) return res.sendStatus(200); // organic install, not affiliate-driven

  const { amount, currency } = await fetchSubscriptionAmount(
    sub.admin_graphql_api_id, install.shop, install.shopAccessToken
  );

  // First charge vs renewal? Look up an existing LinkJolt conversion on this sub.
  const existing = await db.linkjoltConversions.findOne({
    subscriptionId: sub.admin_graphql_api_id,
  });

  const yyyyMm = new Date().toISOString().slice(0, 7);
  const orderId = `${sub.admin_graphql_api_id}-${yyyyMm}`;

  const r = 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,
      orderId,                                              // dedup key, one per billing cycle
      conversionType: existing ? 'recurring' : 'one_time',
      parentConversionId: existing?.linkjoltConversionId,    // chain the renewal
    }),
  });

  if (r.ok) {
    const body = await r.json();
    if (!existing) {
      await db.linkjoltConversions.create({
        subscriptionId: sub.admin_graphql_api_id,
        linkjoltConversionId: body.data.id,
      });
    }
  }

  res.sendStatus(200);
});

Step 6 - Handle refunds, cancellations, and uninstalls

Shopify does not fire a dedicated refund event for app billing. Cancellations surface as a status change on app_subscriptions/update:

  • CANCELLED - merchant explicitly cancelled or uninstalled. If you want to claw back pending commissions, look up the LinkJolt conversion by subscriptionId and mark it rejected via the dashboard or Developer API.
  • FROZEN - payment failed, Shopify will retry. Usually safe to do nothing - if payment recovers, you get another ACTIVE event (which dedups on orderId).
  • DECLINED, EXPIRED - the merchant declined the install or let the approval window lapse. No conversion was fired, so no action needed.

Step 7 - Test end-to-end

  1. Visit https://www.linkjolt.io/s/<your-app-handle>/<test-affiliate-code> in incognito. Confirm the redirect 302s to your Shopify install URL with ?state=... appended.
  2. Install the app on a development store. Confirm your OAuth callback verifies the state and persists the affiliate ID.
  3. Approve a test subscription via the Shopify billing confirmation page. Confirm Shopify fires app_subscriptions/update to your webhook.
  4. Confirm your handler calls LinkJolt's POST /api/v1/conversions successfully (HTTP 201). The test conversion appears in your dashboard tagged is_test=true.
  5. Trigger a renewal (or fire a second app_subscriptions/update manually with a fresh orderId). Confirm the new conversion has conversionType=recurring and links to the parent.

Attribution boundaries

State-token attribution is deterministic when the affiliate link is used. Organic App Store browses where the visitor never touched your linkjolt.io/s/... link result in an unattributed install - your callback receives no state, you record the install with affiliateId: null, and no commission fires. This matches Shoffi and PartnerStack behaviour.

The state token has a default 1-hour expiry to prevent replay. If you want to allow slower install windows (App Store browsing + install can stretch over hours), pass a larger maxAgeSeconds to verifyLinkjoltState.