@the-meridian/sdk
v2.1.1
Published
Meridian SDK for Shopify embedded apps — entitlements, billing, plan/account UI, and usage tracking against the Meridian API.
Readme
@the-meridian/sdk
The runtime SDK for adding Meridian billing and entitlements to a Shopify embedded app. Mount one provider, gate features synchronously in render, render the pricing and account pages, and track usage — with a separate server entry for resource routes, extensions, and background jobs.
npm install @the-meridian/sdkreact and react-dom (>=18) are peer dependencies. The package ships ESM + CJS with types, and
two entry points:
@the-meridian/sdk— the React surface (browser).@the-meridian/sdk/server— the server surface (Node, no React).
How auth works
The SDK never sees your app's Meridian API key. The flow is:
- Server, once per install: exchange the shop for a per-shop
shopToken(createMeridianApp().mintShopToken(...), which calls Meridian'sidentify). Persist theshopTokenand hand it to the browser via your loader. The token lives an hour, and repeat mints for the same shop are served from an in-process cache, so calling this in a loader is cheap. UsemintShopTokenDetailed(...)when you persist the token yourself: it returns{ shopToken, shopTokenExpiresAt? }so you can decide when to re-mint. - Browser: mount
<MeridianProvider shopToken={shopToken}>. Every read and billing call goes straight to Meridian authenticated with theshopTokenbearer — Meridian resolves the Shopify Admin token it needs on its side, so there is no server bridge to build for subscribe/cancel/track.
Set these env vars on the server (read by createMeridianApp):
| Variable | Required | Purpose |
| ------------------- | -------- | ---------------------------------------------------- |
| MERIDIAN_APP_ID | yes | Your Meridian app UUID. |
| MERIDIAN_API_KEY | yes | Server-to-server API key (never sent to the browser).|
| MERIDIAN_DEV_DATABASE_ID | no | Set for you by meridian dev. Binds installs made by your locally-run app to your managed dev database; unset (deployed apps), the binding is cleared. |
Security model
The shopToken is a per-shop, short-lived bearer (it expires after ~1 hour; the provider
refreshes it transparently). It is intentionally safe to hand to the browser:
- Scope — it authorizes reads and billing writes (
subscribe,cancelSubscription,updateUsageCap,track) for one shop only. It is not yourMERIDIAN_API_KEY, cannot call the Shopify Admin API directly, and cannot reach any other shop's data. - Blast radius — because billing calls run client-side, any script on the page can use the token while it is valid; the impact is bounded to that one shop's billing.
For apps that want sensitive operations enforced on your own server instead of client-side, pass the
on* overrides — the provider then calls your handler instead of Meridian directly:
<MeridianProvider
shopToken={shopToken}
onSubscribe={(planId, returnUrl, interval) => myServer.subscribe(planId, returnUrl, interval)}
onCancelSubscription={() => myServer.cancel()}
onUpdateUsageCap={(input) => myServer.updateCap(input)}
onTrack={(input) => myServer.track(input)}
>
{/* app shell */}
</MeridianProvider>Point baseUrl only at an HTTPS Meridian host — the SDK warns if a non-HTTPS, non-localhost
baseUrl is configured, since the token would otherwise be sent over an insecure connection.
Server setup
Create one Meridian app entrypoint. It degrades gracefully when the env vars are absent
(configured === false, null clients, ungated gates) so the app still renders — and the same way
when Meridian itself fails. An invalid or rotated MERIDIAN_API_KEY, an outage or a network error
makes mintShopToken / serverClientForShop return null, gatesForShop return ungated gates and
registerWebhooks return an empty result, each logging one actionable console.error; nothing
throws into your loaders. (The low-level createMeridianServerClient / createMeridianAdminClient
still throw MeridianApiError — handle errors there.)
If Meridian rate-limits your app (HTTP 429), the first refused call degrades the same way and every
later Meridian call is skipped locally until the Retry-After deadline passes, with one
console.error naming it. For that window gates read as ungated and shop tokens as null for every
shop, which is the trade for never blocking a loader on a retry. MeridianApiError.retryAfterSeconds
carries the same deadline to code using the low-level clients.
// app/meridian.server.ts
import { createMeridianApp } from "@the-meridian/sdk/server";
export const meridian = createMeridianApp();In your Shopify afterAuth hook, wire Meridian with a single call. It fetches the
server-driven webhook topic set (managed billing topics plus any developer-routed topics
configured in your Meridian dashboard), registers the webhooks, and reports the authentication
so install/auth automations fire — you never declare webhook topics yourself:
hooks: {
afterAuth: async ({ session, request }) => {
await meridian.afterAuth(session, { request });
},
},afterAuth is best-effort and never throws: it returns a
{ registered, updated, errors, lifecycleNotified, referralCode, referralForwarded } summary, falls
back to the embedded managed topics if the webhook config is unreachable, and always unions those
managed topics in so billing webhooks are never dropped. The lower-level
meridian.registerWebhooks(...) and meridian.lifecycle?.notifyAuthenticated(...) remain available
if you need to call them separately.
Re-registering after you add a topic
afterAuth only runs when a shop has no session or its offline access token is expiring, so a topic
you add in the dashboard is not registered on an already-installed shop until it next
authenticates. If your app uses non-expiring offline tokens, that may not happen at all.
meridian.registerWebhooks(...) reconciles the shop immediately — same topic set, no re-install:
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const result = await meridian.registerWebhooks({
shop: session.shop,
accessToken: session.accessToken,
});
return result; // { registered, updated, errors }
};It is idempotent (create-or-update per topic), so it is safe to call on every load of a settings page, or from a one-off route you hit after changing the topic set.
Passing request is optional and only matters if you run an affiliate program: it lets Meridian read
the referral capture cookie and attribute the install (see
Affiliate referrals). The OAuth callback is the right place for it —
it's the last top-level request of the install, before the app is framed by the admin. Without it,
the single-argument call keeps working exactly as before.
In the loader for your embedded app shell, mint the shopToken and pass it down:
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const shopToken = await meridian.mintShopToken({ shop: session.shop });
return { shopToken };
};That costs one identify per shop per token lifetime, not one per request: the app instance caches
the handshake for exactly as long as Meridian says the token lives, then replaces it. A token that is
still live is never rotated, so a token you already persisted or handed to the browser keeps working
for its full hour.
The cache is per process, though, so on serverless or multi-instance hosting every cold start pays
for a fresh one. If you already persist Shopify sessions, persist the token with them and skip the
call while it is live:
const details = await meridian.mintShopTokenDetailed({ shop: session.shop });
// details: { shopToken: "mrd_shop_…", shopTokenExpiresAt?: "2026-08-27T12:00:00.000Z" }shopTokenExpiresAt is the expiry Meridian reported, verbatim, and is absent when the API reported
none. Re-mint a little before it, or whenever a call comes back with an expired-token error.
A payload that changes always reaches Meridian: identify upserts contacts, attributes affiliate
referrals and tags the dev database, so the cache only short-circuits a call identical to one it has
already sent for that shop.
Affiliate referrals (mref)
Meridian can attribute an install to the affiliate who referred it. Affiliate links carry the
affiliate's handle as ?mref=<handle>; pass that code to mintShopToken (or identify) and
Meridian credits the install and its commissions to that affiliate:
const shopToken = await meridian.mintShopToken({
shop: session.shop,
mref, // the code captured from the tracked link
});| Field | Type | Notes |
| --------------- | --------- | --------------------------------------------------------- |
| shop | string | The shop domain (*.myshopify.com). |
| name | string? | Display name for the shop's contact. |
| email | string? | Contact email. |
| mref | string? | Affiliate referral code (max 64 chars). |
| mrefClickedAt | string? | ISO-8601 instant the link was clicked (see below). |
Send mrefClickedAt whenever the capture knows it and the commission window tightens to the click,
so a merchant who was already installed and paying when they clicked earns the affiliate nothing on
the revenue that came before. captureReferral records it for you from 1.8.0 on; an app still
capturing with 1.7.x sends no timestamp and Meridian falls back to a flat 30-day window before the
attribution, exactly as it does today.
Attribution is first-write-wins per install: the first code Meridian sees for a shop is the one that sticks, and later codes are ignored. That makes the field safe to re-send on every identify — you don't need to track whether you've already sent it, and a merchant can't be re-attributed by a second affiliate link. An unknown or malformed code is ignored server-side; it never fails the identify call.
Capture happens on your pages, not on Shopify's. A link that points straight at your Shopify App Store listing never touches your app, so there is no
?mref=for you to read and nothing gets attributed. Affiliate links must land on a page you instrument (your marketing site or the app's own landing route), which captures the code and then sends the merchant on to install.
Capture the code on your landing page
captureReferral does the capture side for you. Call it in the loader of the page your tracked
links land on and put the returned headers on the response:
export const loader = async ({ request }) => {
const { setCookies } = await meridian.captureReferral(request);
const headers = new Headers();
for (const cookie of setCookies) headers.append("Set-Cookie", cookie);
return data({ … }, { headers });
};It reads ?mref= (or ?ref=) from the URL, returns the Set-Cookies that park the code and the
click timestamp for 30 days, and — when Shopify also put ?shop= on the URL — hands the pairing to
Meridian's server-side stash. That second path matters: the capture cookies are SameSite=None;
Secure so they survive the embedded admin iframe, but browsers that block third-party cookies
outright still drop them, and the stash covers those installs (dated from the capture, so the click
is known either way). Nothing here throws, so it is safe to await on a page that only renders.
| Result field | Meaning |
| ------------ | -------------------------------------------------------------------- |
| code | The captured code, or null when the link carried none. |
| clickedAt | ISO-8601 instant of the capture, or null when there was no code. |
| shop | The ?shop= domain, lower-cased, or null. |
| setCookies | Every Set-Cookie to send (code + timestamp), or []. Use this. |
| setCookie | The code cookie alone, or null — kept for compatibility. |
| headers | { "Set-Cookie": … } for the code cookie alone, or {}. |
| stashed | true when the code also reached Meridian's server-side stash. |
setCookie/headers carry the code cookie only — two Set-Cookie values can't live in one plain
object — so prefer setCookies unless you have a reason not to.
The individual helpers are exported too, if you'd rather wire the pieces yourself:
referralCodeFromUrl, referralFromCookie, referralCodeFromCookie, serializeReferralCookie,
serializeReferralClickedAtCookie, clearReferralCookies, shopFromUrl,
MERIDIAN_REFERRAL_COOKIE, MERIDIAN_REFERRAL_CLICKED_AT_COOKIE, REFERRAL_TTL_SECONDS.
Recommended: a redirect route
You don't need a real landing page. A route that captures and immediately redirects is enough, and it keeps the affiliate's link pointing at the App Store listing everyone expects:
export const loader = async ({ request }) => {
const { setCookies } = await meridian.captureReferral(request);
const headers = new Headers();
for (const cookie of setCookies) headers.append("Set-Cookie", cookie);
return redirect("https://apps.shopify.com/your-app", { headers });
};If a consent banner gates cookies on that route, pass its verdict through:
captureReferral(request, { consent }) — see Consent and GDPR.
Set the program's base link to https://your-app.com/go and affiliates share …/go?mref=<handle>.
The merchant sees one instant hop to the listing — no extra click, no interstitial — while the
cookie is set first-party on your own domain, which is exactly where it survives best.
Same host as the app. The capture cookie is host-only: the route that calls
captureReferralmust be served from the same host as your app's OAuth routes (app.your-app.com/go, notwww.your-app.com/go), or the callback and the embedded app never see the cookie and nothing gets attributed. If your marketing site lives on another host, have its install CTA relay?mref=in its link to a capture route on the app host —referralCodeFromUrlreads it off whatever URL it lands on.
Resolution is automatic
You do not need to plumb the captured code into mintShopToken yourself. Two paths close the
loop, both server-side:
afterAuth(session, { request })reads themeridian_mrefcookie (and themeridian_mref_atclick timestamp beside it) off the OAuth callback and forwards them. The callback is the reliable moment — it runs top-level, so the cookies are still readable there, unlike inside the embedded admin iframe.- The server-side stash. When an
identifyarrives with no code, Meridian looks up whatcaptureReferralstashed for that shop (30-day window) and attributes from it.
Both are idempotent: attribution is first-write-wins per install, so overlapping paths can't double-attribute or steal a merchant from the affiliate who referred them first.
The two carriers themselves resolve differently, which matters when a browser sees more than one tracked link:
| Carrier | Scope | On a second capture | Authoritative? |
| --- | --- | --- | --- |
| meridian_mref cookie (+ meridian_mref_at) | The browser | Last click wins | Yes, whenever it survives — it is what afterAuth forwards |
| Meridian's server-side stash | (app, shop) | First write wins | Fallback only, read when identify carries no code |
So the most recent click wins if the cookie made it, and the stash covers the browsers that refuse cookies at all. The stash can therefore hold an older code than the cookie — deliberately: it is keyed per shop and never overwritten.
Clear the cookies once attribution is through
The capture cookies belong to the browser, not the shop. An agency, a freelancer or your own developer installing your app for a second client from the same browser would attribute that install to the same affiliate for up to 30 days. Clear them as soon as the code has been forwarded:
import { clearReferralCookies } from "@the-meridian/sdk/server";
const result = await meridian.afterAuth(session, { request });
const headers = new Headers();
if (result.referralForwarded) {
for (const cookie of clearReferralCookies()) headers.append("Set-Cookie", cookie);
}clearReferralCookies() clears the code and its timestamp companion; the older
clearReferralCookie() (code only) is deprecated, since a leftover timestamp dates the next capture
from the previous click.
Consent and GDPR
What the capture stores in the browser is two first-party cookies on your own domain: the affiliate's
handle (meridian_mref) and when the link was clicked (meridian_mref_at), both for 30 days. There
is no visitor identifier, no profile, and no cross-site tracking — the cookies say which affiliate
sent this browser to your app, nothing about who is using it.
That is still a cookie a consent banner may have to gate. Pass { consent: false } and nothing is
written to the browser:
const capture = await meridian.captureReferral(request, {
consent: hasAcceptedCookies(request), // your banner's verdict
});With consent declined, only Meridian's server-side stash carries the code. It is keyed by shop domain
— which Shopify itself puts in the URL (?shop=) on install links — and holds no browser identifier,
so attribution still works for the normal install path. A capture that comes back with
cookieSkipped: true and stashed: false attributed nothing at all: there was no shop context and no
cookie, which is the honest outcome of declining on a bare marketing page.
Deleting an affiliate's data is a Meridian-side operation (the admin can void commissions and remove the affiliate); the SDK holds no state of its own beyond these two cookies.
Mount the provider
import { MeridianProvider } from "@the-meridian/sdk";
export default function App() {
const { shopToken } = useLoaderData<typeof loader>();
return shopToken ? (
<MeridianProvider shopToken={shopToken}>
{/* app shell */}
</MeridianProvider>
) : (
/* render without Meridian features */ null
);
}loading stays true until the first /me fetch resolves — render skeletons or null during
loading to avoid a flash. The provider transparently refreshes an expired shopToken.
Gate features
useMeridian() exposes synchronous gates for render paths. Features and usage events are addressed
by their stable app-local key.
import { useMeridian } from "@the-meridian/sdk";
function AdvancedReports() {
const { isEnabled, getLimit, getUsage } = useMeridian();
if (!isEnabled("advanced_reports")) return <UpgradePrompt />;
const limit = getLimit("seats"); // number | undefined
const used = getUsage("api_calls")?.usedThisPeriod;
return <Reports seatLimit={limit} apiCallsUsed={used} />;
}You also get customer, entitlements, plans, refresh(), and the write methods
subscribe(), cancelSubscription(), updateUsageCap(), and track().
Track usage
const { track } = useMeridian();
await track({ eventKey: "api_calls", quantity: 1 });
// `properties` is your own object. Meridian stores it with the event, and usage
// views and automations read it back by name — `properties.total_price`.
await track({
eventKey: "order_processed",
properties: { total_price: order.totalPrice, currency: order.currency },
});Metered events appear under entitlements.events[eventKey].usedThisPeriod after the next refresh.
Pricing & account pages
Both require a <MeridianProvider> ancestor.
Both pages render Polaris web components
(<s-*> elements), which the Shopify admin registers through the App Bridge script. They therefore
must render inside an embedded Shopify app with App Bridge loaded — every Shopify app template
(AppProvider embedded / the app-bridge.js script tag) already provides this. Outside the
embedded admin the elements are unregistered and render as unstyled text (a console warning points
this out in dev). Render them inside your own <s-page><s-section> so they inherit the page chrome:
<s-page heading="Pricing">
<s-section>
<MeridianPricingPage returnUrl={returnUrl} />
</s-section>
</s-page>MeridianPricingPage needs a returnUrl — where Shopify sends the merchant back after they
approve (or decline) the charge. It must be an https URL on the shop's own admin, i.e.
https://<shop>.myshopify.com/admin/... or https://admin.shopify.com/store/.... Anything else —
your app's own tunnel/hosting origin, request.url, window.location, or any http:// URL — is
rejected with INVALID_RETURN_URL, because Shopify's billing API only accepts shop-admin return
URLs. Using the shop-admin URL is also what re-embeds your app after approval instead of bouncing
the merchant to the login screen.
Because it depends on the session shop and your Shopify API key (both server-only), build it in the loader — never from the request URL or in the browser:
// app/routes/app.pricing.tsx
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
// https://<shop>.myshopify.com/admin/apps/<client_id>/<your-app-route>
// session.shop is the shop's *.myshopify.com domain; SHOPIFY_API_KEY is your app's client id.
const returnUrl = `https://${session.shop}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/pricing`;
return { returnUrl };
};import { MeridianPricingPage, MeridianAccountPage } from "@the-meridian/sdk";
export default function Pricing() {
const { returnUrl } = useLoaderData<typeof loader>();
// Pass the loader-built returnUrl straight through — do not construct it inline.
return <MeridianPricingPage returnUrl={returnUrl} />;
}
// The account page takes no returnUrl:
<MeridianAccountPage onChangePlan={() => navigate("/app/pricing")} />Pricing button labels react to the active plan (Subscribe / Upgrade / Downgrade /
Change to this plan / Current plan). The account page shows per-event usage and a cancel
button; the cancel confirmation and raise-cap flows use Polaris modals (native window.confirm
/ window.prompt are suppressed inside the embedded admin's iframe).
Server-side (no React)
For resource routes, Shopify extensions, and background jobs, build a server client from a
shopToken:
import { createMeridianServerClient } from "@the-meridian/sdk/server";
const client = createMeridianServerClient(shopToken);
const { enabled, limit } = await client.checkFeature("advanced_reports");
await client.track({ eventKey: "api_calls", quantity: 5 });
// Entitlements + key-based gating helpers in one call (degrades to ungated on failure):
const gates = await client.getGates();
if (gates.can("advanced_reports")) { /* ... */ }Or resolve gates straight from a shop in a loader/webhook without minting a token yourself:
const gates = await meridian.gatesForShop(session.shop);Receiving forwarded webhooks
Meridian re-serializes Pub/Sub-sourced events and forwards them to your declared host,
so Shopify's own HMAC can't validate the body — verify Meridian's signature headers
instead. Before it starts forwarding to a new host, Meridian sends a signed
verification challenge to the same path that you answer with your signing secret.
Handle both in one route (default path /webhooks/shopify): try the challenge first,
then fall through to the forwarded-webhook path.
import {
meridianChallengeResponse,
verifyMeridianWebhook,
} from "@the-meridian/sdk/server";
export const action = async ({ request }) => {
const raw = await request.text();
const secret = process.env.MERIDIAN_WEBHOOK_SECRET ?? "";
// 1. Verification handshake? Answer it and stop.
const challenge = await meridianChallengeResponse(raw, null, secret, {
headers: request.headers,
});
if (challenge) return Response.json(challenge);
// 2. Otherwise it's a forwarded webhook — verify, then handle the event.
if (!(await verifyMeridianWebhook({ rawBody: raw, headers: request.headers, secret }))) {
return new Response("invalid signature", { status: 401 });
}
const topic = request.headers.get("X-Shopify-Topic");
console.log(`Received forwarded ${topic} webhook`);
return new Response(null, { status: 200 });
};verifyMeridianWebhook enforces replay protection: forwards carry
X-Meridian-Timestamp and X-Meridian-Signature-V2 (an HMAC over
"{timestamp}.{body}"), and requests older (or newer) than 5 minutes are rejected.
Requests without those headers — an older Meridian backend — fall back to the legacy
body-only X-Meridian-Signature check; pass requireTimestamp: true to disable the
fallback once your backend is known to send them. The legacy
verifyForwardedWebhook(rawBody, sig, secret) remains available and unchanged.
The signing secret is shown in your Meridian dashboard; set it as
MERIDIAN_WEBHOOK_SECRET. When you rotate it from the dashboard, the old secret
keeps verifying for 24 hours (the v2 header carries signatures for both), giving you a
window to roll out the new value — update MERIDIAN_WEBHOOK_SECRET promptly, and note
that the legacy body-only check switches to the new secret immediately.
API reference
See docs/api.md for the full curated surface.
License
MIT
