SDK: client & server

@entitlehub/sdk is a zero-dependency TypeScript SDK: a client class for reads (browsers, React Native, Expo) and a server class for writes. Every method maps to a REST endpoint.

Install

bash
npm install @entitlehub/sdk

Zero dependencies; uses the global fetch (Node 18+, modern browsers, React Native, Expo). Prefer no dependency at all? Every method below maps 1:1 to a plain REST endpoint you can call with fetch.

Client: reads (publishable key)

Construct once with your publishablekey and the acting user, then ask what they're entitled to. CustomerInfo is cached (default 5 min) and refreshed on demand.

typescript
import { EntitleHub } from "@entitlehub/sdk";

const eh = new EntitleHub({
  apiKey: "pk_live_…",        // publishable key, client-safe
  appUserId: user.id,          // your own stable user id
});

const info = await eh.getCustomerInfo();
if (info.isActive("pro")) unlockPro();

// convenience: single check
if (await eh.isEntitled("premium")) showPremium();

// authoritative check (always hits the server, includes a reason when inactive)
const res = await eh.checkEntitlement("pro");
// res => { active, status, expires_at, will_renew, reason? }

// paywall data
const offerings = await eh.getOfferings();

// react to changes (e.g. after a purchase re-fetches)
const off = eh.addCustomerInfoUpdateListener((info) => render(info));

Switching users

typescript
await eh.logIn(newUser.id);   // switch acting user, clears cache, re-fetches
eh.logOut();                   // back to anonymous

The CustomerInfo object

typescript
info.isActive("pro")            // boolean
info.active["pro"]              // full detail: { status, store, product, expires_at, will_renew, since }
info.activeEntitlementIds      // ["pro", "premium"]
info.expirationDate("pro")     // Date | null (null = lifetime) | undefined (not active)

Server: writes (secret key)

Keep this on your backend; it uses your secret key. Report validated purchases and grant entitlements.

typescript
import { EntitleHubServer } from "@entitlehub/sdk";

const eh = new EntitleHubServer({ apiKey: process.env.ENTITLEHUB_SECRET_KEY! });

// Google Play: validated: EntitleHub verifies the token with your Play service account
await eh.reportPurchase(userId, {
  store: "play",
  storeProductId: "app_pro_monthly",
  purchaseToken: playToken,
  isSubscription: true,
});

// Apple StoreKit 2: validated: product/environment read from the signed JWS
await eh.reportPurchase(userId, { store: "app_store", storeProductId: "", signedTransaction: jws });

// Grant directly (promo / comp / code redemption), no store purchase
await eh.grantEntitlement(userId, "premium", { durationDays: 0 }); // 0 = lifetime

// Read / check from the server too
const info = await eh.getCustomerInfo(userId);
const res  = await eh.check(userId, "pro");

Managing your catalog

Since 0.3.0 the server class also covers the Catalog API: eh.entitlements, eh.products and eh.offerings. Each create has an ensure twin that returns the existing row instead of failing, so a setup script can run on every deploy.

typescript
await eh.entitlements.ensure({ key: "pro", name: "Pro" });
await eh.products.ensure({
  store: "stripe", storeProductId: "price_1Q...", type: "subscription", duration: "P1M",
  priceMicros: 9_990_000, entitlements: ["pro"],
});
await eh.products.archive("price_old"); // retire a SKU; existing subscribers keep access
Error handling

Failed calls throw EntitleHubError with a status and a code (auth, config, …). Catch it to, e.g., force a re-login on auth. When the API sends its own code (product_exists, ambiguous_product, rate_limited, …) it is on apiCode, with the full payload on body.

Refunds: detecting a revoked receipt

Reporting a receipt that has been refunded answers 409 receipt_revoked: no grant is written, and EntitleHub revokes the entitlement it had granted. Apple stamps a refunded transaction with revocationDatepermanently and StoreKit keeps handing it back, so this is how you tell “their access ended because the money was returned” apart from “their access ended for some reason I can't explain.”

ts
import { isReceiptRevoked } from "@entitlehub/sdk";

try {
  await eh.reportPurchase({ signedTransaction: jws });
} catch (err) {
  if (isReceiptRevoked(err)) showRefundNotice();  // the purchase is gone, and so is the access
  else throw err;
}
On restore, a try/catch will not see it

restorePurchases()keeps going past a receipt it can't report, so one stale purchase can't cost someone the rest of theirs. That means it resolves rather than rejecting, and wrapping it in try/catch to catch a refund has never worked. Pass onReportError instead - it is the only way these surface:

Needs @entitlehub/react-native≥ 0.1.9. Passing the options object to older versions is harmless (JS drops the extra argument), so it is safe to add first.

ts
import { restorePurchases, isReceiptRevoked } from "@entitlehub/react-native";

const info = await restorePurchases({
  onReportError: (err, purchase) => {
    if (isReceiptRevoked(err)) showRefundNotice(purchase);
    else console.warn("restore: could not report", purchase, err);
  },
});
Version matters here

isReceiptRevoked needs @entitlehub/sdk ≥ 0.3.1; err.apiCode and err.body need ≥ 0.3.0. On 0.2.0 or earlier neither exists, so a check written against them compiles, looks right, and silently never matches. Check what you're pinned to before relying on it. And don't fall back to err.status === 409 as a general rule: the catalog endpoints answer 409 for their own conflicts (product_exists, entitlement_in_use, …).