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.
Language support. Examples below are in Node.js, but the only LinkJolt-specific bit is the state token verify (~15 lines). Equivalent samples for Python, Ruby, Go, and PHP are tabbed on the verify code block. Everything else (Shopify webhook HMAC, Admin GraphQL calls, outbound HTTPS POST) is standard per-language using your existing Shopify SDK + HTTP client.
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.https://your-app.com/install. NOT theapps.shopify.com/your-app listing URL.Why not the App Store listing URL? Shopify strips all query parameters from apps.shopify.com/<handle> URLs before initiating the install OAuth flow. The ?state=<token> we append for attribution would silently die. This is a long-standing Shopify limitation with no workaround on the App Store side - confirmed in the Shopify Partner community. You must own the install entry point to capture the affiliate ID.
A tiny route on your app's backend that:
?shop=<myshopify-domain>&state=<linkjolt-token>state echoed backAffiliates promote a link in the shape linkjolt.io/s/your-app/AFFCODE. Our redirect appends ?shop=<auto>&state=<signed> and 302s to your endpoint. Note: shop is not auto-known on the LinkJolt side because Shopify only reveals the merchant's shop domain after they pick which store to install on. Most apps handle this by showing a shop-domain input form first, then forwarding to OAuth. Pattern:
// Step 1.5: GET /install -- the destination of LinkJolt's /s/... redirect.
// Receives ?state=<linkjolt-token> from our hosted landing.
app.get('/install', async (req, res) => {
const { shop, state } = req.query;
// Persist the LinkJolt state so we can recover it in the OAuth callback,
// even if Shopify drops it (some edge cases reported in community).
if (state) {
res.cookie('linkjolt_state', state, {
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days, matches standard affiliate window
httpOnly: true,
secure: true,
sameSite: 'lax', // 'lax' so it survives the cross-site redirect from Shopify
});
}
if (!shop) {
// No shop domain yet - show a "Which store do you want to install on?" form.
return res.send(renderShopInputForm({ state }));
}
// Build Shopify OAuth authorize URL. Forward the linkjolt token as state.
const params = new URLSearchParams({
client_id: process.env.SHOPIFY_API_KEY,
scope: 'read_orders,write_products', // your app's scopes
redirect_uri: 'https://your-app.com/auth/callback',
state: state || crypto.randomBytes(16).toString('hex'),
});
res.redirect(`https://${shop}/admin/oauth/authorize?${params}`);
});Shopify echoes the state param back verbatim to your redirect_uri callback (standard OAuth 2.0). If for any reason the state is missing from the callback, fall back to the cookie set above.
State token expiry. The state token is signed with a 1-hour default expiry to keep replay windows tight. In practice, an end-to-end install flow (visitor click → Shopify install confirmation dialog → OAuth callback) can take longer than that if the merchant browses the App Store, switches accounts, or gets approval from a store admin. So when you verify the token in your OAuth callback below, pass a longer maxAgeSeconds (e.g. 30 days) since the signature still prevents tampering and only the time window is relaxed.
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. Use 30-day window because the install flow
// may have started days ago; signature still prevents tampering.
const lj = verifyLinkjoltState(state, 30 * 24 * 60 * 60);
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.