View as Markdownllms.txt

Billing

Billing

AppRefer Billing runs your App Store, Google Play and Stripe subscriptions itself: it verifies every purchase, keeps each customer’s entitlements and sends the conversions to your ad networks, so you don’t need RevenueCat.

How it works

Two kinds of input feed one ledger. The SDK reports each purchase and restore straight after it happens, and the stores send their own server notifications (App Store Server Notifications V2, Google Play real-time developer notifications and Stripe webhooks), which also cover renewals, refunds and expiries while the app is closed. AppRefer verifies every input with the store (Apple’s signature, the Google Play Developer API, Stripe’s webhook signature), so nothing the app sends can grant access on its own.

  • Each store subscription or one-time purchase is one record, owned by one app user id (your user id, or an anonymous $ARAnon:<UUID> the SDK creates).
  • The SDK tags every purchase with the customer’s account token (Apple appAccountToken, Google obfuscatedAccountId, Stripe metadata), so a renewal or refund that arrives months later still finds its owner.
  • Entitlements are worked out from the customer’s records: an active subscription or a store grace period gives access; billing retry, pause, expiry, refunds and revocations don’t. Expiry is checked against the clock, so a missed notification never leaves access on. Consumables never unlock anything, and lifetime products only once paid.
  • The SDK report and the store notification for the same transaction are counted once, whichever arrives first.

Modes

billing.modeWhat it does
off (default)Billing endpoints answer 404. Nothing is recorded.
shadowRecords every purchase and serves entitlements to the SDK, but sends no conversions to ad networks. RevenueCat (or whatever runs today) keeps forwarding. Use it to compare AppRefer against RevenueCat before switching.
primaryAppRefer sends the conversions from its own ledger. For each store AppRefer is connected to (apple.bundleId for the App Store, google.packageName for Google Play, a Stripe webhook for Stripe), the RevenueCat webhook stops forwarding that store’s events and only keeps an ownership link. Stores that aren’t connected keep the full RevenueCat path.

Set up

  1. Turn billing on for the app and connect each store you sell on (below).
  2. Map your products to entitlements.
  3. Add AppRefer Billing to the app (SDK 0.5.0+; offerings need 0.8.0+; 0.11.0 is current).
  4. Buy with a sandbox account and check the readiness endpoint shows each store reaching AppRefer.
  5. Moving from RevenueCat? Start in shadow, compare, then switch to primary (see Moving from RevenueCat).

Billing settings live on the app at billing. Read them with GET /api/v1/apps/{appId}/billing (organization key with integrations:read) and change them with PUT on the same path (integrations:write). PUT merges: send only the sections you change, and null removes a section. Secrets (apple.privateKey, google.serviceAccountJson, google.pushToken, stripe.webhookSecret, stripe.apiKey, webhook.secret) are stored encrypted and always come back masked; sending the masked value back keeps the stored one. A mode other than off needs at least one store.

HTTP
PUT /api/v1/apps/{appId}/billing
Authorization: Bearer ak_live_…        (organization key with integrations:write)
Content-Type: application/json

{
  "mode": "shadow",
  "apple": {
    "bundleId": "com.example.app",
    "appAppleId": 1234567890,
    "issuerId": "57246542-96fe-1a63-e053-0824d011072a",
    "keyId": "2X9R4HXF34",
    "privateKey": "<contents of the .p8 file>"
  },
  "google": {
    "packageName": "com.example.app",
    "serviceAccountJson": "{\"type\":\"service_account\", …}",
    "pushToken": "a-long-random-string"
  },
  "products": {
    "pro_monthly": { "entitlements": ["premium"], "type": "subscription", "period": "P1M" },
    "pro_yearly":  { "entitlements": ["premium"], "type": "subscription", "period": "P1Y" },
    "coins_100":   { "entitlements": [], "type": "consumable" }
  },
  "defaultEntitlement": "premium"
}

