@encorekit/react-native
v2.0.2
Published
Encore React Native bridge — thin native module layer delegating to encore-swift-sdk (iOS) and encore-android-sdk (Android)
Readme
Encore React Native SDK
Thin React Native bridge to the native Encore iOS SDK and Encore Android SDK. Presents retention offers with native UI and delegates purchases to your existing subscription manager.
Upgrading from 1.x? 2.0 is a breaking change:
onPurchaseRequest/completePurchaseRequest/onPurchaseComplete/onPassthroughare gone, andshow()returns a different shape. See MIGRATION.md.
Installation
npm install @encorekit/react-nativeiOS
cd ios && pod installAndroid
No additional setup required — com.encorekit:encore resolves from Maven Central, which is included by default in Android projects. The React Native module auto-links with RN 0.60+.
Quick Start
1. Wrap Your App with EncoreProvider
import { EncoreProvider } from '@encorekit/react-native';
import type { PurchaseController } from '@encorekit/react-native';
// Encore decides *when* a purchase happens; your app decides *how*.
const purchaseController: PurchaseController = async ({ productId }) => {
const outcome = await RevenueCat.purchase(productId);
if (outcome.userCancelled) return 'cancelled';
if (outcome.deferred) return 'pending';
return 'purchased';
};
export default function App() {
return (
<EncoreProvider
apiKey="your_api_key"
options={{ logLevel: 'debug' }}
purchaseController={purchaseController}
>
<YourApp />
</EncoreProvider>
);
}The provider calls configure once, on mount, with the controller as part of
it — the same shape as
Encore.shared.configure(apiKey:purchaseController:options:) on iOS,
Encore.configure(context, apiKey, purchaseController = …) on Android and
Encore.shared.configure(apiKey: …, purchaseController: …) on Flutter. Binding
the controller is configuring, so there is no window in which the SDK is
configured and your purchase path is not.
Outside React, configure directly:
await Encore.configure('your_api_key', {
logLevel: 'debug',
purchaseController,
});If you have no in-app purchase in your Encore flows, omit it. The SDK then
never attempts a purchase, and every presentation records
publisher: 'not_attempted' — the honest record, not a failure.
2. Identify Users
import Encore from '@encorekit/react-native';
// After authentication
Encore.identify('user_123', {
email: '[email protected]',
subscriptionTier: 'premium',
});
// On logout
Encore.reset();3. Show a Placement
const result = await Encore.placement('paywall').show();
if (result.status === 'presented') {
const converted = result.claim !== null || result.publisher === 'purchased';
if (converted) grantAccess();
}To present the post-action reward surface instead of the churn-intervention sheet — a brand-funded reward at a moment the user has just accomplished something — set the use case and supply your own copy:
import Encore, { UseCase } from '@encorekit/react-native';
const result = await Encore.placement('streak_complete')
.useCase(UseCase.rewardUsers)
.headline('7 day streak!')
.subheadline("Here's a little thank you from us")
.show();4. Observe Outcomes
onOutcome is the passive observation channel — analytics forwarding, app-owned
state, debugging overlays. It mirrors Swift's AsyncStream<PlacementOutcome> and
Kotlin's SharedFlow<PlacementOutcome>, and unlike the 1.x on* setters it
appends, so several observers can listen at once.
useEffect(
() =>
Encore.onOutcome((outcome) => {
if (outcome.type === 'presentation') {
analytics.track('encore_presentation', outcome.result);
} else {
// A strict-unlock claim verified server-side after its flow ended —
// possibly on a later launch. Join back via ClaimedOffer.transactionId.
refreshEntitlements();
}
}),
[],
);There is no replay: subscribe at startup, or you will miss outcomes emitted
before you subscribed. Control flow belongs at the call site, on the value
show() returns — the stream is for observing.
Reading a PresentationResult
The result is the factual record of a presentation. Either nothing appeared, or the interaction ran and the record carries two independent funnels plus how it ended.
type PresentationResult =
| { status: 'not_presented'; reason: NotPresentedReason }
| {
status: 'presented';
advertiser: AdvertiserOutcome; // Encore's funnel: how far the claim got
publisher: PublisherOutcome; // your funnel: what your controller said
dismissal: DismissReason; // how the sheet went away
claim: ClaimedOffer | null; // shorthand for a claimed/verified offer
};There is no SDK-computed "did this unlock" verdict, because what a claim means is a property of the variant flow that served it — any projection over app-global config could contradict the flow that actually ran. Branch on the raw axes instead:
const converted =
result.status === 'presented' &&
(result.claim !== null || result.publisher === 'purchased');The two axes are independent. A user can claim an advertiser offer and decline
the purchase; both are recorded. 'not_attempted' is a real value — "the funnel
was open and nothing entered it" — never a missing one.
Platform-specific values
The vocabularies are shared and frozen, but a few members only ever come from one platform:
| Value | Only on |
|:------|:--------|
| unsupported_ios | iOS |
| no_foreground, iap_first_declined | Android |
| user_cancelled, last_offer_declined, dismissed (dismissal) | iOS |
ClaimedOffer.offerId and .campaignId are both always present. On iOS an
offer is a campaign and the two carry the same value; the bridge emits both so
one JS shape serves both platforms.
API Reference
configure(apiKey, options?)
Initialize the SDK. Called automatically by EncoreProvider.
| Parameter | Type | Description |
|:----------|:-----|:------------|
| apiKey | string | Your Encore API key |
| options.purchaseController | PurchaseController | Your purchase code — the SDK's single purchase path |
| options.logLevel | 'none' \| 'error' \| 'warn' \| 'info' \| 'debug' | Log verbosity (default: 'none') |
| options.unlock | 'optimistic' \| 'strict' | How a claim grants access (default: 'optimistic') |
'strict' verifies claims server-side, persisting unverified ones and
re-checking across launches; those settle later as a strict_unlock_verified
outcome on onOutcome.
The purchase controller
Encore decides when a purchase happens; your app decides how.
type PurchaseController = (
request: PurchaseRequest,
) => Promise<PurchaseResult> | PurchaseResult;
type PurchaseResult = 'purchased' | 'cancelled' | 'pending';Return one of the three results, or throw for a real failure — a throw is
recorded as publisher: 'failed' with your message, and the flow continues. Map
your billing layer's "user cancelled" error to 'cancelled' rather than
throwing it.
'pending' is not a failure: an Ask-to-Buy or SCA purchase may settle minutes or
days later, and the store webhook is the source of truth. This is the reason the
1.x boolean completePurchaseRequest(success) is gone — a boolean could only
report a deferred purchase as a decline.
request carries productId, plus placementId, promoOfferId (iOS) and
basePlanId (Android) where they apply. Android's native controller also
receives an Activity; the bridge absorbs it, because a React Native host's
billing library resolves the current Activity itself.
setPurchaseController(controller)
Replaces the controller bound at configure(). The controller the native SDK
holds is the bridge itself and always forwards to the current handler, so a swap
is a JS-side concern that takes effect on the next purchase request. Use it when
the controller genuinely has to change at runtime — a billing stack that only
exists after login, say.
await Encore.setPurchaseController(newController);Calling it before configure() also still registers a purchase path, which is
how 2.0 wired one up. What no bridge can do is add a controller to an SDK that
was already configured without one: both native SDKs take it as a configure()
argument and Android has no setter, so that call warns and resolves
{ success: false }. Pass the controller to configure instead.
identify(userId, attributes?)
Associate a user ID with SDK events.
setUserAttributes(attributes)
Merge attributes into the current user profile.
reset()
Clear user data and generate a new anonymous ID. Call on logout. The registered purchase controller survives — it is build-time wiring, not user state.
setClaimEnabled(enabled)
Grays out and disables the claim CTA on offer cards. Runtime-mutable.
placement(placementId)
Returns a PlacementBuilder. Each option returns a new builder, so a builder is
safe to hold and branch from.
| Method | Type | Description |
|:-------|:-----|:------------|
| useCase(useCase) | UseCase | Which surface to present (default: UseCase.reduceChurn) |
| headline(text) | string | Overrides the sheet headline, on every use case |
| subheadline(text) | string | Overrides the sheet subheadline, on every use case |
| show() | — | Presents the sheet. Resolves with PresentationResult; never rejects |
Copy resolves in strict priority: the value you pass here, then the value configured in the Encore portal, then the template default. A blank string is ignored, so the chain falls through rather than rendering an empty line.
headline / subheadline apply to both use cases — the native side writes
them into the variable the active template reads: ${customHeadline} /
${customSubheadline} on churn intervention, and the reward template's own
equivalents on post-action reward.
Use cases
UseCase names the capability; its values are the wire format the backend
stores. The two are deliberately unrelated, so renaming a key never changes
what goes over the wire.
| Value | Wire slug | Presents |
|:------|:----------|:---------|
| UseCase.reduceChurn | 'churn-intervention' | Second-chance monetisation after a paywall decline. The default. |
| UseCase.rewardUsers | 'post-action-reward' | A brand-funded reward for something the user just did. Claim-only; never presents IAP. |
These are the same names the iOS
(.reduceChurn) and Android
(REDUCE_CHURN) SDKs use, so a placement reads the same on all three.
Because UseCase is a const object rather than a TypeScript enum, its type
resolves to the wire slugs — .useCase('post-action-reward') still compiles
and still sends the same bytes. The capability names are the documented form;
the slugs are accepted, not deprecated.
A use case with no enabled variant resolves to
{ status: 'not_presented', reason: { type: 'use_case_unavailable' } } rather
than falling back to the churn-intervention sheet — presenting an in-app
purchase at a moment the user was meant to be rewarded is worse than presenting
nothing. Enable the use case in the Encore portal to fix it.
onOutcome(handler)
Subscribes to the outcomes stream. Returns an unsubscribe function. Appends rather than replaces — several observers can listen at once.
type PlacementOutcome =
| { type: 'presentation'; placementId: string; result: PresentationResult }
| { type: 'strict_unlock_verified'; transactionId: string };EncoreProvider
React context provider. Props:
| Prop | Type | Description |
|:-----|:-----|:------------|
| apiKey | string | Your Encore API key |
| options | ConfigureOptions | Optional configuration |
| purchaseController | PurchaseController | Your purchase code, forwarded into configure() |
useEncoreContext()
Hook returning the full EncoreSDK interface. Must be used within EncoreProvider.
Types
interface UserAttributes {
email?: string;
firstName?: string;
lastName?: string;
phoneNumber?: string;
postalCode?: string;
city?: string;
state?: string;
countryCode?: string;
latitude?: string;
longitude?: string;
dateOfBirth?: string;
gender?: string;
language?: string;
subscriptionTier?: string;
monthsSubscribed?: string;
billingCycle?: string;
lastPaymentAmount?: string;
lastActiveDate?: string;
totalSessions?: string;
custom?: Record<string, string>;
}
type PurchaseResult = 'purchased' | 'cancelled' | 'pending';
interface PurchaseRequest {
productId: string;
placementId?: string;
promoOfferId?: string; // iOS
basePlanId?: string; // Android
}
interface ClaimedOffer {
offerId: string;
campaignId: string;
advertiserName: string;
transactionId?: string;
}
interface EncoreErrorInfo {
type: string; // e.g. 'network_error', 'http_error', 'not_configured'
message: string;
}
type AdvertiserOutcome =
| { type: 'not_attempted' }
| { type: 'claimed'; offer: ClaimedOffer }
| { type: 'verified'; offer: ClaimedOffer }
| { type: 'cooldown' }
| { type: 'failed'; error: EncoreErrorInfo };
type PublisherOutcome =
| 'not_attempted'
| 'purchased'
| 'cancelled'
| 'pending'
| 'failed';
type DismissReason =
| 'close_button'
| 'swipe_dismiss'
| 'user_cancelled'
| 'last_offer_declined'
| 'dismissed'
| 'provisional_cooldown'
| 'flow_completed'
| 'interrupted';
type NotPresentedReason =
| {
type:
| 'not_configured'
| 'already_presenting'
| 'unsupported_ios'
| 'no_offer_available'
| 'experiment_control'
| 'use_case_unavailable'
| 'no_foreground'
| 'iap_first_declined';
}
| { type: 'error'; error: EncoreErrorInfo };
type PresentationResult =
| { status: 'not_presented'; reason: NotPresentedReason }
| {
status: 'presented';
advertiser: AdvertiserOutcome;
publisher: PublisherOutcome;
dismissal: DismissReason;
claim: ClaimedOffer | null;
};
// Keys name the capability; values are the frozen wire format.
const UseCase = {
reduceChurn: 'churn-intervention',
rewardUsers: 'post-action-reward',
} as const;
type UseCase = (typeof UseCase)[keyof typeof UseCase];
interface PlacementOptions {
useCase?: UseCase;
headline?: string;
subheadline?: string;
}Requirements
- React Native 0.60+
- iOS 15.0+
- Android API 26+
- Node 16+
Note on Entitlements
Entitlement tracking is handled by the native SDKs and your subscription manager. The React Native bridge does not expose entitlement query methods directly — use your subscription manager's React Native SDK (RevenueCat, Adapty, etc.) for entitlement checks.
License
MIT
