Purchases

EntitleHub validates receipts and owns entitlements; the on-device purchase happens through a store billing library. On Expo / React Native that's one call; on any other stack it's a short backend hop.

Expo / React Native? One call.

@entitlehub/react-native opens the native store sheet, validates the receipt with EntitleHub, and returns your entitlements, the RevenueCat DX.

npx expo install expo-iap · npm i @entitlehub/react-native @entitlehub/sdk

ts
import { configureEntitleHub, purchaseProduct, isEntitled } from "@entitlehub/react-native";

await configureEntitleHub({ apiKey: "pk_live_…", appUserId: user.id });

// opens the native sheet → validates with EntitleHub → returns entitlements
const info = await purchaseProduct("app_pro_monthly", { isSubscription: true });
if (info.isActive("pro")) unlockPro();

Only your publishable key ships in the app. EntitleHub validates the receipt server-side, so a forged one is rejected. That's the whole integration. The rest of this page is the manual version for other stacks (or full control).

Native store-library crashes

The on-device purchase runs through a store library (expo-iap / OpenIAP). A native crash there (e.g. an EXC_BAD_ACCESSin the store module on a specific OS) is the store library's concern: a native exception off the JS thread can't be caught in JS. Levers: pin a working expo-iap version, pass your own module via configureEntitleHub({ iap }), or use the manual path below with a billing library you've verified on your target OS.

Manual: bring your own billing (any stack)

Keep a billing library for the purchase step, then report the receipt to EntitleHub yourself.

The loop

  1. App: open the native purchase sheet with your billing library → get a token/receipt.
  2. App → your backend: POST the token (never put the EntitleHub secret key in the app).
  3. Backend → EntitleHub: POST /v1/subscribers/{id}/purchases with your sk_ key, EntitleHub validates against Apple/Google and writes the grant.
  4. App: re-read entitlements (or let a webhook update your DB).

1. Install a billing library

bash
npx expo install expo-iap
# or:  npm install react-native-iap

2. App: buy, then send the receipt to your backend

tsx
import { requestPurchase, getProducts, finishTransaction } from "expo-iap";

async function buyPro(userId: string) {
  await getProducts(["app_pro_monthly"]);          // load from the store
  const purchase = await requestPurchase({ sku: "app_pro_monthly" });

  // Android gives a purchaseToken; iOS gives a StoreKit 2 JWS transaction.
  await fetch("https://your-backend.com/iap/report", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: sessionToken },
    body: JSON.stringify({
      userId,
      productId: "app_pro_monthly",
      // Android:
      purchaseToken: purchase.purchaseToken,
      // iOS (StoreKit 2):
      jws: purchase.jwsRepresentation,
    }),
  });

  await finishTransaction({ purchase });           // acknowledge with the store
}

3. Backend: report to EntitleHub (validated)

With the SDK:

ts
import { EntitleHubServer } from "@entitlehub/sdk";
const eh = new EntitleHubServer({ apiKey: process.env.ENTITLEHUB_SECRET_KEY! });

app.post("/iap/report", async (req, res) => {
  const { userId, productId, purchaseToken, jws } = req.body;

  const info = jws
    ? // Apple: product/environment come from the verified JWS
      await eh.reportPurchase(userId, { store: "app_store", storeProductId: "", signedTransaction: jws })
    : // Google: EntitleHub validates the token with your Play service account
      await eh.reportPurchase(userId, { store: "play", storeProductId: productId, purchaseToken, isSubscription: true });

  res.json({ entitled: info.isActive("pro") });
});

Or the raw REST call: see the purchases endpoint.

4. App: read entitlements

tsx
import { EntitleHub } from "@entitlehub/sdk";
const eh = new EntitleHub({ apiKey: "pk_live_…", appUserId: userId });

const info = await eh.getCustomerInfo({ fetchPolicy: "network-only" }); // fresh after a purchase
if (info.isActive("pro")) unlockPro();

Purchase history

GET /v1/subscribers/{app_user_id}/purchasesreturns everything that has happened to one subscriber, newest first, plus their grants with the store's own identifiers attached. It needs a secret key: the response carries revenue and raw store order ids.

bash
curl -H "Authorization: Bearer sk_live_…" \
  https://entitlehub.com/v1/subscribers/user_123/purchases
json
{
  "app_user_id": "user_123",
  "purchases": [
    { "type": "refund", "product": "pro_annual", "store": "play",
      "revenue_micros": -29990000, "currency": "USD", "created_at": "2026-09-18T09:12:00Z" },
    { "type": "initial_purchase", "product": "pro_annual", "store": "play",
      "revenue_micros": 29990000, "currency": "USD", "created_at": "2026-09-01T17:40:00Z" }
  ],
  "grants": [
    { "entitlement": "pro", "product": "pro_annual", "store": "play", "status": "revoked",
      "store_order_id": "GPA.3372-8541-2210-84713",
      "store_correlation_id": "gnbk…opaque.play.token",
      "linked": true }
  ]
}

store_order_id is the row in the Play Console / App Store Connect / Stripe that describes the same money. store_correlation_id is what a notification from that store is matched on (Play purchase token, Apple originalTransactionId, Stripe subscription id). linked: false means the purchase was reported without a receipt, so no renewal, expiry or refund from the store can ever reach that grant.

Refunded receipts

Apple stamps a refunded transaction with revocationDate permanently, and StoreKit keeps handing it to your app on every restore. Reporting one answers 409 receipt_revoked: no grant is written, and if the entitlement it belonged to was still active, EntitleHub revokes it and fires a refund webhook. That makes a restore a backstop for a refund whose notification never arrived.

Restore purchases

Same shape: your billing library restores the platform's active purchases, you re-report each token to EntitleHub, then re-read. Because EntitleHub keys on your app_user_id, entitlements follow the user across reinstalls and devices.

Prerequisites

For validated reports you need store credentials configured on the project, a Play service accountfor Google; Apple works with no secret. Without a Play credential, a Google report falls back to the untrusted server-report mode (don't ship that).

Keeping state fresh

Renewals, cancellations, and refunds arrive automatically once you wire Apple ASSN / Google RTDN, no app involvement. Until then, re-report on app launch / restore to refresh the subscription's expiry.

Always send the receipt

A purchase reported without anything the store can match carries a generated id no notification will ever equal, so the grant it creates is frozen: no renewal, expiry or refund will reach it, even after you wire notifications up. The response says so in warnings, and Settings → Store credentials counts these per store.

What counts as matchable: purchase_token (Play), signed_transaction (Apple), stripe_subscription_id (Stripe subscriptions), or, for a one-time Stripe payment (which has no subscription id), transaction_id set to the PaymentIntent (pi_…). EntitleHub resolves that same PaymentIntent from charge.refunded and charge.dispute.created, so one-time refunds revoke correctly.