@raiment-app/sdk
v1.1.0
Published
Backend-only client for a self-hosted Raiment deployment - @heymantle/client-compatible shapes (Customer/Subscription/Plan/Feature) and API (identify, getPlans, getCustomer, subscribe) for Shopify apps.
Readme
@raiment-app/sdk
Backend-only client your Shopify app uses to talk to your Raiment deployment. Response shapes (Customer / Subscription / Plan / Feature) are aligned field-for-field with Mantle's @heymantle/[email protected] for everything Raiment 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 Raiment directly; it talks to your own backend, which uses this client and relays whatever your frontend needs.
Install
bun add @raiment-app/sdk
# or: npm install @raiment-app/sdkConfigure
Set one env var in your app (server-side):
RAIMENT_API_KEY=rmt_... # per-app API key, created in the Raiment dashboard under App → APIThen construct with no arguments:
import { RaimentClient } from "@raiment-app/sdk";
const admin = new RaimentClient(); // uses https://api.raiment.netRAIMENT_URL is optional. With nothing set the client talks to Raiment's
hosted API, DEFAULT_BASE_URL = https://api.raiment.net. Set it only if you
self-host:
RAIMENT_URL=https://billing.yourcompany.comPoint it at the API host. https://raiment.net is the dashboard and answers
every path with HTML, so a client aimed there parses markup as JSON and fails on
every call.
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 RaimentClient({ 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
import { RaimentClient, shopFacts } from "@raiment-app/sdk";
const admin = new RaimentClient();
const { shop } = await shopify.rest.get({ path: "shop" }); // you probably fetch this already
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
...shopFacts(shop), // name, email, plan, country, region, timezone, storefront domain, shop id
});
// persist apiToken on your shop recordidentify() is an upsert - safe to call again on every app load to keep the shop record fresh.
shopFacts(shop) maps Shopify's REST shop object onto the fields Raiment can
use, and is worth preferring over hand-picking them: province and
province_code are both on that object and only the name belongs here, and
platformId has to be the numeric shop id - Raiment turns it into the Shopify
Shop GID that usage and credit billing need, saving itself an Admin API call.
Every field is optional; sending one overwrites what Raiment holds, omitting it
leaves the value alone. They surface as columns on the Customers list and on the
customer page, so support can see where a store is and what it sells.
One field Shopify can't give you: industry. There is no such field in REST
or GraphQL - if your app asks the merchant during onboarding, pass it and the
column stays useful for stores that install from now on.
Per-shop plan visibility - customFields
identify({ customFields }) stores arbitrary per-shop values, and
getCustomer().customFields hands them back unchanged. What makes them more than
a scratchpad: a plan's availability can be set to custom fields in the
dashboard - "only shops whose tier is wholesale may see this plan" - and that
rule matches against exactly these values. It's how you drive who sees which plan
from state only your app knows.
await admin.identify({ myshopifyDomain, customFields: { tier: "wholesale", seats: 12 } });Merged, not replaced, so pushing one key leaves the others alone. Keys Raiment
owns (platform, platformId, shopifyPlan, shopifyPlanName,
partnerDevelopment, mantleCustomerId) are ignored with a server-side warning
instead of an error - they decide billing, so they aren't yours to set.
Development stores - free access for partners
Pass shopifyPlanName / shopifyPlan (straight off the REST shop object you already fetch) and Raiment knows whether Shopify will ever bill this store.
Tick "Free for development & client transfer stores" on any plan in the dashboard, and partner-owned stores - dev stores, client transfer stores, staff stores - get that plan at a price of 0. No code in your app, and no separate free plan: they subscribe to the same plan a paying merchant does, so getCustomer() reports the same plan id and the same features throughout. Nothing that caches entitlements (a shop metafield, say) has to change when the store later goes live.
No Shopify charge is created for such a store at all - the subscription is local to Raiment. That matters because Shopify rejects a live charge on a store it won't bill (the merchant hits "The shop cannot accept the provided charge"), 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 only signal Raiment gets. When that happens it fires a customers/platform_plan_changed notification naming the plan the shop is already on. Shopify requires the merchant to approve a real charge at that point (there is no way to start billing an approved-at-$0 subscription; the amount is fixed at approval), but since they're already on the plan there's nothing for them to re-choose - send them straight to subscribe() for that same planId.
Affiliate referrals - pass referralCode or nothing gets credited
If you run an affiliate program, your app is the only thing that can credit a referral. An affiliate's tracked link looks like https://apps.shopify.com/your-app?mref=CODE, and Shopify's App Store strips that query param before the merchant reaches your OAuth callback. Since your app runs its own OAuth, Raiment never sees the install - so unless you carry the code yourself, every referral is lost.
Two steps. First, capture the code on your marketing/landing pages:
<script>
(function () {
var KEY = "mantlekit_mref";
var p = new URLSearchParams(location.search);
// mref wins, then utm_source, then ref (the order Mantle used)
var code = p.get("mref") || p.get("utm_source") || p.get("ref");
if (code) {
try {
// 30 days - match the attribution window
document.cookie = KEY + "=" + encodeURIComponent(code) +
";path=/;max-age=" + 60 * 60 * 24 * 30 + ";samesite=lax";
} catch (e) {
try { sessionStorage.setItem(KEY, code); } catch (e2) {}
}
}
})();
</script>Then read that cookie when the merchant lands on your app and forward it:
await admin.identify({
myshopifyDomain,
accessToken,
accessTokenExpiresAt,
referralCode, // from the mantlekit_mref cookie, if present
});Safe to send on every identify(). The first code to arrive for a store wins; a code that shows up after the store already has an affiliate is ignored rather than stealing the referral, so a stale cookie can't reassign a merchant months later. An unknown code is logged and dropped - it never fails identify().
If the merchant installs through a link Raiment itself hosts (/oauth/start?...&mref=CODE), attribution happens server-side and you don't need any of this.
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 Raiment
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). Raiment 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 Raiment assume one hour; omitting it on a non-expiring legacy token is what tells Raiment the token has no deadline.Push a fresh token immediately before every
subscribe()andcancelSubscription()- those are the calls where Raiment 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 Raiment 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. Raiment POSTs
{ myshopifyDomain, requestedAt }with anX-Raiment-Signature: sha256=<hmac>header (HMAC-SHA256 of the raw body, keyed by this app's Raiment API key). Reply{ accessToken, expiresAt }, or410 Goneif the shop is unrecoverable. Any other status is treated as temporary and retried.Without an endpoint, Raiment 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.
Raiment renews. Only for apps installed through Raiment's own OAuth. Raiment 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 RaimentClient({ 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 Raiment 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 - Raiment 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 Subscription8. Page views - "Last active" and the usage timeline
Raiment's customer view shows when a merchant was last in your app, and a per-day timeline of what they looked at. Both are fed by page views your app reports. Nothing else can produce them: the Shopify Partner feed tells us about billing, not about someone opening a screen.
The customer token is client-safe, so report them straight from the frontend:
// Once, where your app mounts (browser only).
const stop = client.autoTrackPageViews();
// ...and on unmount, if your app tears the client down:
stop();That reports the screen they landed on and every route change after it
(pushState/replaceState/popstate/hashchange), batched every 5s and flushed when
the tab hides - so the last screen before they close it is not lost. One session
id per tab, kept in sessionStorage, so a reload continues the same visit.
Reporting them by hand works too, from a browser or a server:
await client.trackPageView("/plans");
await client.trackPageView({ path: "/settings", title: document.title });
await client.trackPageViews([{ path: "/", occurredAt: someEarlierDate }]);Notes:
- Reporting from a browser needs your app's origin on the API's CORS
allowlist (
CORS_ORIGINSon the Raiment API, comma separated) - same rule as any other client-side/sdk/v1call, since the API reflects only trusted origins. Without it the browser blocks the request and nothing arrives, with the reason visible only in the API log ([cors] blocked https://…). CallingtrackPageView()from your backend instead needs no allowlisting. - Telemetry never throws. A failed report resolves to
{ recorded: 0 }- it cannot break the page it is measuring. - Only the path is stored (query string included, host dropped). Timestamps are honoured within a week of now, otherwise replaced with server time.
- Page views are NOT usage events.
sendUsageEvent-style metered usage drives billing and auto-tiering; page views are diagnostics and are pruned after 90 days. "Last active" itself is stored separately and is never pruned.
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 |
| trackPageView() / autoTrackPageViews() | No | No |
The reads are answered from Raiment'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 Raiment 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 RaimentError with status and, for validation failures, userErrors: { field, message }[]:
import { RaimentError } from "@raiment-app/sdk";
try {
await client.subscribe({ planId, returnUrl });
} catch (e) {
if (e instanceof RaimentError && e.status === 422) {
console.error(e.userErrors);
}
}