@the-meridian/sdk
v1.5.0
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 authenticated request: exchange the shop for a per-shop
shopToken(createMeridianApp().mintShopToken(...), which calls Meridian'sidentify). Persist theshopTokenand hand it to the browser via your loader. - 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).|
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.
// 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 }) => {
await meridian.afterAuth(session);
},
},afterAuth is best-effort and never throws: it returns a
{ registered, updated, errors, lifecycleNotified } 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.
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 };
};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 });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
X-Meridian-Signature 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,
verifyForwardedWebhook,
} from "@the-meridian/sdk/server";
export const action = async ({ request }) => {
const raw = await request.text();
const sig = request.headers.get("X-Meridian-Signature");
const secret = process.env.MERIDIAN_WEBHOOK_SECRET ?? "";
// 1. Verification handshake? Answer it and stop.
const challenge = await meridianChallengeResponse(raw, sig, secret);
if (challenge) return Response.json(challenge);
// 2. Otherwise it's a forwarded webhook — verify, then handle the event.
if (!(await verifyForwardedWebhook(raw, sig, 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 });
};The signing secret is shown in your Meridian dashboard; set it as
MERIDIAN_WEBHOOK_SECRET.
API reference
See docs/api.md for the full curated surface.
License
MIT