Store URLs below use https://apprefer.com; if you host AppRefer yourself, use your own domain.

Connect the App Store

FieldWhat it is
apple.bundleIdRequired. Notifications and transactions for any other bundle id are rejected.
apple.appAppleIdYour app’s numeric Apple ID (App Store Connect → App Information). Production notifications must carry it. Sandbox notifications don’t, so it isn’t checked for them.
apple.issuerId, apple.keyId, apple.privateKeyAn App Store Server API key (an In-App Purchase key, .p8). Optional but recommended: AppRefer uses it to read auto-renew and grace-period status when the SDK reports a subscription purchase.
apple.forwardToUp to 3 https URLs that get each verified notification passed on unchanged (see Moving from RevenueCat).
  1. In App Store Connect → your app → App Information → App Store Server Notifications, set both the Production and the Sandbox URL to https://apprefer.com/api/billing/apple/{appId} and choose Version 2.
  2. Use Request a test notification. The readiness endpoint then shows the time it arrived.

AppRefer checks every notification’s signature chain against Apple’s root certificate; an invalid one gets 401. Version 1 notifications can’t be verified, so they are acknowledged and ignored: switch the setting to Version 2.

Connect Google Play

FieldWhat it is
google.packageNameRequired. Notifications for another package are acknowledged and ignored.
google.serviceAccountJsonA Google Cloud service-account key (JSON) with access to the app in Play Console.
google.pushTokenA shared secret you put in the push URL as ?token=. Use this or authenticated push.
google.pushAudience, google.pushServiceAccountEmailFor Pub/Sub authenticated push instead of a token: the audience you set on the subscription and the service account it signs as. Both are required.
  1. In the Google Cloud project, enable the Google Play Android Developer API and create a service account with a JSON key.
  2. In Play Console → Users and permissions, invite the service account’s email with access to the app and the permissions View financial data and Manage orders and subscriptions. AppRefer reads purchases with it and acknowledges them.
  3. Create a Pub/Sub topic, give google-play-developer-notifications@system.gserviceaccount.com the Pub/Sub Publisher role on it, and select it in Play Console → Monetisation setup → Real-time developer notifications.
  4. Add a push subscription on the topic with the endpoint https://apprefer.com/api/billing/google/{appId}?token=<pushToken>, or turn on authentication with your audience and service account and leave out the token. If another service already reads the topic, add a second subscription; each subscription gets every message.
  5. Send a test notification from Play Console and check readiness.

A notification only says something changed: AppRefer always reads the current state from the Play Developer API. It acknowledges purchases as soon as it records them (Google refunds purchases left unacknowledged for 3 days), and treats voided purchases as refunds. A push with the wrong token or signature gets 401.

Connect Stripe (web purchases)

Sell on the web through Stripe Checkout with Stripe Managed Payments, using one Stripe account per product. Web purchases then share entitlements and conversions with the same customer’s app purchases.

FieldWhat it is
stripe.webhookSecretRequired. The endpoint’s signing secret (whsec_…).
stripe.apiKeyA restricted key (rk_…) with read access to Subscriptions, Invoices, Checkout Sessions, Charges and Invoice payments. Treat it as required: webhook payloads can be stale or out of order, so AppRefer re-reads the objects. Without it only subscription-object events and refunds of purchases AppRefer already holds are recorded.
stripe.accountIdThe product’s acct_… id. Events for any other (Connect) account are ignored.
  1. In Stripe → Developers → Webhooks, add the endpoint https://apprefer.com/api/billing/stripe/{appId} with the events checkout.session.completed, checkout.session.async_payment_succeeded, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, customer.subscription.paused, charge.refunded and optionally refund.created.
  2. Save the signing secret and restricted key in billing.stripe. One secret is stored, so connect the live-mode endpoint for production. To try test mode first, use the test endpoint’s secret and a test key, then replace both. Test-mode events are recorded as sandbox.
  3. Give every Price a lookup key. AppRefer uses it as the product id (falling back to the price id), so billing.products keeps working when you replace a price.
  4. Create Checkout Sessions as below.

