@kebabty/react-native
v0.8.3
Published
Kebabty subscription platform SDK for React Native — entitlements, paywalls and store purchase validation
Maintainers
Readme
@kebabty/react-native
Kebabty subscription SDK for React Native. StoreKit 2 on iOS, Play Billing on Android, with identity, entitlements, dashboard paywalls and server-side validation on both.
Install
npm install @kebabty/react-native
cd ios && pod installRequires React Native 0.76+ and React 18+. iOS deployment target 15.1
(StoreKit 2); Android minSdk 24, compileSdk/targetSdk 36, JDK 17. The Android
module autolinks through Gradle — no install step of its own — and adds
com.android.installreferrer:installreferrer:2.2 and
com.android.billingclient:billing:9.1.0 to your app. Override the billing
version with ext.playBillingVersion if you need to, but not below 8.1.
Setup
import { Kebabty, KebabtyProvider } from '@kebabty/react-native'
await Kebabty.configure({ apiKey: 'pk_kbty_…' })That is the whole setup. The origin, the app version and persistent storage are the SDK's own — it reads the version off your app bundle and persists through its native module (UserDefaults on iOS, SharedPreferences on Android). The only other thing worth passing is something only your app knows:
await Kebabty.configure({
apiKey: 'pk_kbty_…',
// pass the id here when the user is already signed in at launch
appUserId: user ? String(user.id) : undefined,
})Wrap the app in <KebabtyProvider> to let fire() present paywalls.
configure() restores the identity of the previous run, identifies the install,
and picks up any StoreKit transaction left unfinished.
Identity
await Kebabty.identify(String(user.id)) // after login
await Kebabty.logout() // after logout — continues with a fresh anonymous idAn anonymous id stops resolving a profile once an appUserId claims it, so the
SDK persists both ids and re-sends the appUserId on every launch. Two things
follow:
logout()is not optional. The identity survives reinstall-free app restarts on its own; the only thing that clears it islogout(). An app that signs a user out without calling it keeps reporting purchases against the previous customer.configure({ appUserId })still wins over the remembered id, so passing it is harmless and is the right thing to do when your own store already knows who is signed in at launch.
Kebabty.getAppUserId() returns the identity currently in force.
Signed identity (optional)
An app that sets an identity secret in the dashboard must prove every
appUserId it claims. The secret never ships in the binary — your backend
computes hex(hmac_sha256(identity_secret, appUserId)) and the SDK sends it as
X-Kebabty-Signature:
await Kebabty.configure({
apiKey: 'pk_kbty_…',
identitySignature: (appUserId) => api.get(`/kebabty-signature/${appUserId}`),
})The callback is asked once per identity, cached, and re-asked if the digest is
ever rejected (a rotated secret). A literal digest works too, via
configure({ appUserId, identitySignature }), identify(id, { signature }) or
Kebabty.setIdentitySignature(digest).
A literal digest only ever covers the appUserId passed alongside it, never one
restored from a previous launch — so an app that signs identity and uses a
literal digest has to pass appUserId to configure() every time. If it does
not, the launch cannot sign the remembered id, the server refuses it, and the
SDK warns and falls back to anonymous. The callback form has no such rule.
Apps with no identity secret pass nothing and behave exactly as before.
Purchases
const profile = await Kebabty.purchase(storeProductId)
const restored = await Kebabty.restorePurchases()
profile.entitlements.premium?.isActivepurchase() resolves only after the backend has recorded the purchase, and only
then is it settled with the store — finished on StoreKit, acknowledged on Play.
Nothing else settles it: a purchase the server never accepted stays open so a
later launch can retry it. On Play that ordering has a deadline attached, since
Google refunds anything unacknowledged after three days.
Play does not replace one subscription with another — it sells both. When the user is switching plans, name the one they are leaving:
await Kebabty.purchase('premium_yearly', {
replaceStoreProductId: 'premium_monthly',
replacementMode: 'charge_prorated_price',
})Both fields are ignored on iOS, where the subscription group settles it.
A 409 (KebabtyPurchaseConflictError) means the purchase is registered to
another Kebabty profile — permanent for the account asking, not for the device.
The SDK stops replaying that transaction instead of destroying it, and tries it
once more the moment identify() names a different user, which is exactly what
the refusal tells the customer to do. The error carries .profile: the caller's
own record as the server returned it with the refusal.
Restoring
restorePurchases() re-validates everything the store account currently grants —
StoreKit's entitlements, or what the signed-in Google account still owns — and
returns the profile the server ends up holding — a 200 from validate is
not by itself a restore. It throws when the store had something and the account
still holds nothing:
| throws | meaning |
| ------------------------------------- | ------------------------------------------------------------- |
| KebabtyPurchaseConflictError | held by another account — your transfer policy keeps it there |
| KebabtyRestoreError family_shared | shared through Family Sharing, so it never leaves the buyer (iOS) |
| KebabtyRestoreError not_granted | accepted, but no entitlement maps to the product |
An Apple ID with nothing to restore is not an error: the profile comes back unchanged.
Errors
Everything the SDK throws is a KebabtyError with a code:
| code | meaning |
| -------------------- | -------------------------------------------------- |
| network / server | transient; retryable is true |
| unauthorized | the SDK key was rejected |
| identity_signature | signed identity required, missing or wrong |
| profile_not_found | no profile for these ids (the SDK recovers itself) |
| purchase_conflict | the transaction belongs to another account |
| purchase_invalid | Apple would not vouch for the transaction |
| restore_incomplete | restored everything the store had, granted nothing |
message is the developer-facing text; displayMessage is safe to show a user
and is what the built-in paywall UI renders.
try {
await Kebabty.purchase(id)
} catch (error) {
if (error instanceof KebabtyPurchaseConflictError) showRestoreHelp()
else if (error instanceof KebabtyError) toast(error.displayMessage)
}Analytics
fire() presents the paywall mapped to an action key and reports impression,
CTA, purchase, restore, close and dismiss-survey answers itself. Events are
batched, persisted across launches and deduplicated server-side.
Observer mode
Already have in-app-purchase code and keeping it?
await Kebabty.configure({ apiKey: 'pk_kbty_…', purchasesCompletedBy: 'app' })Kebabty stops buying and stops completing: purchase() refuses, a StoreKit
transaction is validated and never finished, a Play purchase is validated and
never acknowledged on the device. It still watches the store and still keeps
entitlements, audiences, experiments and analytics working. Your code buys,
delivers, completes with the store, and then calls syncPurchases().
The built-in paywalls still work — hand them your purchase code:
await Kebabty.fire('LOCKED_POST', { onPurchase: (id) => yourIap.buy(id) })On Android there is a second switch: set the app's acknowledgement policy to the host app in the dashboard too, or the backend keeps acknowledging Play purchases out from under your code. Set both, or neither — the SDK warns once and the Lab shows a failure while they disagree. iOS needs only the SDK switch. See the Observer mode page in the docs.
Test mode
await Kebabty.configure({ apiKey: 'pk_kbty_…', mode: 'test' })A LAB badge rides in the corner — drag it to either edge, tap to open — and everything the SDK knows is behind it: the profile and its entitlements, the catalog joined with what StoreKit will actually sell, every request with both bodies, every analytics event and whether it landed, every paywall payload with its audience and experiment arm, and the transactions StoreKit is still holding. It opens on a diagnostics list that names the usual integration failures — a pod that is not in the build, product ids the store does not know, a missing provider, a rejected key — with the fix on the row.
A debug build shows it without being asked. A release build that did not name
mode: 'test' renders nothing and records nothing. Nothing else about the SDK
changes.
See CHANGELOG.md for what changed in 0.3.0.
