ReadyNative

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

Pro

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
  1. Serve the API routes. This module requires backend/api-routes (it sets web.output: "server"), so setup refuses --payments stripe with any other backend. Without EXPO_PUBLIC_API_URL pointing at that server the paywall shows "Configure Stripe". Put the origin in .env as EXPO_PUBLIC_API_URL: your EAS Hosting URL, or http://<lan-ip>:8081 while npx expo start runs. In a dev build you can leave it unset - src/lib/env.ts falls back to the Metro dev server that shipped the bundle (http on a LAN host, https on a tunnel host).
  2. In Stripe, open [Developers] → [API keys] in test mode. Copy the secret key into .env as STRIPE_SECRET_KEY (never with an EXPO_PUBLIC_ prefix) and the publishable key as EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY - hosted Checkout doesn't use the publishable key, but it's there for a Payment Sheet upgrade later.
  3. Open [Products], create a product with a recurring price, and copy the price id (price_…) into .env as STRIPE_PRICE_ID.
  4. Open [Developers] → [Webhooks], add an endpoint at <EXPO_PUBLIC_API_URL>/api/stripe/webhook, and copy its signing secret (whsec_…) into .env as STRIPE_WEBHOOK_SECRET. Locally, run stripe listen --forward-to localhost:8081/api/stripe/webhook and use the secret it prints.
  5. Optional: set STRIPE_ENTITLEMENT in .env to the entitlement id an active subscription grants. It defaults to pro.
  6. Fill in the server keys in .env. They're marked server: true in the module, so .env.example lists them under a separate "server" section and src/lib/env.ts never includes them - they're read through process.env in the +api.ts files only. bun run doctor checks all five.
  7. Pick an auth module. The checkout and entitlements routes work out who is paying on the server, from the session - see "Who the routes charge" below. With auth/clerk, also set CLERK_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.

  1. Run bun run doctor - every row for this module should be green.

The keys, in one place:

KeyWhere
EXPO_PUBLIC_API_URLOrigin that serves src/app/api/** (EAS Hosting URL, or http://<lan-ip>:8081 in dev)
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEYDevelopers → 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 moduleHeader the app sendsHow the server checks it
auth/better-authcookie (session cookie from SecureStore)auth.api.getSession({ headers }) against Better Auth's session table
auth/supabaseauthorization: Bearer <access token>supabase.auth.getUser(jwt) with the project's URL + anon key
auth/clerkauthorization: Bearer <session token>verifyToken() from @clerk/backend with CLERK_SECRET_KEY (or CLERK_JWT_KEY)
auth/nonenothingnobody can be verified
  • No verified user → 401 Sign in required, whatever customer the request claims. A signed-in user's own id always wins over the customer field.
  • 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/none the routes refuse by default. STRIPE_ALLOW_ANONYMOUS=true makes them trust the customer id the client sends (?customer= / body customer) - 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 401 from checkout or entitlements means 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.scheme in readynative.config.ts; in Expo Go the exp:// 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-recipe runs Checkout next to RevenueCat (src/lib/store-purchases.ts) with one merged entitlement set.
  • On a physical iPhone, EXPO_PUBLIC_API_URL must be https:// unless it is a LAN IP, localhost, *.local or a dot-less host name - App Transport Security blocks plain http to anything else ("requires the use of a secure connection"), including expo start --tunnel hosts. 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 raw NSURLError, and src/lib/env.ts warns 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):

  1. In Stripe test mode, create the product and recurring price for STRIPE_PRICE_ID, set STRIPE_SECRET_KEY, run the API (npx expo start with api-routes, or deploy to EAS Hosting), and point EXPO_PUBLIC_API_URL at its LAN or public origin.
  2. Run stripe listen --forward-to localhost:8081/api/stripe/webhook and paste the printed whsec_… into STRIPE_WEBHOOK_SECRET.
  3. 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 answers 401.
  4. 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 show pro; stripe listen prints checkout.session.completed → 200.
  5. Cancel in Checkout → you return with the toast "Checkout cancelled"; swipe the browser away → nothing changes.
  6. 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-modules

In a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:

  1. 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.
  2. Replace, don't delete src/lib/payments.ts: core code imports it, so swap in the no-op version from modules/payments/none/files/ of a fresh clone of your tier repo - same exports, nothing behind them.
  3. Uninstall the dependencies: bun remove stripe.
  4. Unwrap the provider: delete <PaymentsProvider> and its import from src/providers.tsx.
  5. Remove the env keys EXPO_PUBLIC_API_URL, EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID, STRIPE_ALLOW_ANONYMOUS from .env, .env.example and your EAS environment, and EXPO_PUBLIC_API_URL, EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY from src/lib/env.ts.
  6. Update the privacy declarations: remove this module's entries from .readynative.json → modules.app.expo.ios.privacyManifests, then re-run bun run gen:privacy and revise your store privacy answers.
  7. Check it: bun run typecheck and bun run lint point at anything that still imports the removed files; bun run gen:graph refreshes docs/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 stripe

Module id: payments/stripe.

Dependencies

PackageVersionKind
stripe^22.6.2dependency

Environment keys

KeyRequiredServer-onlyExampleDocs
EXPO_PUBLIC_API_URLnonohttps://your-app.expo.appdashboard
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEYyesnopk_test_xxxxxxxxxxxxxxxxxxxxxxxxdashboard
STRIPE_SECRET_KEYyesyessk_test_xxxxxxxxxxxxxxxxxxxxxxxxdashboard
STRIPE_WEBHOOK_SECRETyesyeswhsec_xxxxxxxxxxxxxxxxxxxxxxxxdashboard
STRIPE_PRICE_IDyesyesprice_xxxxxxxxxxxxxxxxxxxxxxxxdashboard
STRIPE_ALLOW_ANONYMOUSnoyesfalsedashboard

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):

TypeLinked to userTrackingPurposes
PurchaseHistoryyesnoAppFunctionality
EmailAddressyesnoAppFunctionality
UserIDyesnoAppFunctionality

Providers

Rendered in src/providers.tsx (lower order = outermost):

OrderProviderFrom
70PaymentsProvider@/lib/payments

Compatibility

Doctor checks

  • env: EXPO_PUBLIC_API_URL, EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID

After setup

  1. 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).
  2. 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.
  3. Stripe: add a webhook endpoint <API_URL>/api/stripe/webhook at 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.
  4. 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".
  5. 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 none they 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.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/lib/payments.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

On this page

Get ReadyNative