Each Checkout Session must name the customer:

  • client_reference_id = the app user id (Stripe only allows letters, digits, - and _ here; for other ids rely on the metadata).
  • metadata.app_user_id and subscription_data.metadata.app_user_id = the app user id, so renewals and refunds find the user without the session.
  • subscription_data.metadata.apprefer_account_token = the customer’s account token.
  • Optional web-funnel metadata, passed on to attribution as for RevenueCat web purchases: ar_click_id, landing_url, fbclid, gclid, ttclid, fbp, fbc, web_funnel_session_id, purchase_event_id and utm_*.
TypeScript
// Your web backend (Node, stripe-node). accountToken comes from
// POST /api/billing/v1/customers for the same appUserId (see below).
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: priceIdForLookupKey("pro_yearly"), quantity: 1 }],
  client_reference_id: appUserId,
  metadata: { app_user_id: appUserId },
  subscription_data: {
    metadata: {
      app_user_id: appUserId,
      apprefer_account_token: accountToken,
      // Optional web-funnel signals, passed on to attribution:
      ar_click_id: arClickId,
      landing_url: landingUrl,
    },
  },
  success_url: "https://example.com/welcome",
  cancel_url: "https://example.com/pricing",
  // …plus the Managed Payments settings Stripe gives for your account
});

Your backend gets the account token by registering the customer with the app’s SDK key. The response is the customer’s entitlements plus accountToken:

HTTP
POST /api/billing/v1/customers
X-AppRefer-Key: pk_live_…
Content-Type: application/json

{ "appUserId": "user_123" }

200 { "appUserId": "user_123", "entitlements": {}, "activeSubscriptions": [],
      "requestDateMs": 1792000000000,
      "accountToken": "8f14e45f-ceea-467a-9575-7f8b2c0d1e6a" }

Amounts are recorded before tax (Managed Payments adds tax on top), in the currency’s real units, including zero-decimal currencies such as JPY.

Products and entitlements

billing.products maps each product id to the entitlements it unlocks: the App Store product id, the Google Play subscription id or one-time product id, or the Stripe Price lookup key.

FieldMeaning
entitlementsRequired list of entitlement ids. An empty list unlocks nothing (credits, consumables, add-ons).
typesubscription, non_consumable or consumable.
periodISO duration such as P1W, P1M, P1Y. Used for MRR.
displayName, storeOptional labels for the dashboard.
  • A product that isn’t listed unlocks defaultEntitlement (premium when unset). List every product that should unlock something else, or nothing.
  • Optionally define the entitlement catalogue in billing.entitlements; once it has entries, products may only name those ids.
  • When several purchases unlock the same entitlement, an active one wins, then the latest expiry.
  • Mirror the mapping in the SDK’s entitlementsByProduct; the SDK uses it only when AppRefer can’t be reached.

To choose what a paywall sells from the server, see Offerings, placements and experiments.

Add Billing to your app

Configure attribution first, then Billing with the same SDK key. On Android, Billing is the apprefer-billing module (com.github.AppAgentic.apprefer-android-sdk:apprefer-billing:0.11.0, minSdk 23). In React Native it is the @apprefer/react-native-sdk/billing entry point and needs react-native-iap. Flutter uses the official in_app_purchase plugins. iOS needs iOS 15+ (StoreKit 2).

import AppRefer

Task { await AppRefer.configure(apiKey: "pk_live_...") }
AppReferBilling.configure(
    appUserId: nil,  // nil = anonymous "$ARAnon:<UUID>", kept in the Keychain
    entitlementsByProduct: ["pro_monthly": ["premium"], "pro_yearly": ["premium"]]
)

Let AppRefer Billing finish, acknowledge and consume purchases: don’t call finishTransaction, completePurchase or the store’s own acknowledge/consume yourself. The attribution device id is sent with purchases automatically, so they are matched to the ad click without extra code.

