@redchurn/react-native-sdk
v0.5.0
Published
RedChurn React Native SDK — cancel save, billing recovery and win-back flows
Maintainers
Readme
@redchurn/react-native-sdk
Stop subscription churn from inside your React Native app. Redchurn turns the three moments that quietly lose you revenue — a tapped Cancel, a failed payment, a lapsed subscriber — into native in-app retention flows you control from a dashboard, without shipping a new build.
<RedchurnProvider config={{ sdkKey: "rdchrn_live_…", rcAppUserId: user.rcId }}>
<App />
</RedchurnProvider>That's the install. Titles, offers, surveys, styling, and on/off switches live in your Redchurn dashboard and reach the app in under a minute.
- 🛟 Cancel Save — intercept the cancel tap, run an optional survey, present retention offers (pause / downgrade / discount / support).
- 💳 Billing Recovery — detect a failed payment and prompt the user to fix it, on-screen and via notifications.
- 👋 Win-back — re-engage churned subscribers with a tailored offer.
- 🎛 Remote-driven — copy, styling, offers, and flow toggles propagate live (≤ 60 s). No App Store / Play release required.
- 🧱 Drop-in components and headless hooks if you want your own UI.
- 🔔 Notifications — local reminders via Notifee + server push via FCM/APNs.
- 🛡 Fail-safe — never throws into your app, never blocks a cancellation, degrades to a no-op when misconfigured.
Version 0.4.0 · React 18+ · React Native 0.72+ · works in Expo dev builds (or use
@redchurn/expofor the managed workflow). Pairs with RevenueCat.
Using Expo's managed workflow? Install
@redchurn/expoinstead — it re-exports everything here and adds an Expo config plugin + anexpo-notificationsadapter.
🇫🇷 Documentation en français : Guide d'intégration Redchurn
Table of contents
- How it works
- Installation
- Quick start
- Provider configuration
- Flow 1 — Cancel Save
- Applying a cancel offer
- Flow 2 — Billing Recovery
- Flow 3 — Win-back
- Notifications (local + push)
- Headless hooks
- Fail-safe guarantees
- Public API
- Troubleshooting
How it works
Redchurn is install-once, configure-from-the-dashboard (the same model as RevenueCat Paywalls or Superwall). You mount the provider once; everything the subscriber sees is edited remotely.
Which prompt a user is eligible for is decided by their subscriber status
(active, billing_issue, churned), which Redchurn learns from your RevenueCat
webhook. The SDK polls a tiny /version endpoint and only re-downloads config
when it actually changed.
| Flow | Trigger | Component | Hook |
| --- | --- | --- | --- |
| Cancel Save | User taps your "Cancel" button | CancelSaveFlow | useCancelFlow |
| Billing Recovery | Subscriber billing_issue | BillingRecoveryFlow | useBillingRecovery |
| Win-back | Subscriber churned | WinBackFlow | useWinBack |
Installation
npm install @redchurn/react-native-sdk @notifee/react-native
# or
yarn add @redchurn/react-native-sdk @notifee/react-native
# Expo dev build
npx expo install @redchurn/react-native-sdk @notifee/react-nativePeer dependencies (all notification-related ones are optional — the SDK runs without them, just without local notifications / FCM push):
| Peer | Required? | Used for |
| --- | --- | --- |
| react ≥ 18, react-native ≥ 0.72 | yes | — |
| @notifee/react-native ≥ 9 | optional | Local billing/win-back notifications |
| @react-native-firebase/messaging ≥ 18 | optional | FCM/APNs server-push tokens |
| @react-native-async-storage/async-storage | optional | Persisting background notification taps |
Quick start
// index.js — register the background handler ONCE, outside React.
// Required so notification taps work while the app is backgrounded/killed.
import { registerRedchurnBackgroundEventHandler } from "@redchurn/react-native-sdk";
registerRedchurnBackgroundEventHandler();// App.tsx
import {
RedchurnProvider,
CancelSaveFlow,
BillingRecoveryFlow,
WinBackFlow,
} from "@redchurn/react-native-sdk";
export default function App() {
const rcAppUserId = user.revenueCatAppUserId;
return (
<RedchurnProvider
config={{
sdkKey: "rdchrn_live_xxxxxxxx",
rcAppUserId,
notificationsEnabled: true,
androidNotificationSmallIcon: "ic_notification", // white silhouette drawable
}}
>
<Home rcAppUserId={rcAppUserId} />
</RedchurnProvider>
);
}
function Home({ rcAppUserId }) {
return (
<>
{/* Render only when the subscriber actually needs them */}
<BillingRecoveryFlow rcAppUserId={rcAppUserId} />
<WinBackFlow rcAppUserId={rcAppUserId} onCtaPress={() => openPaywall()} />
{/* Intercept your own cancel button */}
<CancelSaveFlow
rcAppUserId={rcAppUserId}
onOfferAccepted={applyOffer} // see "Applying a cancel offer"
render={({ interceptCancel }) => (
<Button
title="Cancel subscription"
onPress={async () => {
if (!(await interceptCancel())) openStoreSubscriptions();
}}
/>
)}
/>
</>
);
}Provider configuration
RedchurnProvider takes a single config object. Only sdkKey is required.
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| sdkKey | string | required | Live key rdchrn_live_…. |
| rcAppUserId | string | — | RevenueCat App User ID. Required to fetch prompts / record outcomes. |
| apiBaseUrl | string | https://app.redchurn.io | Override for dev/staging. |
| locale | string | auto | BCP-47 tag; server falls back to English. |
| platform | "ios" \| "android" | auto | Sent on register. |
| debug | boolean | false | Log SDK diagnostics. |
| onError | (error: RedchurnError) => void | — | Non-fatal error sink (never throws). |
| autoRegister | boolean | true | Register + heartbeat at mount. |
| prefetchConfig | boolean | true | Fetch remote config at mount. |
| versionPollIntervalMs | number | 60000 | /version poll cadence. 0 disables. |
| configCacheTtlMs | number | 60000 | Local config cache TTL. |
| notificationsEnabled | boolean | true | Schedule local billing/win-back notifications. |
| androidNotificationSmallIcon | string | ic_launcher | Drawable name (use a white silhouette). |
| pushToken / pushProvider | string / "fcm" \| "apns" | — | Provide a server-push token explicitly. |
| deviceLabel | string | — | Label shown in the dashboard. |
| pushEnvironment | "production" \| "sandbox" | __DEV__ ? sandbox : production | APNs environment. |
Set the user later if it isn't known at mount:
const { setRCAppUserId } = useRedchurn();
setRCAppUserId(user.revenueCatAppUserId);Flow 1 — Cancel Save
Intercept your own "Cancel subscription" button before opening the store's
cancellation UI. interceptCancel() returns true when a save sheet is shown (stop
there) or false when there's nothing to show (proceed to the store).
<CancelSaveFlow
rcAppUserId={rcAppUserId}
onOfferAccepted={applyOffer}
onUnavailable={(reason) => console.log("No save sheet:", reason)}
render={({ interceptCancel }) => (
<Button
title="Cancel subscription"
onPress={async () => {
if (!(await interceptCancel())) openStoreSubscriptions();
}}
/>
)}
/>Omit render to get the batteries-included sheet (survey → offers, styling,
accessibility). Provide render for full control while keeping the SDK's logic.
onUnavailable reasons: "no_user", "flow_disabled", "no_offers", "ready",
"already_open".
Fail-open: if the SDK can't show a sheet for any reason,
interceptCancel()returnsfalseand the user always reaches the store. You never trap anyone.
Applying a cancel offer
When the user accepts an offer, your onOfferAccepted handler applies it through
RevenueCat/StoreKit. Redchurn records the save only after your handler resolves
without throwing.
import { getCancelOfferApplicationParams } from "@redchurn/react-native-sdk";
async function applyOffer(offer) {
const params = getCancelOfferApplicationParams(offer);
switch (params.type) {
case "discount":
// Apply a StoreKit/Play promotional offer via react-native-purchases
await applyDiscountWithRevenueCat(params);
break;
case "pause":
await pauseSubscription({ months: params.pauseMonths });
break;
case "downgrade":
openPaywall({ productId: params.targetProductId });
break;
case "support":
Linking.openURL(params.contactUrl ?? `mailto:${params.contactEmail}`);
break;
}
}Prefer a declarative style? applyCancelOffer(offer, { discount, pause, downgrade,
support }) dispatches to the matching handler for you.
Throw on failure. If applyOffer throws, the sheet stays open and no save is
credited — exactly what you want when a purchase fails or is cancelled.
If a user un-cancels later via the store without going through an offer, the RevenueCat
UNCANCELLATIONwebhook still counts as a save server-side. No double counting — the backend de-duplicates by event id.
Flow 2 — Billing Recovery
Renders nothing until the subscriber is in billing_issue. Mount it once near the
top of your home screen.
<BillingRecoveryFlow
rcAppUserId={rcAppUserId}
presentation="banner" // "banner" (default) or "sheet"
onCtaPress={() => openUpdatePaymentFlow()}
/>For custom UI use useBillingRecovery({ rcAppUserId }) → { visible, title,
message, ctaLabel, ctaUrl, check, dismiss, markRecovered }.
Flow 3 — Win-back
Renders only for churned subscribers.
<WinBackFlow rcAppUserId={rcAppUserId} onCtaPress={() => openReactivationPaywall()} />Custom UI: useWinBack({ rcAppUserId }) → { visible, title, message, ctaLabel,
ctaUrl, dismissLabel, check, dismiss, markReactivated }.
Notifications (local + push)
Billing-recovery and win-back can reach users two ways. Use both:
| Channel | Delivered by | Works when app is closed? | | --- | --- | --- | | Local | Notifee, scheduled by the SDK while the app runs | ✅ once scheduled | | Server push | Redchurn via FCM/APNs, triggered by the RevenueCat webhook | ✅ even if never reopened |
Local (Notifee)
- Keep
notificationsEnabled: true. - Add a white silhouette drawable and pass its name as
androidNotificationSmallIcon(not your colored launcher icon). - On Android 13+, call
requestNotificationPermission()once after login. - Register
registerRedchurnBackgroundEventHandler()inindex.js(see Quick start) so taps work when the app is backgrounded.
import { requestNotificationPermission } from "@redchurn/react-native-sdk";
await requestNotificationPermission();Server push (FCM/APNs)
With @react-native-firebase/messaging installed, the SDK auto-registers the FCM
token. To pass a token yourself, set pushToken / pushProvider in the provider
config, or use the notifications API from useRedchurnNotifications().
Preview on-device
import { scheduleTestNotification } from "@redchurn/react-native-sdk";
await scheduleTestNotification({ title: "Payment failed", body: "Update your card." });Deeplinks (email → app)
Open the right flow when a user taps a link — e.g. an "Update payment" button in
a billing-recovery email — and track the open for attribution. Your app owns its
URL scheme / universal links; the SDK interprets the URL. A Redchurn deeplink
carries a flow (plus optional step, campaign, source):
myapp://redchurn?flow=billing_recovery&step=payment_failed&campaign=oct&source=email
https://app.example.com/r?flow=win_back&source=emailMount the handler once near your root:
import { useRedchurnDeeplinkHandler } from "@redchurn/react-native-sdk";
useRedchurnDeeplinkHandler({
onBillingRecovery: () => billing.check(),
onWinBack: () => winBack.check(),
});It handles cold-start and live links, ignores non-Redchurn URLs, and fires a
LINK_OPENED event with { flow, source, campaign } so opens are attributable in
the dashboard. Use parseRedchurnDeeplink(url) to parse a URL yourself.
One hook for both. Notification taps and deeplinks route to the same flows, so
useRedchurnOpenHandler({ onBillingRecovery, onWinBack })wires both with a single set of callbacks. Reach for the two hooks directly only if you need to handle the sources differently.
Configure your app's deeplink base URL in the dashboard so billing-recovery / win-back emails automatically link to the app with these params (they fall back to the web CTA when it's unset).
Headless hooks
Build your own UI on the same logic the components use:
| Hook | Returns |
| --- | --- |
| useRedchurn() | Context: { ready, remoteConfig, refreshConfig, setRCAppUserId, trackEvent, recordOutcome, getPrompts, isFlowInAppEnabled, … } |
| useCancelFlow(options) | { isVisible, session, busy, offers, interceptCancel, selectSurveyReason, handleOutcome, dismiss } |
| useBillingRecovery({ rcAppUserId }) | { visible, title, message, ctaLabel, ctaUrl, check, dismiss, markRecovered } |
| useWinBack({ rcAppUserId }) | { visible, title, message, ctaLabel, ctaUrl, dismissLabel, check, dismiss, markReactivated } |
| useRedchurnLifecycle() | Tracks APP_OPEN on mount + foreground |
| useRedchurnNotifications() | Sync/cancel local notifications, register push tokens, schedule test |
| useRedchurnNotificationHandler() | Wires foreground/background/pending tap routing |
Fail-safe guarantees
| Rule | Consequence for your app |
| --- | --- |
| Never throws to your code | API calls return result objects / sensible defaults. |
| Fail-open on cancel | If tracking is down, the user can still cancel. |
| No-op on bad key | An invalid sdkKey → flows render nothing, no crash. |
| 12 s timeout + bounded retries | Only /outcomes is retried (it credits saved MRR). |
| Isolated onError | Your callback is non-throwing. |
Public API
// Provider & context
RedchurnProvider, useRedchurn
// Flow components (batteries-included)
CancelSaveFlow, BillingRecoveryFlow, WinBackFlow
// Lower-level presentational pieces
CancelSaveSheet, BillingRecoveryBanner, BillingRecoverySheet, WinBackPrompt
// Hooks
useCancelFlow, useBillingRecovery, useWinBack,
useRedchurnLifecycle, useRedchurnNotifications, useRedchurnNotificationHandler
// Notifications
registerRedchurnBackgroundEventHandler, requestNotificationPermission,
isNotifeeAvailable, scheduleTestNotification, getFcmToken,
isFirebaseMessagingAvailable, parseRedchurnNotificationData
// Offer utilities
applyCancelOffer, getCancelOfferApplicationParams, resolveCancelOffer,
resolvedOfferToMetadata, getPauseMonths, getDiscountPercent, resolveDowngradePath
// Styling & client
parseInAppStyle, DEFAULT_IN_APP_STYLE, createRedchurnClient, RedchurnClient, safeOpenUrl
// Constants & types
SDK_VERSION, DEFAULT_API_BASE_URL, FLOW_TYPES, CANCEL_OFFER_KEYS
// + RedchurnConfig, RedchurnError, SdkRemoteConfig, ResolvedCancelOffer, FlowType, …Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Dashboard says "SDK not detected" | Register never ran | Check sdkKey, network, and that the provider mounts. |
| Cancel sheet never shows | Flow/offers disabled, or no rcAppUserId | Enable Cancel Save + an offer; pass a valid id. |
| Notification taps do nothing when killed | Background handler not registered | Call registerRedchurnBackgroundEventHandler() in index.js. |
| Android notifications absent | Missing permission / wrong icon | Call requestNotificationPermission(); use a white silhouette icon. |
| Local notifications never appear | Notifee not installed | npm install @notifee/react-native (optional peer). |
Tests
npm run test # vitest
npm run typecheck # tsc --noEmitChangelog · CHANGELOG.md — Dashboard ·
app.redchurn.io — Support · [email protected] ·
License · MIT
