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

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

Configure

Set one env var in your app (server-side):

RAIMENT_API_KEY=rmt_...   # per-app API key, created in the Raiment dashboard under App → API

Then construct with no arguments:

import { RaimentClient } from "@raiment-app/sdk";

const admin = new RaimentClient(); // uses https://api.raiment.net

RAIMENT_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.com

Point 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 record

identify() 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:

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

  2. Push a fresh token immediately before every subscribe() and cancelSubscription() - those are the calls where Raiment 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 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 an X-Raiment-Signature: sha256=<hmac> header (HMAC-SHA256 of the raw body, keyed by this app's Raiment 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, 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 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

8. 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_ORIGINS on the Raiment API, comma separated) - same rule as any other client-side /sdk/v1 call, 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://…). Calling trackPageView() 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);
  }
}