Sign in and out

logIn moves purchases made while anonymous to the signed-in user. Only SDK-made anonymous ids can be merged this way, so a public SDK key can’t pull another real user’s purchases onto a new account. logOut starts a fresh anonymous id.

let info = try await AppReferBilling.logIn("user_123")
await AppReferBilling.setEmail("a@example.com")
_ = try await AppReferBilling.logOut()

Check an entitlement

customerInfo() never throws: it uses a cached answer under 5 minutes old, otherwise asks AppRefer, and if AppRefer can’t be reached it unlocks from the store’s own verified purchases, so a paying user is never locked out by an outage.

let info = await AppReferBilling.customerInfo()          // or customerInfo(fetchPolicy: .fetchCurrent)
if info.entitlements["premium"]?.isActive == true { unlock() }

for await info in AppReferBilling.customerInfoStream { update(info) }

Purchase

purchase tags the purchase with the customer’s account token, sends it to AppRefer for verification and only then completes it with the store. Who is buying is saved on the device before the sheet opens, so a purchase that completes later (Ask to Buy, a slow payment, after a restart or user switch) still goes to them. To sell from offerings instead of fixed product ids, use purchase(package:) (see Offerings).

let products = try await AppReferBilling.products(["pro_monthly", "pro_yearly"])
let result = try await AppReferBilling.purchase(products[1])
switch result.outcome {
case .purchased:     unlock(result.customerInfo)
case .userCancelled: break
case .pending:       showPendingNote()   // arrives later via customerInfoStream
}

Restore

restorePurchases syncs with the store and sends every owned purchase to AppRefer as a restore. Only a restore may move a purchase that belongs to another app user to the one signed in; ordinary purchases and renewals never change ownership.

let info = try await AppReferBilling.restorePurchases()

Any purchase whose purchase date is more than 24 hours old when the SDK reports it is treated as a restore or refresh: it updates the customer’s state but is never sent to ad networks as a new conversion.

Sandbox, TestFlight and App Review

  • App Review, TestFlight and sandbox Apple IDs buy in the sandbox even in your release build. AppRefer accepts those purchases with your live pk_live_ key and grants the entitlement, so reviewers can unlock the app.
  • Sandbox purchases (Apple sandbox, Google Play license testers, Stripe test mode) are recorded as sandbox: kept out of revenue metrics (the dashboard’s sandbox toggle shows them) and never sent to ad networks.
  • A real production purchase sent with a pk_test_ key is refused (400).
  • Transactions from an Xcode StoreKit configuration file are signed with a local certificate, which AppRefer rejects. Test with a sandbox Apple ID; the SDK still unlocks Xcode purchases on the device through its local fallback.

Conversions sent to ad networks

In primary mode, each store event runs through the same attribution and forwarding as a RevenueCat webhook, using RevenueCat’s recommended Meta mapping. Prices are converted to USD with daily exchange rates.

Store eventMetaOther networks
Free trial startsStartTrial, only when qualified trials are on (held, and dropped if cancelled)StartTrial, same rule
First paid subscription paymentSubscribePurchase (TikTok); your mapped Google Ads action
Trial converts to paidSubscribePurchase
RenewalSubscribe (unless renewal forwarding is off for the app)Purchase
One-time purchasePurchasePurchase
Plan change, cancellation, billing issue, expiry, refundNot sent (recorded only)Not sent
  • Change the Meta event names in the app’s Meta integration settings. See Qualified trials for the trial hold.
  • Family Sharing access and sandbox purchases are never sent.
  • Forwarding follows the install’s tracking consent (see Privacy). Purchases with no ad attribution are still sent to your connected ad networks (Meta, TikTok and ChatGPT Ads, and Google when a gclid is known), which credit only what they can match, as RevenueCat does.
  • In shadow mode nothing is sent.

Moving from RevenueCat

