@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/mantleConfigure
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 → APIThen construct with no arguments:
import { MantleKitClient } from "@sense-apps/mantle";
const admin = new MantleKitClient(); // reads MANTLEKIT_URL + MANTLEKIT_API_KEYExplicit 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 recordidentify() 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:
Always send
accessTokenExpiresAtalongsideaccessToken. Omitting it makes Mantlekit assume one hour; omitting it on a non-expiring legacy token is what tells Mantlekit the token has no deadline.Push a fresh token immediately before every
subscribe()andcancelSubscription()— those are the calls where Mantlekit talks to Shopify on your behalf. Do not rely on anidentify()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.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 anX-Mantlekit-Signature: sha256=<hmac>header (HMAC-SHA256 of the raw body, keyed by this app's Mantlekit API key). Reply{ accessToken, expiresAt }, or410 Goneif 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 featureBoth 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. hidden7. 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 SubscriptionWhich 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);
}
}