React Native – Taking Payments with Stripe

August 3, 20264 min readUpdated 8/24/2026

Payments are the best worked example of a native module, and the one where getting the architecture wrong has consequences beyond a bug. This lesson is Stripe on React Native, and the rules that apply whatever provider you use.

Use the provider's sheet

Stripe's PaymentSheet is a native view rendered by Stripe's own SDK. You hand it a client secret and it collects the card, handles 3D Secure, and comes back with a result.

The tempting alternative is CardField — Stripe's input component — with your own layout around it. Resist it. The sheet handles saved cards, Apple Pay and Google Pay, bank redirects and every regional authentication flow, and none of that is work you want. More importantly, the card number never touches your JavaScript, which is what keeps your app out of PCI scope.

The equivalent on the web is PaymentElement, for the same reasons.

Two steps, and why they cannot be one

Step one: the app POSTs the order — identifiers and quantities, no prices. The server prices the cart from its own database, saves the order as PENDING_PAYMENT, opens a Stripe PaymentIntent and returns its client secret.

Step two: the app hands that secret to the sheet.

The order has to exist before the sheet can open, because the PaymentIntent is what the sheet confirms. That ordering is why checkout is two steps rather than one submit — and it is also why the customer sees the server's total before they pay, not the app's estimate.

Opening the sheet

      const { error: initError } = await initPaymentSheet({
        merchantDisplayName: 'StayHub Pizza',
        paymentIntentClientSecret: request.clientSecret,
        returnURL: 'pizzaapp://stripe-redirect',
        defaultBillingDetails: {
          name: request.customerName,
          email: request.customerEmail,
        },
        allowsDelayedPaymentMethods: false,
      });

      if (initError) {
        return { status: 'failed', message: initError.message };
      }

Initialise, then present. Splitting them lets you fail fast: there is no point opening a sheet that cannot work.

returnURL is the deep link from lesson 12, and it matters here. When a card needs 3D Secure the customer leaves for their bank; this is what brings them back. Get it wrong and the app is simply never reopened, leaving a paid order the customer never sees confirmed.

Cancelled is not failed

      const { error: presentError } = await presentPaymentSheet();

      if (presentError) {
        if (presentError.code === 'Canceled') return { status: 'cancelled' };
        return { status: 'failed', message: presentError.message };
      }

      return { status: 'succeeded' };
    },

⚠️ A dismissed sheet reports as an error, with code 'Canceled'. Treat that as a failure and you show "your payment failed" to somebody who simply changed their mind — and they will believe you.

This is why the gateway returns a three-way outcome rather than a boolean or a thrown error. The distinction between "it did not work" and "they decided not to" has to survive as far as the UI.

What the screen does with it

    if (outcome.status === 'cancelled') {
      return;
    }
    if (outcome.status === 'failed') {
      setError(outcome.message);
      return;
    }

Cancelled does nothing at all — no message, no state change. The order is still reserved and they can try again, which is exactly what a customer who backed out expects.

    clear();
    showToast('Payment accepted');
    router.replace(`/order/${created.order.id}`);
  }

replace, not push — the back gesture must not return to a checkout for an order that has already been paid.

The rule that outranks everything else

The device is never the authority on whether a payment succeeded.

"Stripe accepted the card" is a fact about the customer's phone. Your order is still PENDING_PAYMENT until your backend knows, and the backend learns it from Stripe's webhook — a signed server-to-server call — not from your app saying so. Anyone can call your API and claim anything; only the webhook signature proves it.

So the confirmation screen does not mark the order paid. It asks the server what the server believes, and keeps asking until it settles:

  paymentStatus: (orderId: UUID, signal?: AbortSignal) =>
    apiClient.get<Order>(`/api/orders/${orderId}/payment-status`, { signal }),

Polling exists because a webhook does not reach a laptop unless stripe listen is running, and even in production it can arrive seconds after the customer does. The endpoint asks Stripe directly, so the screen is correct either way. Ten attempts, two seconds apart, then a message telling them to check their email — a poll without a limit is a battery bug.

Prices come from the server

The order request carries product ids, sizes, crust ids, topping ids and quantities. No money at all. A patched app sending total: 0.01 changes nothing, because the server never reads it.

The app's arithmetic exists so the customer sees a number before they commit, and the moment the order exists the screen switches to the server's figures. The demo app has an end-to-end test asserting the request body contains no price field — worth writing, because this is the kind of rule that erodes quietly.

Keys

The publishable key goes in the app. It is public by design: it identifies the account and can only create intents, never charge one. The secret key lives on the server and nowhere else. If a key starting sk_ ever appears in a mobile bundle, treat it as compromised and roll it — a bundle is downloadable and inspectable.

Saving a card

Same sheet, a SetupIntent instead of a PaymentIntent — it collects a card without charging it. The sheet does not hand back the payment method, so the SetupIntent is read afterwards to find it, and only the opaque pm_… token is sent to your server. Never the number, never the CVC, never the cardholder name. Brand, last four and expiry are display metadata and are all you should store.

What is next

Accessibility — because a View announces nothing.