You don’t need a RevenueCat data export. Old app versions can keep buying through RevenueCat while new versions use AppRefer Billing. Still deciding? Our RevenueCat alternative comparison covers prices and when RevenueCat is the better pick.

  1. Shadow first. Set mode: "shadow", map your products and connect the stores without moving anything off RevenueCat:
    • App Store: either set RevenueCat’s Apple server notification forwarding URL to the AppRefer URL, or point App Store Connect at AppRefer and put RevenueCat’s notification URL in apple.forwardTo. AppRefer passes each verified notification on unchanged, and if a forward fails it answers 500 so Apple retries (recording is idempotent).
    • Google Play: add a second push subscription on your existing notifications topic.
    • Stripe: add the AppRefer endpoint next to the existing one.
  2. Ship the SDK. On first Billing configure for each install and user, the SDK imports the store’s active purchases in the background (StoreKit 2 current entitlements, Google Play owned purchases). They fill in state without moving purchases already owned by someone else, and being older than 24 hours they create no ad conversions. Keep the same app user ids you used with RevenueCat.
  3. Compare. The Subscriptions page’s “vs RevenueCat” table compares daily counts and revenue while in shadow mode (see Subscription analytics).
  4. Switch to primary. Keep the RevenueCat webhook connected for old app versions. For connected stores it no longer forwards; it saves an ownership link instead (the Apple original transaction id or first Google order id, with the app user, device id and attributes), so later renewals of those purchases find their user. Its money events use the same transaction-based id as AppRefer’s own, so each purchase is forwarded once. If a store notification arrives before RevenueCat’s webhook for a purchase AppRefer can’t yet place, the conversion waits up to an hour for RevenueCat’s copy, then is sent unattributed. Once the Stripe webhook is connected, RevenueCat’s Stripe events are skipped too.
  5. When no old versions remain, remove RevenueCat (and the forwardTo URL).

Each SDK’s README has a RevenueCat-to-AppRefer method table; most names are the same (customerInfo, entitlements["premium"]?.isActive, logIn, restorePurchases).

Outbound webhooks

Send every billing event to your own backend, in RevenueCat’s webhook shape so an existing RevenueCat handler can be reused. It works in shadow mode too, so you can wire it up before switching.

HTTP
PUT /api/v1/apps/{appId}/billing
Authorization: Bearer ak_live_…

{ "webhook": { "url": "https://api.example.com/apprefer/billing", "secret": "at-least-24-random-characters", "enabled": true } }
  • Event types: INITIAL_PURCHASE, RENEWAL (also trial conversions, with is_trial_conversion: true), PRODUCT_CHANGE, NON_RENEWING_PURCHASE, CANCELLATION (auto-renew off; also refunds, with a negative price and cancel_reason: "CUSTOMER_SUPPORT"), UNCANCELLATION, BILLING_ISSUE, SUBSCRIPTION_PAUSED and EXPIRATION. Family-shared access is not sent.
  • event.id is stable per store transaction: make your handler idempotent on it. mode is shadow or primary; while RevenueCat still runs the app, don’t grant access from both webhooks.
  • entitlement_ids lists what the product unlocks (always empty for consumables). If the owner wasn’t known yet, app_user_id is an $ARBillingAnon: id; the event is not sent again later.
  • price is USD (0 for a trial start); price_in_purchased_currency is in currency, the purchase currency. Sandbox events have environment: "SANDBOX".
HTTP
POST https://api.example.com/apprefer/billing
Content-Type: application/json
AppRefer-Signature: t=1792000000,v1=5f2b…c9

