@whop/elements-react-native
v0.4.0
Published
Whop Elements — native React Native components for collecting payments.
Downloads
680
Readme
@whop/elements-react-native
Native React Native components for collecting payments with Whop.
The elements are real React Native views, and card fields are PCI-isolated by Basis Theory's React Native SDK. The pieces only the platform can do, the Apple Pay and Google Pay sheets, their branded buttons, and the system browser a 3DS step opens in, are Swift and Kotlin behind React Native Codegen.
import {
WhopElements, Payments, PaymentElement, AddressElement, EmailElement, BrandingElement,
} from '@whop/elements-react-native';
<WhopElements environment="production">
<Payments ref={payments} accountId="biz_…" plan="plan_…">
<EmailElement />
<AddressElement />
<PaymentElement onChange={(e) => setReady(e.complete)} />
<BrandingElement />
</Payments>
</WhopElements>;
const { confirmationToken } = await payments.current.createConfirmationToken();Confirm the token server-side, then hand any pending step to
payments.current.handleNextAction({ clientSecret }).
Requirements
| | |
| --- | --- |
| React Native | >= 0.87 on Swift Package Manager, >= 0.86 on CocoaPods. New Architecture on |
| iOS | 15.1+ |
| Android | minSdk 24, compileSdk 37 |
| Peers | react and react-native only — no native peer dependencies |
The package ships two iOS manifests and the toolchain picks one. React Native's SPM autolinker
prefers ios/Package.swift and only falls back to a podspec, so an app on Swift Package Manager is
unaffected by the podspec's existence. The floor is higher there because Package.swift links
ReactNativeHeaders and ReactNativeDependenciesHeaders, two header-only XCFrameworks that first
shipped in React Native 0.87.
WhopElementsReactNative.podspec exists because every CocoaPods-based toolchain discovers native
modules by scanning a package for a podspec, and skips a package without one silently rather
than failing. That includes Expo, whose iOS autolinking is CocoaPods-based at every released SDK.
Without the podspec an Expo app installs this package, builds cleanly, and gets no native module at
all.
Elements
| Component | What it collects |
| --- | --- |
| <PaymentElement /> | The offered payment methods and the selected one's required fields |
| <AddressElement /> | A postal address, in the selected country's own format |
| <CardElement /> | Card number, expiry, and security code, prearranged |
| <CardFields> + <CardNumberElement /> <CardExpiryElement /> <CardCvcElement /> | The same three fields, placed individually |
| <EmailElement /> | The buyer's email |
| <TaxIdElement /> | A business tax registration |
| <BrandingElement /> | Whop's merchant-of-record notice |
| <ExpressCheckoutElement /> | One Apple Pay or Google Pay press that pays a whole checkout, under <Checkout> |
<BrandingElement /> is required beside a payment surface: Whop is the merchant of record on these
sales, so createConfirmationToken refuses with BRANDING_NOT_MOUNTED without it.
<AddressElement /> autocompletes by default from the platform geocoder, MKLocalSearchCompleter
on iOS and android.location.Geocoder on Android, so it needs no API key, no account, no location
permission and no extra dependency. Android resolves a complete address rather than predicting from a
fragment, so expect fewer and coarser suggestions there. A geocoder with no matches shows nothing and
the buyer types the address by hand, which is where they started: autocomplete never blocks a
checkout. Pass autocomplete={false} to turn it off.
<Payments>
| Prop | |
| --- | --- |
| accountId | The Whop company the sale belongs to |
| plan | The plan being sold, which resolves the currency, amount and offered methods. Pass currency and amount instead for an ad-hoc charge |
| mode | payment (default) charges; setup saves a payment method without charging: pass currency, never plan or amount; methods that cannot be saved are not offered and amount limits do not apply; confirm as usual and create the setup intent from your server |
| returnUrl | Where the issuer returns after a redirect or 3DS step. Must be https and hosted by you, because the API refuses anything else, so a custom app scheme will not work. You do not register a deep link: handleNextAction polls the payment to rest and closes the browser itself. A setup's sint_… secret is handled the same way against the setup intent |
| googlePayMerchantName | The name shown in the Google Pay sheet, defaulting to Whop. Neither wallet needs anything else from you: Apple Pay uses the merchant registered on the Whop account |
| order | The method tile order, with Stripe's paymentMethodOrder semantics |
<Checkout> and <ExpressCheckoutElement />
<WhopElements environment="production">
<Checkout plan="plan_…" onComplete={({ sessionId, receiptId }) => navigation.navigate('Thanks')}>
<ExpressCheckoutElement />
</Checkout>
</WhopElements><Checkout> opens a Whop checkout session when it mounts and keeps the session's credential to
itself. <ExpressCheckoutElement /> renders the device's wallet button: Apple Pay on iOS, Google
Pay on Android. One press is the whole purchase. The sheet collects what the session still needs
(the email, a phone number, a shipping address), the element confirms the session, runs any 3DS
step in the system browser, and shows a native code step for sellers who verify phone numbers.
There is no confirmation token to send to your server.
| Prop | |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| plan, items, checkoutConfiguration, quantity | What is being bought, with the web element's rules: items is the whole cart, plan beside checkoutConfiguration only sets quantity |
| promoCode, affiliateCode, attribution, metadata | Recorded on the session. A promo code that cannot apply opens the checkout at full price |
| returnUrl | Where an off-site payment step returns. https, hosted by you, as on <Payments> |
| onComplete | ({ sessionId, receiptId }), once, when the purchase stands. Your app is never navigated, so this is where you move the buyer on. Fulfill from webhooks |
| onError, onLoadingChange, fallback, style | As on <Payments> |
Every option is create-time. Changing one after mount reports CHECKOUT_OPTIONS_FROZEN and keeps
the original order: give <Checkout> a new key to sell something else.
The element renders nothing where the device has no wallet, the checkout's payment method
configuration disables it, or the plan cannot be bought right now. A checkout a sheet cannot
finish (a setup, a waitlist join, a transfer, a question only a form can ask) shows an
explanatory face and reports EXPRESS_CHECKOUT_UNAVAILABLE. On iOS the sheet reprices as the
buyer changes card and offers a promo code field when the seller allows codes. Google's sheet has
neither on Android. wallets filters what may render, and there is no layout prop because a
device only ever offers one wallet.
Appearance
One object, applied live — mounted elements restyle in place.
<WhopElements
appearance={{
theme: { appearance: 'dark', accentColor: 'blue' },
tokens: { radius: 12 },
parts: { 'whop-CardNumberField': { borderWidth: 2 } },
}}
>theme is identical to the web elements'. The two deltas are deliberate, because React Native has
no CSS:
tokensreplacesvariables— a closed, typed map of numbers, not"8px"strings.partsreplacesclasses— the samewhop-*part names, with React Native style objects instead of CSS declarations. Flat per-part overrides: no cascade, no pseudo-classes.
Passing variables or classes throws by name rather than being silently ignored.
An unset theme.appearance follows the device's colour scheme.
Example app
cd example
pnpm install
pnpm run setup # generates ios/ and android/, converts iOS to SwiftPM
pnpm run ios # or: pnpm run androidSet your accountId and plan in example/src/config.ts first. It runs against
production by default — switch environment to sandbox there to point at the sandbox API.
ios/ and android/ are generated by pnpm run setup from the official React Native app template
rather than committed: they are several hundred files that belong to a React Native version, and
regenerating is how the example follows a version bump instead of drifting from it. The script also
converts the project to Swift Package Manager without ever invoking pod — it strips CocoaPods out
of the .pbxproj directly, because react-native spm add --deintegrate shells out to it.
One deliberate leftover: ios/Podfile stays as a pod-free stub. The React Native CLI locates the
iOS project by finding a Podfile, and react-native spm reads that location, so deleting it makes
project.ios resolve to null and the SPM setup fail. The example has four
screens: a realistic Checkout, Express (a real one-press purchase), a Gallery of each
element with live prop controls, and Theming applied to a live form.
The example is its own pnpm install root. React Native's settings.gradle does
includeBuild("../node_modules/@react-native/gradle-plugin"), which pnpm's isolated linker does not
expose, so example/.npmrc sets node-linker=hoisted — scoped there so the rest of the monorepo
stays isolated.
Native layer
Three units, all generated from src/specs/ by React Native Codegen:
| Unit | Kind | iOS | Android |
| --- | --- | --- | --- |
| WhopAuthSession | Turbo Module | ASWebAuthenticationSession | Chrome Custom Tabs |
| WhopPlatformPay | Turbo Module | PKPaymentAuthorizationController | PaymentsClient |
| WhopPayButton | Fabric Component | PKPaymentButton | PayButton |
A redirect or 3DS step opens in the system browser, out of process — not a WebView the app can
read or script. handleNextAction then polls the payment to rest, because the API accepts only
https or loopback return URLs and a custom-scheme deep link back into the app is refused.
React Native cannot express a Fabric component or a Turbo Module in pure Swift: both need an
Objective-C++ class the C++ renderer can see. Nobody writes it here. scripts/codegen-shims.mjs
emits it from the same specs Codegen reads, into the gitignored ios/generated/, during prepare.
Every line a human writes on iOS is Swift and on Android is Kotlin.
The SDK has no native peer dependencies, deliberately: under Swift Package Manager, one peer
that ships no Package.swift makes the whole app unbuildable, and most of the React Native
ecosystem does not ship one yet. That is why the branding wordmark is drawn with plain views rather
than react-native-svg.
Not in this release
Absent entirely rather than half-present, so nothing renders that cannot work: saved payment methods, the email code sign-in, installment tiers, ledger-balance payment, and the post-confirm instruction panes.
Development
pnpm install
pnpm build # emits ios/generated, then module + typescript targets
pnpm test
pnpm check-types
pnpm theme # regenerate src/theme/scales.generated.ts from frosted-ui's stylesheet
pnpm mirror # which files here mirror the web elements changed on this branch
pnpm check-swift # typecheck the Swift against the iOS SDK, no app buildNothing in ios/Sources imports React, so the whole Swift implementation typechecks standalone —
a wrong PassKit or AuthenticationServices symbol surfaces in seconds rather than one at a time
inside an Xcode build.
pnpm mirror answers the question a web author cannot otherwise ask, which files here implement the
same behaviour as the web elements they just changed. scripts/mirror.mjs derives the ported files
from their own headers and keeps an explicit table for the element components; pnpm mirror:validate
fails if either side of a pair has moved. Add a row when you add an element.
src/core/ is ported verbatim from frontend/apps/web/src/elements/. Each file names its source in
a header, and the address engine's own test suite is ported with it, so the port is proved against
the same assertions the web elements pass.
CI and npm releases
.github/workflows/frontend-elements-react-native.yml runs the unit tests, typecheck,
lint, mirror validation, native shim generation, and package build. It packs the SDK and checks
that the JavaScript, declarations, native sources, generated shims, and autolinking config ship.
These checks do not build or run an iOS or Android app; native changes still need device verification.
To release, bump this package's version in a PR, add its entry to CHANGELOG.md, and merge it into main. After the checks pass,
the workflow publishes the verified tarball. Stable versions go to latest; prereleases use
alpha, beta, canary, or rc (for example, 0.2.0-beta.1 goes to beta). Already published
versions skip publishing, and older unpublished versions cannot move a channel backwards.
The version is not bumped automatically. Merging CI setup with the existing 0.1.0 version
therefore does not publish another release.
Publishing uses npm OIDC, following the web Elements workflow. Configure the trusted publisher
in the npm settings for @whop/elements-react-native:
- Provider: GitHub Actions
- Organization:
whopio - Repository:
whop-monorepo - Workflow filename:
frontend-elements-react-native.yml - Environment: leave blank
No npm token is needed. The publish step runs from the downloaded artifact directory outside
frontend so the workspace's install-time npm configuration cannot override OIDC authentication.
Provenance is not requested because the source repository is private.
If publishing fails, rerun the failed job, or dispatch the workflow on main to retry after checks.
Dispatching it on another branch only runs checks.
