npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

react 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:

  1. Server, once per install: exchange the shop for a per-shop shopToken (createMeridianApp().mintShopToken(...), which calls Meridian's identify). Persist the shopToken and 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. Use mintShopTokenDetailed(...) when you persist the token yourself: it returns { shopToken, shopTokenExpiresAt? } so you can decide when to re-mint.
  2. Browser: mount <MeridianProvider shopToken={shopToken}>. Every read and billing call goes straight to Meridian authenticated with the shopToken bearer — 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 your MERIDIAN_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 captureReferral must be served from the same host as your app's OAuth routes (app.your-app.com/go, not www.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 — referralCodeFromUrl reads 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:

  1. afterAuth(session, { request }) reads the meridian_mref cookie (and the meridian_mref_at click 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.
  2. The server-side stash. When an identify arrives with no code, Meridian looks up what captureReferral stashed 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