Wire app_subscriptions billing events to LinkJolt. Hosted install redirect, signed state, recurring commissions.
20 min read
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.
linkjolt.io/s/<handle>/<code>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:
linkjolt.io/s/your-app-handle/AFF_CODE?state=<token> appended{ shop, affiliateId, campaignId }app_subscriptions/update with status: ACTIVE/api/v1/conversionsIn LinkJolt, create one campaign per Shopify app you sell. For each Shopify-app campaign, fill in two extra fields:
apps.shopify.com/seo-toolkit, the handle is seo-toolkit. Used by the hosted redirect to look up the campaign.?state=<token>and 302s the user there.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.
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.
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 chargesapp/uninstalled - optional, useful if you want to mark active subscriptions as cancelled in your own DBVerify 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.
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);
});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.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.app_subscriptions/update to your webhook.POST /api/v1/conversions successfully (HTTP 201). The test conversion appears in your dashboard tagged is_test=true.app_subscriptions/update manually with a fresh orderId). Confirm the new conversion has conversionType=recurring and links to the parent.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.