{
  "api_version": "1.0",
  "app_id": "app_example",
  "mode": "primary",
  "event": {
    "id": "arb_3c9e…",
    "type": "INITIAL_PURCHASE",
    "app_user_id": "user_123",
    "original_app_user_id": "user_123",
    "aliases": [],
    "product_id": "pro_yearly",
    "period_type": "NORMAL",
    "purchased_at_ms": 1792000000000,
    "expiration_at_ms": 1823536000000,
    "event_timestamp_ms": 1792000001000,
    "environment": "PRODUCTION",
    "store": "APP_STORE",
    "price": 39.99,
    "currency": "USD",
    "price_in_purchased_currency": 39.99,
    "is_trial_conversion": false,
    "transaction_id": "2000000123456789",
    "original_transaction_id": "2000000123456789",
    "country_code": "US",
    "is_family_share": false,
    "offer_code": null,
    "subscriber_attributes": { "$email": { "value": "a@example.com" } },
    "metadata": { "source": "apprefer_billing", "lifecycle": "initial_purchase", "storeEventId": "…" },
    "entitlement_ids": ["premium"]
  }
}

Verify every request. The header is AppRefer-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> with your secret. Reject requests whose t is more than 5 minutes away from now.

import { createHmac, timingSafeEqual } from "crypto";

// rawBody: the request body exactly as received (a string), before JSON.parse.
export function verifyAppReferSignature(rawBody: string, header: string | null, secret: string): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 minutes
  const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
  const got = Buffer.from(String(parts.v1 ?? ""), "utf8");
  const want = Buffer.from(expected, "utf8");
  return got.length === want.length && timingSafeEqual(got, want);
}
Delivery ruleValue
SuccessAny 2xx within 5 seconds. Redirects are not followed.
RetriesFirst attempt straight away, then after 5 minutes, 30 minutes and every 2 hours, up to 40 attempts (about 3 days).
Turned offPending deliveries pause and resume when you turn the webhook on again (records are kept 30 days).
URLhttps on the default port, a public DNS name (no IP address, localhost, .local, .internal or .run.app hosts), no credentials in the URL. The resolved address is checked again on every send.
SecretAt least 24 characters. It is never shown again after you save it.

Check a customer from your backend

For servers that need to know whether a user is premium (a cloud function, a plugin backend), read the same customer info the SDK sees with a private organization key that has billing:read. The account token is never returned here.

HTTP
GET /api/v1/apps/{appId}/billing/customers/{appUserId}
Authorization: Bearer ak_live_…

{ "data": {
    "appUserId": "user_123",
    "entitlements": {
      "premium": {
        "id": "premium",
        "isActive": true,
        "productId": "pro_yearly",
        "store": "app_store",
        "expiresAtMs": 1823000000000,
        "willRenew": true,
        "periodType": "normal",
        "state": "active",
        "subscriptionKey": "2000000123456789"
      }
    },
    "activeSubscriptions": ["pro_yearly"],
    "requestDateMs": 1792000000000
  } }

appUserId is URL-encoded and up to 256 characters. Billing must be on (404 otherwise). Limited to 600 requests a minute per organization.

Promotional grants

