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

@sense-apps/mantle

v0.4.2

Published

Backend-only client for a self-hosted Mantlekit deployment — @heymantle/client-compatible shapes (Customer/Subscription/Plan/Feature) and API (identify, getPlans, getCustomer, subscribe) for Shopify apps.

Readme

@sense-apps/mantle

Backend-only client your Shopify app uses to talk to your Mantlekit deployment. Response shapes (Customer / Subscription / Plan / Feature) are aligned field-for-field with Mantle's @heymantle/[email protected] for everything Mantlekit supports — same nesting, same field names, same helper semantics — so Mantle-era code like customer.subscription?.plan?.features["unlimited_bars"]?.value ports unchanged.

Server-side only. Your frontend never talks to Mantlekit directly; it talks to your own backend, which uses this client and relays whatever your frontend needs.

Install

bun add @sense-apps/mantle
# or: npm install @sense-apps/mantle

Configure

Set two env vars in your app (both server-side):

MANTLEKIT_URL=https://your-mantlekit-api.example.com
MANTLEKIT_API_KEY=mk_...   # per-app API key, created in the Mantlekit dashboard under App → API

Then construct with no arguments:

import { MantleKitClient } from "@sense-apps/mantle";

const admin = new MantleKitClient(); // reads MANTLEKIT_URL + MANTLEKIT_API_KEY

Explicit options always override env — useful for apps configured via a config.json, or when one process talks to more than one deployment:

const admin = new MantleKitClient({ baseUrl, apiKey });

Auth model — two tiers, both server-side

| Credential | Where it lives | What it unlocks | | ------------------ | ------------------------------------ | ------------------------------------------------------ | | apiKey | your server's env | identify() only | | customerApiToken | your DB, one per shop (returned by identify()) | everything else — getPlans, getCustomer, subscribe, cancelSubscription |

customerApiToken has no env fallback on purpose: it is per-shop state, not deployment config. Store it next to the shop row in your own database.

Usage

1. Register the shop after your OAuth callback

const admin = new MantleKitClient();
const { shopId, apiToken } = await admin.identify({
  myshopifyDomain: "cool-store.myshopify.com",
  accessToken, // the offline token your OAuth flow just obtained
  accessTokenExpiresAt, // when it dies — see "Access tokens" below
  name: shop.name,
  email: shop.email,
  shopifyPlanName: shop.plan_name, // see "Development stores" below
  shopifyPlan: shop.plan_display_name,
});
// persist apiToken on your shop record

identify() is an upsert — safe to call again on every app load to keep the shop record fresh.

Development stores — free access for partners

Pass shopifyPlanName / shopifyPlan (straight off the REST shop object you already fetch) and Mantlekit knows whether Shopify will ever bill this store. Two things follow:

  • A plan whose availability is Development & client transfer stores is offered only to partner-owned stores — dev stores, client transfer stores, staff stores. Price it at 0 and partners get your app free, with no code in your app.
  • Charges for such a store are automatically created with test: true. Shopify rejects a live charge there (the merchant hits "The shop cannot accept the provided charge"), so this is the only way the flow completes — and it means you can delete your own list of test-store domains.

Stores on a Shopify trial are deliberately excluded: they're real merchants who are about to start paying.

Send these on every identify(), not just the first. The day a partner store becomes a paying merchant store is the day it should stop getting your app free — that's the only signal Mantlekit gets, and it fires a shop.started_paying_shopify webhook so you can prompt for a real plan.

Access tokens — read this if you pass accessToken

Shopify offline tokens stopped being permanent in December 2025. They now last one hour and are renewed with a refresh token that lasts 90 days. Renewing rotates the refresh token: the one you spend is dead and a new one comes back, so only one system per shop may renew. If your app and Mantlekit both renew, whichever goes second gets invalid_grant and that shop stops billing until the merchant reinstalls.

Two supported setups, chosen per app under App → Settings → Shopify access:

Your app renews (default). Mantlekit stores the token you push only as a one-hour cache and never keeps a refresh token. Because the cache goes stale between calls, do both of these:

  1. Always send accessTokenExpiresAt alongside accessToken. Omitting it makes Mantlekit assume one hour; omitting it on a non-expiring legacy token is what tells Mantlekit the token has no deadline.

  2. Push a fresh token immediately before every subscribe() and cancelSubscription() — those are the calls where Mantlekit talks to Shopify on your behalf. Do not rely on an identify() that ran at app start: most apps call it only on install and OAuth login, and relaunching an embedded app does neither, so the token here is usually hours old by the time a merchant clicks subscribe.

    Reads (getPlans, getCustomer) never touch Shopify and need none of this.

  3. Optional — register a token endpoint so Mantlekit can fetch a live token when a job runs with no merchant present. Required for scheduled or usage-based charges, cancel-at-period-end, hourly reconciliation, and importing existing subscriptions. Mantlekit POSTs { myshopifyDomain, requestedAt } with an X-Mantlekit-Signature: sha256=<hmac> header (HMAC-SHA256 of the raw body, keyed by this app's Mantlekit API key). Reply { accessToken, expiresAt }, or 410 Gone if the shop is unrecoverable. Any other status is treated as temporary and retried.

    Without an endpoint, Mantlekit can only act on a shop within an hour of your last identify() — renewal charges, usage records and reconciliation run on a fixed schedule, so shops nobody has opened recently get skipped.

Mantlekit renews. Only for apps installed through Mantlekit's own OAuth. Mantlekit holds the refresh token; your app must not renew these tokens itself.

Non-expiring tokens keep working until 2027-01-01 and are already rejected for public apps created on or after 2026-04-01. Migrating a shop to an expiring token is one-way and revokes the old token immediately, so it must be done by whichever side renews.

2. Per-shop client for everything else

const client = new MantleKitClient({ customerApiToken: shop.mantlekitToken });

3. Read the customer — Mantle's exact nesting

const customer = await client.getCustomer();
const subscription = customer?.subscription || null;

if (subscription?.plan) {
  SetPlanMetafields(shopifyClient, subscription.plan); // full Plan: total, interval, features, …
}

const isPremiumUser = subscription?.plan?.features["unlimited_bars"]?.value === true;
const maxBarViews = (subscription?.plan?.features["bar_views"]?.value as number) || 2500;

Customer also carries plans (the eligible-plan list, same as getPlans()), features (resolved for the shop — falls back to each feature's default when there's no subscription), and billingStatus ("none" | "active" | "trialing" | "canceled" | "frozen").

4. Pricing page

const plans = await client.getPlans();
// Mantle Plan shape: id, name, total, subtotal, interval ("EVERY_30_DAYS" | "ANNUAL" | "QUARTERLY"),
// trialDays, currencyCode, features, featuresOrder, discounts, autoAppliedDiscount, flexBilling, …

Eligibility (tag rules, per-shop price/trial overrides) is resolved server-side — the list is exactly what this shop may buy, with override pricing already applied. Money fields (total, subtotal) are numbers only because that is Mantle's wire shape — treat them as display values.

Discounts work like Mantle's: created in the Mantlekit dashboard as a percentage (percentage: 20 = 20%) or fixed amount off, optionally limited to specific plans, for a number of billing intervals (or forever / until a date), and auto-applied to shops by tag. When one auto-applies, plan.autoAppliedDiscount is set and plan.total is already the discounted price, with plan.subtotal the original — render a slashed price:

if (plan.autoAppliedDiscount) {
  render(`~~$${plan.subtotal}~~ $${plan.total}/mo — ${plan.autoAppliedDiscount.percentage}% off`);
}

The same discount is forwarded to Shopify at subscribe time (on the recurring line item), so the approval screen and the real charge match what you displayed. Flex-billing plans never get auto-applied discounts (Shopify's discount input can't reach usage lines).

5. Subscribe / upgrade

// REQUIRED: push a live Shopify token first — Mantlekit creates the charge
// through Shopify's Billing API, and Shopify tokens expire after one hour.
await admin.identify({ myshopifyDomain, accessToken, accessTokenExpiresAt });

const subscription = await client.subscribe({
  planId,
  returnUrl: `https://your-app.com/billing/confirm?shop=${shop.domain}`,
});
// redirect the merchant's browser to Shopify's approval screen:
redirect(subscription.confirmationUrl!);

The subscription only becomes active after the merchant approves and Shopify confirms — don't grant features on redirect alone; check getCustomer().

Skipping that identify() fails with no usable Shopify access token for this shop for any store that has not just come through OAuth. See Access tokens.

6. Gate features

if (await client.isFeatureEnabled({ featureKey: "unlimited_bars" })) { ... }

// limit features evaluate as `count < limit`, -1 = unlimited — Mantle's semantics:
const canAddBar = await client.isFeatureEnabled({ featureKey: "bars", count: currentBarCount });

const maxSeats = await client.limitForFeature({ featureKey: "team_seats" }); // -1 if not a limit feature

Both helpers also accept a bare string key. Each one calls getCustomer() under the hood — if you need several flags in one request, call getCustomer() once and read customer.features yourself.

Hidden features are returned too. features contains every feature in the app's catalog, each carrying visible — the dashboard's "Visible to customers" flag. Previously hidden ones were stripped from the response, so an app could not gate on an internal flag at all. Gating works the same either way:

// works for hidden features — they're just not meant to be shown to merchants
if (await client.isFeatureEnabled({ featureKey: "internal_beta_export" })) { ... }

For rendering a pricing table, iterate plan.featuresOrder — it still lists the visible keys only, in display order, so pricing UIs need no filtering of their own:

for (const key of plan.featuresOrder) render(plan.features[key]);      // merchant-facing
Object.values(plan.features).filter((f) => f.visible);                 // same set, if you prefer
Object.values(plan.features);                                          // everything, incl. hidden

7. Cancel

// Same rule as subscribe — cancelling reaches Shopify, so push a live token first.
await admin.identify({ myshopifyDomain, accessToken, accessTokenExpiresAt });

const cancelled = await client.cancelSubscription(); // resolves to the cancelled Subscription

Which calls need a fresh Shopify token?

| Call | Talks to Shopify? | Push identify() first? | | --- | --- | --- | | getPlans() | No | No | | getCustomer() | No | No | | isFeatureEnabled() / limitForFeature() | No | No | | subscribe() | Yes | Yes, every time | | cancelSubscription() | Yes | Yes, every time |

The reads are answered from Mantlekit's own database, so they keep working indefinitely off the stored apiToken — which never expires. Only the two billing calls depend on a live Shopify token.

What's intentionally empty

Mantle features Mantlekit doesn't implement are emitted as empty values rather than omitted, so ported code never crashes on .length/?.: subscription.lineItems, plan.discounts, customer.usage, customer.usageCredits, customer.reviews, customer.paymentMethod, customFields. There are no client methods for Stripe billing, invoices, notifications, or checklists.

Errors

Every non-2xx response throws MantleKitError with status and, for validation failures, userErrors: { field, message }[]:

import { MantleKitError } from "@sense-apps/mantle";

try {
  await client.subscribe({ planId, returnUrl });
} catch (e) {
  if (e instanceof MantleKitError && e.status === 422) {
    console.error(e.userErrors);
  }
}