Stripe Checkout
Set up Stripe Checkout for payments in an Expo app with ReadyNative: web checkout via API routes (no native SDK) · Expo Go OK
Expo Go: yes
Runs in Expo Go; no dev build needed for this module.
This sells subscriptions through Stripe's hosted Checkout in the system browser, so there's no native payments SDK at all (@stripe/stripe-react-native is deliberately not used). src/lib/payments.ts implements the shim - entitlements come from GET /api/stripe/entitlements for whoever the request's session proves - and the server side lives in your tree: src/app/api/stripe/{checkout,entitlements,webhook,return}+api.ts with the shared logic in src/server/stripe.ts. You also get /paywall as a modal, tests for both halves, and /examples/paywall with --with-examples. @/lib/payments keeps the same API whichever option you pick - pick Stripe when you're selling to the web as well, or when the 30% store cut isn't something you have to pay.
Setup
bun run setup --payments stripe --backend api-routes- Serve the API routes. This module requires
backend/api-routes(it setsweb.output: "server"), so setup refuses--payments stripewith any other backend. WithoutEXPO_PUBLIC_API_URLpointing at that server the paywall shows "Configure Stripe". Put the origin in.envasEXPO_PUBLIC_API_URL: your EAS Hosting URL, orhttp://<lan-ip>:8081whilenpx expo startruns. In a dev build you can leave it unset -src/lib/env.tsfalls back to the Metro dev server that shipped the bundle (http on a LAN host, https on a tunnel host). - In Stripe, open [Developers] → [API keys] in test mode. Copy the secret key into
.envasSTRIPE_SECRET_KEY(never with anEXPO_PUBLIC_prefix) and the publishable key asEXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY- hosted Checkout doesn't use the publishable key, but it's there for a Payment Sheet upgrade later. - Open [Products], create a product with a recurring price, and copy the price id (
price_…) into.envasSTRIPE_PRICE_ID. - Open [Developers] → [Webhooks], add an endpoint at
<EXPO_PUBLIC_API_URL>/api/stripe/webhook, and copy its signing secret (whsec_…) into.envasSTRIPE_WEBHOOK_SECRET. Locally, runstripe listen --forward-to localhost:8081/api/stripe/webhookand use the secret it prints. - Optional: set
STRIPE_ENTITLEMENTin.envto the entitlement id an active subscription grants. It defaults topro. - Fill in the server keys in
.env. They're markedserver: truein the module, so.env.examplelists them under a separate "server" section andsrc/lib/env.tsnever includes them - they're read throughprocess.envin the+api.tsfiles only.bun run doctorchecks all five. - Pick an auth module. The
checkoutandentitlementsroutes work out who is paying on the server, from the session - see "Who the routes charge" below. Withauth/clerk, also setCLERK_SECRET_KEY(server) so the routes can verify Clerk's session token.
Going to production?
Switch Stripe out of test mode and redo steps 2-4 with live values: a live sk_…, a live price_…, and a webhook endpoint pointing at your deployed origin with its own whsec_…. Set EXPO_PUBLIC_API_URL to that https origin, and store the server keys in EAS (eas env:set --environment production --visibility sensitive; secret variables don't deploy to EAS Hosting), not in .env. Leave STRIPE_ALLOW_ANONYMOUS unset.
- Run
bun run doctor- every row for this module should be green.
The keys, in one place:
| Key | Where |
|---|---|
EXPO_PUBLIC_API_URL | Origin that serves src/app/api/** (EAS Hosting URL, or http://<lan-ip>:8081 in dev) |
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY | Developers → API keys (pk_…; unused by hosted Checkout, kept for Payment Sheet upgrades) |
STRIPE_SECRET_KEY (server) | same page, sk_… - never EXPO_PUBLIC_ |
STRIPE_PRICE_ID (server) | Products → price price_… (recurring) |
STRIPE_WEBHOOK_SECRET (server) | Webhooks → endpoint <API_URL>/api/stripe/webhook → signing secret whsec_… |
STRIPE_ENTITLEMENT (server, opt.) | Entitlement id an active subscription grants; default pro |
STRIPE_ALLOW_ANONYMOUS (server, opt.) | true trusts the client's customer id, only with auth/none - demos only, see "Who the routes charge" |
Deps: stripe ^22.6.2, server-side and pure JS. Expo Go works, since Checkout runs in the system browser.
How the round trip goes: Subscribe → POST /api/stripe/checkout { returnUrl } with the session headers → a Checkout session for the verified user (mode: subscription, subscription_data.metadata.app_user_id) → the browser → Stripe redirects to /api/stripe/return?checkout=success&to=<returnUrl> → that page bounces to the app scheme → the auth session closes → entitlements are refetched. Entitlements are a subscription search on metadata['app_user_id'] with status active or trialing, mapped to [STRIPE_ENTITLEMENT], so no database is required. Persist them in handleStripeEvent if you add one.
Who the routes charge
The app never tells the server who it is paying for. Every request from src/lib/payments.ts carries auth.getAuthHeaders(), and resolveCaller() in src/server/stripe.ts turns those back into a user through the auth module's src/server/session.ts (serverAuth.getRequestUser(request)):
| Auth module | Header the app sends | How the server checks it |
|---|---|---|
auth/better-auth | cookie (session cookie from SecureStore) | auth.api.getSession({ headers }) against Better Auth's session table |
auth/supabase | authorization: Bearer <access token> | supabase.auth.getUser(jwt) with the project's URL + anon key |
auth/clerk | authorization: Bearer <session token> | verifyToken() from @clerk/backend with CLERK_SECRET_KEY (or CLERK_JWT_KEY) |
auth/none | nothing | nobody can be verified |
- No verified user →
401 Sign in required, whatevercustomerthe request claims. A signed-in user's own id always wins over thecustomerfield. - The server can't verify at all (for example Clerk without
CLERK_SECRET_KEY) →503, so a misconfiguration doesn't look like a signed-out user. - With
auth/nonethe routes refuse by default.STRIPE_ALLOW_ANONYMOUS=truemakes them trust thecustomerid the client sends (?customer=/ bodycustomer) - anyone can then buy for, or read the entitlements of, any id. That's for demos, or an app whose ids you accept as guessable; it has no effect once an auth module is selected. - Your own API routes can use the same helper:
const user = await serverAuth.getRequestUser(request)from@/server/session.
Usage
Gate a feature on an entitlement:
import { payments } from "@/lib/payments";
const { active, loading } = payments.useEntitlements(); // active: ["pro"]
if (!loading && !active.includes("pro")) await payments.presentPaywall?.();Start Checkout from your own screen:
import { startCheckout, getPrice, formatPrice } from "@/lib/payments";
const { price } = await getPrice();
// session.user.id keys the local entitlements cache; the server charges the verified session user.
const result = await startCheckout(session.user.id); // "success" | "cancel" | "dismiss"Refresh after a purchase, which is also what Restore does here:
import { payments } from "@/lib/payments";
await payments.restorePurchases();Gotchas
- A
401fromcheckoutorentitlementsmeans the session didn't reach or didn't verify on the server: check that the app and the API talk to the same auth backend (for Better Auth,BETTER_AUTH_URL= the API origin), and that the auth module's server keys are set where the API runs (EAS Hosting env, not just.env). - The custom-scheme bounce needs
app.schemeinreadynative.config.ts; in Expo Go theexp://return URL is accepted too. - Apple and Google require in-app purchase for digital goods sold inside the app. Check the store rules for what you're selling before you ship Checkout as the only path: a Stripe link-out for digital goods is allowed on the US App Store storefront, elsewhere only with Apple's external purchase entitlement.
examples/snap-reciperuns Checkout next to RevenueCat (src/lib/store-purchases.ts) with one merged entitlement set. - On a physical iPhone,
EXPO_PUBLIC_API_URLmust behttps://unless it is a LAN IP,localhost,*.localor a dot-less host name - App Transport Security blocks plain http to anything else ("requires the use of a secure connection"), includingexpo start --tunnelhosts. Tunnels and EAS Hosting serve https, so switch the scheme rather than the host. The paywall turns that failure into this advice instead of the rawNSURLError, andsrc/lib/env.tswarns about it in dev. - Apple Pay needs no code: turn it on in Stripe → [Settings] → [Payment methods] and hosted Checkout offers it automatically. The button renders in the iOS Simulator, but the Apple Pay sheet needs a card in Wallet, so test it on a real iPhone.
Check it works (the Examples steps need a tree set up with --with-examples):
- In Stripe test mode, create the product and recurring price for
STRIPE_PRICE_ID, setSTRIPE_SECRET_KEY, run the API (npx expo startwith api-routes, or deploy to EAS Hosting), and pointEXPO_PUBLIC_API_URLat its LAN or public origin. - Run
stripe listen --forward-to localhost:8081/api/stripe/webhookand paste the printedwhsec_…intoSTRIPE_WEBHOOK_SECRET. - Sign in with an auth module - with a null user the paywall shows "Sign in to subscribe". Examples → "Paywall & entitlements" reads "none active".
curl <API_URL>/api/stripe/entitlements?customer=<your user id>without the session answers401. - Tap "Open paywall" → a card with the product name and price → Subscribe → the browser opens Checkout → pay with
4242 4242 4242 4242→ the "Payment complete" page bounces back → toast "Subscription active" and entitlements showpro;stripe listenprintscheckout.session.completed→ 200. - Cancel in Checkout → you return with the toast "Checkout cancelled"; swipe the browser away → nothing changes.
- Cancel the subscription in the dashboard, then tap "Restore purchases" →
none active.
Remove it
While modules/ exists (a tree set up with --keep-modules), setup does all of it:
bun run setup --payments none --yes --keep-modulesIn a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:
- Delete the files that are still there (the demo screens are gone already unless you set up with
--with-examples):src/app/api/stripe/checkout+api.ts,src/app/api/stripe/entitlements+api.ts,src/app/api/stripe/return+api.ts,src/app/api/stripe/webhook+api.ts,src/app/examples/paywall.tsx,src/app/paywall/_layout.tsx,src/app/paywall/index.tsx,src/lib/__tests__/payments-stripe.test.ts,src/screens/examples/paywall-example-screen.tsx,src/screens/paywall/paywall-screen.tsx,src/server/__tests__/stripe-routes.test.ts,src/server/stripe.ts. - Replace, don't delete
src/lib/payments.ts: core code imports it, so swap in the no-op version frommodules/payments/none/files/of a fresh clone of your tier repo - same exports, nothing behind them. - Uninstall the dependencies:
bun remove stripe. - Unwrap the provider: delete
<PaymentsProvider>and its import fromsrc/providers.tsx. - Remove the env keys
EXPO_PUBLIC_API_URL,EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_ID,STRIPE_ALLOW_ANONYMOUSfrom.env,.env.exampleand your EAS environment, andEXPO_PUBLIC_API_URL,EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEYfromsrc/lib/env.ts. - Update the privacy declarations: remove this module's entries from
.readynative.json→modules.app.expo.ios.privacyManifests, then re-runbun run gen:privacyand revise your store privacy answers. - Check it:
bun run typecheckandbun run lintpoint at anything that still imports the removed files;bun run gen:graphrefreshesdocs/ARCHITECTURE.md.
Reference
Everything below is generated from modules/payments/stripe/module.json - the same file bun run setup reads, so it is what actually lands in your repo.
Install
bun run setup --payments stripeModule id: payments/stripe.
Dependencies
| Package | Version | Kind |
|---|---|---|
stripe | ^22.6.2 | dependency |
Environment keys
| Key | Required | Server-only | Example | Docs |
|---|---|---|---|---|
EXPO_PUBLIC_API_URL | no | no | https://your-app.expo.app | dashboard |
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY | yes | no | pk_test_xxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
STRIPE_SECRET_KEY | yes | yes | sk_test_xxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
STRIPE_WEBHOOK_SECRET | yes | yes | whsec_xxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
STRIPE_PRICE_ID | yes | yes | price_xxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
STRIPE_ALLOW_ANONYMOUS | no | yes | false | dashboard |
Keys go in .env (see .env.example). Required keys are checked by bun run doctor; Server-only keys have no EXPO_PUBLIC_ prefix, are read only by API routes and never reach the bundle.
Privacy
Play Data safety draft: collects Payment info (entered in Stripe's hosted checkout, never in the app), Email address, User id (customer metadata); shared with Stripe (processor). Source of truth: vendor disclosure.
Apple privacy manifest data types (composed into ios.privacyManifests by setup):
| Type | Linked to user | Tracking | Purposes |
|---|---|---|---|
PurchaseHistory | yes | no | AppFunctionality |
EmailAddress | yes | no | AppFunctionality |
UserID | yes | no | AppFunctionality |
Providers
Rendered in src/providers.tsx (lower order = outermost):
| Order | Provider | From |
|---|---|---|
| 70 | PaymentsProvider | @/lib/payments |
Compatibility
- Requires
backend=api-routes
Doctor checks
- env:
EXPO_PUBLIC_API_URL,EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_ID
After setup
- Stripe: create a Product with a recurring Price at https://dashboard.stripe.com/products and copy the price id (price_...) into .env as STRIPE_PRICE_ID (server-only, no EXPO_PUBLIC_ prefix).
- Stripe: copy the secret key (sk_test_...) from https://dashboard.stripe.com/apikeys into .env as STRIPE_SECRET_KEY, and the publishable key as EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY.
- Stripe: add a webhook endpoint
<API_URL>/api/stripe/webhookat https://dashboard.stripe.com/webhooks (events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted) and put its signing secret (whsec_...) into .env as STRIPE_WEBHOOK_SECRET. Locally:stripe listen --forward-to localhost:8081/api/stripe/webhook. - Stripe: the routes under src/app/api/stripe/ only run with
backend/api-routes(web.output "server"); set EXPO_PUBLIC_API_URL to that server's origin. Without it the paywall shows "Configure Stripe". - Stripe: checkout needs a signed-in user - pick an auth module. The API routes resolve the caller server-side from the session (src/server/session.ts, sent via auth.getAuthHeaders()), never from a client-supplied id; with auth
nonethey answer 401 unless you set STRIPE_ALLOW_ANONYMOUS=true (trusts client ids - demos only).
Files
13 files copied to the project root
src/app/api/stripe/checkout+api.tssrc/app/api/stripe/entitlements+api.tssrc/app/api/stripe/return+api.tssrc/app/api/stripe/webhook+api.tssrc/app/examples/paywall.tsxsrc/app/paywall/_layout.tsxsrc/app/paywall/index.tsxsrc/lib/__tests__/payments-stripe.test.tssrc/lib/payments.tssrc/screens/examples/paywall-example-screen.tsxsrc/screens/paywall/paywall-screen.tsxsrc/server/__tests__/stripe-routes.test.tssrc/server/stripe.ts