Give one customer an entitlement without a purchase: an App Review account, a customer moved over from another billing system, or goodwill from support. Use a private organization key with billing:write (a key with * can't; choose the scope on purpose). There is no SDK call for it.

HTTP
PUT /api/v1/apps/{appId}/billing/customers/{appUserId}/entitlements/premium
Authorization: Bearer ak_live_…
Content-Type: application/json

{ "expiresAt": "2100-12-31T23:59:59Z", "reason": "App Review account" }

{ "data": {
    "grant": { "entitlementId": "premium", "status": "active", "expiresAt": "2100-12-31T23:59:59.000Z",
               "reason": "App Review account", "grantedAt": "…", "grantedBy": "api:…", "revokedAt": null, … },
    "changed": true,
    "customerInfo": { "entitlements": { "premium": { "isActive": true, "store": "promotional",
                      "productId": "ar_promo_premium", "periodType": "promotional", "willRenew": false, … } },
                      "activeSubscriptions": [], … } } }
  • The entitlement must be defined for the app (in the catalogue, or unlocked by a product). expiresAt is an ISO 8601 time with a zone, at least a minute ahead and no later than 2100-12-31T23:59:59Z. reason is required (up to 500 characters).
  • One grant per customer and entitlement. Sending the same grant again changes nothing (changed: false); a new expiresAt replaces the old one. DELETE on the same path (optional body { "reason" }) revokes it at once; access then comes from purchases only.
  • Grants count for the customer and every anonymous id merged into them. When a purchase unlocks the same entitlement, the active one with the later expiry is shown, a lifetime purchase always wins, and an expired or revoked grant never hides a purchase.
  • A grant shows as store: "promotional" and is never a purchase: it isn't in activeSubscriptions, revenue, the subscription metrics, outbound webhooks or ad-network conversions. Every change is in the audit log. SDKs read the unfamiliar store as unknown and still unlock the entitlement.

Billing readiness

For release checks, GET /api/v1/apps/{appId}/billing/readiness with an organization key that has analytics:read, apps:read, billing:read or integrations:read shows the mode, which stores are connected, their public ids, whether each secret is set, when each store last reached AppRefer and from which environments, catalogue counts and the outbound webhook state. It returns booleans, ids, counts and times only, never a secret. Notification times are accurate to about 5 minutes and include test notifications, so a test notification proves the URL. Times are recorded from October 2026 onwards. The admin MCP tool get_app_readiness returns the same, with Support readiness.

HTTP
GET /api/v1/apps/{appId}/billing/readiness
Authorization: Bearer ak_live_…

{ "data": {
  "mode": "primary",
  "apple": { "configured": true, "bundleId": "com.example.app", "appAppleId": 1234567890,
             "hasServerApiKey": true, "forwardTo": 0,
             "lastNotificationAt": "2026-10-16T09:00:00.000Z", "environmentsSeen": ["sandbox"],
             "lastNotificationAtByEnvironment": { "production": null, "sandbox": "2026-10-16T09:00:00.000Z" } },
  "google": { "configured": true, "packageName": "com.example.app", "hasServiceAccount": true,
              "pushAuth": "token", "lastNotificationAt": null },
  "stripe": { "configured": false, "accountId": null, "hasApiKey": false, "hasWebhookSecret": false,
              "lastEventAt": null, "environmentsSeen": [] },
  "products": 3, "entitlements": 1, "offerings": 1, "currentOfferingId": "default",
  "webhook": { "configured": false, "enabled": false } } }

SDK endpoints

The SDKs call these with the public SDK key (X-AppRefer-Key). You only need them for a custom client or a web backend. They answer 404 while billing is off, and are limited to 120 requests a minute per IP.

EndpointBody / queryDoes
POST /api/billing/v1/customersappUserId, appreferId?, attributes?Registers the customer; returns customer info plus accountToken.
GET /api/billing/v1/customers?appUserId=Customer info without the account token.
POST /api/billing/v1/purchasesappUserId, store app_store + signedTransaction (StoreKit 2 JWS), or store play_store + purchaseToken, productType, productId (one-time); restore?, presentedOfferingContext?, bucketId?Verifies, records (and acknowledges on Google) one purchase; returns customer info and purchase.
POST /api/billing/v1/identifyanonymousId ($ARAnon:<uuid>), appUserIdlogIn: moves the anonymous customer’s purchases to appUserId.
POST /api/billing/v1/offeringsappUserId, platform, …See Offerings.

Customer info has this shape (state is active, in_grace_period, in_billing_retry, paused, expired or revoked):

JSON
{
  "appUserId": "user_123",
  "entitlements": {
    "premium": {
      "id": "premium",
      "isActive": true,
      "productId": "pro_yearly",
      "store": "app_store",
      "expiresAtMs": 1823000000000,
      "willRenew": true,
      "periodType": "normal",
      "state": "active",
      "subscriptionKey": "2000000123456789"
    }
  },
  "activeSubscriptions": ["pro_yearly"],
  "requestDateMs": 1792000000000
}