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
npm install @entitlehub/sdkZero 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.
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
await eh.logIn(newUser.id); // switch acting user, clears cache, re-fetches
eh.logOut(); // back to anonymousThe CustomerInfo object
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.
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.
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 accessFailed 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.”
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;
}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.
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);
},
});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, …).
