> ## Documentation Index
> Fetch the complete documentation index at: https://setup.despia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payment

> Show the native Stripe Payment Sheet from your web app and receive the result through a single global callback.

Call `despia('stripe://payment?...')` to show the native Stripe Payment Sheet with a publishable key and a Payment Intent client secret. The sheet handles card entry, 3DS, Link, and any other payment methods enabled on the Payment Intent, then the Despia runtime fires `window.stripeEvent` once with the outcome.

A second action, `despia('stripe://manage?...')`, opens Stripe's native CustomerSheet so signed-in customers can add, remove, and pick a default among their saved payment methods. Both actions share the same `window.stripeEvent` callback. See [Manage saved cards](#manage-saved-cards).

<Danger>
  **Do not use this for digital goods inside mobile apps.** Credits, coins, in-app currency, memberships, subscriptions, premium tiers, ad removal, unlocking levels, and any virtual content consumed inside the app must use [RevenueCat](/native-features/revenuecat/introduction), the store-compliant native in-app purchase path backed by Apple StoreKit and Google Play Billing. Apple and Google will reject your app on submission if you accept payment for digital goods through Stripe or any other external payment system. See [in-app purchase rejections](/store-rejections/common-rejection/in-app-purchases) for the full policy and rejection language.

  Stripe Payment is for physical goods and real-world services that are delivered or consumed outside the app. This is the same category Apple permits in section 3.1.3 of the App Review Guidelines and Google permits in the Play Store payments policy. Apps like Amazon (physical products), Uber (rides), DoorDash (food delivery), and Angie's List (service marketplace) all fall under this allowance. The rule of thumb: if what the user buys arrives at a doorstep, is performed by a human, or is fulfilled outside the app, Stripe is allowed. If it unlocks anything inside the app, use RevenueCat instead.
</Danger>

<Info>
  Your backend creates the Payment Intent with your Stripe secret key and returns the client secret to the page. Amount, currency, customer, allowed payment methods, Stripe Connect routing, and metadata are all decided server-side. The web app only passes the publishable key and the client secret into the native action.
</Info>

***

## Installation

<Tabs>
  <Tab title="Bundle">
    <CodeGroup>
      ```bash npm theme={null}
      npm install despia-native
      ```

      ```bash pnpm theme={null}
      pnpm add despia-native
      ```

      ```bash yarn theme={null}
      yarn add despia-native
      ```
    </CodeGroup>

    ```javascript theme={null}
    import despia from 'despia-native';
    ```
  </Tab>

  <Tab title="CDN">
    <CodeGroup>
      ```html UMD theme={null}
      <script src="https://cdn.jsdelivr.net/npm/despia-native/index.min.js"></script>
      ```

      ```html ESM theme={null}
      <script type="module">
          import despia from 'https://cdn.jsdelivr.net/npm/despia-native/+esm'
      </script>
      ```
    </CodeGroup>
  </Tab>
</Tabs>

***

## Create a Payment Intent on your server

The Payment Intent must be created server-side with your Stripe secret key, never from the web app. Your server returns the `client_secret` to the page, which then passes it into the `despia()` call.

```bash theme={null}
curl https://api.stripe.com/v1/payment_intents \
  -u "sk_live_xxx:" \
  -d amount=1999 \
  -d currency=usd \
  -d "automatic_payment_methods[enabled]=true"
```

`amount` is an integer in the smallest currency unit, so `1999` charges \$19.99 in USD or ¥1999 in JPY. `currency` is a lowercase three-letter ISO code. `automatic_payment_methods[enabled]=true` lets Stripe choose which methods to show based on what is enabled for your account and what is compatible with the Payment Intent. Amount, currency, customer, metadata, Stripe Connect routing, and the allowed methods are all decided here, not on the client.

A successful call returns the Payment Intent object as JSON:

```json theme={null}
{
  "id": "pi_3MtwBwLkdIwHu7ix28a3tqPa",
  "object": "payment_intent",
  "amount": 1999,
  "currency": "usd",
  "status": "requires_payment_method",
  "client_secret": "pi_3MtwBwLkdIwHu7ix28a3tqPa_secret_xxx",
  "automatic_payment_methods": { "enabled": true },
  "livemode": true,
  "created": 1717171717
}
```

Your server should return two fields to the page: `client_secret` from this response, and your matching `publishable_key` from your environment variables. Returning both together guarantees the keys never cross modes, since the server is the only place that knows for sure whether it is running with `sk_test_...` or `sk_live_...` (see [Test versus live keys](#test-versus-live-keys) below). `id` and `status` stay server-side for webhook reconciliation, the `client_secret` already encodes the ID so the page never needs the bare `pi_...` value. `status` starts as `requires_payment_method` and transitions to `succeeded` once the sheet completes and the payment captures.

For the full parameter list, lifecycle, and error codes, see the [Payment Intents API reference](https://docs.stripe.com/api/payment_intents), the [Create a Payment Intent endpoint](https://docs.stripe.com/api/payment_intents/create), and the [Payment Intents guide](https://docs.stripe.com/payments/payment-intents).

***

## How it works

This is the fire-and-listen pattern. Define `window.stripeEvent` once at page load. The Despia runtime calls it with the outcome when the Payment Sheet closes. Then fire the action with `despia()`.

```javascript theme={null}
import despia from 'despia-native'

const isDespia = navigator.userAgent.toLowerCase().includes('despia')

// Define the listener BEFORE firing. The runtime guards the call with
// typeof window.stripeEvent === 'function', so if it is not a function
// at the moment the result fires, the event is dropped silently.
window.stripeEvent = function (event) {
    // event = { method: 'paymentSheet', status, error? }
    if (event.status === 'completed') {
        // User finished payment. Confirm on your backend before fulfilling.
    } else if (event.status === 'canceled') {
        // User dismissed the sheet.
    } else if (event.status === 'failed') {
        // event.error holds the reason.
    }
}

async function pay() {
    const res = await fetch('/api/create-payment-intent', { method: 'POST' })
    const { client_secret, publishable_key } = await res.json()

    if (isDespia) {
        despia(`stripe://payment?publishable_key=${publishable_key}&payment_intent_client_secret=${client_secret}`)
    }
}
```

The action is `stripe://payment`. The two required params are `publishable_key` (`pk_live_xxx` or `pk_test_xxx`) and `payment_intent_client_secret` (the full `pi_..._secret_...` string from the Payment Intent, not the bare `pi_...` id). Four optional styling params (`theme`, `accent_color`, `corner_radius`, `action_corner_radius`) control the sheet's appearance and are covered in the Styling section. An optional saved-card pair (`customer_id`, `ephemeral_key_secret`) attaches the Stripe customer so the sheet lists their saved cards and saves the new one, covered in [Saved cards on payment](#saved-cards-on-payment). Do not fire a second `stripe://payment` while one sheet is open.

***

## Styling

The Payment Sheet accepts two optional query params that control its appearance: `theme` and `accent_color`. Both are independent, both are safe to omit if Stripe's default styling is fine.

### Theme

The `theme` param controls the sheet's color scheme. Accepted values are `light`, `dark`, and `automatic`. Anything else (a typo, an empty value, or omitting the param) falls back to `automatic`, which follows the device's system light/dark setting.

```javascript theme={null}
if (isDespia) {
    despia(`stripe://payment?publishable_key=${publishable_key}&payment_intent_client_secret=${client_secret}&theme=dark`)
}
```

Native Stripe SDKs do not expose the named themes from Stripe.js (`stripe`, `night`, `flat`). The only theme switch available here is light, dark, or automatic.

### Accent color

The `accent_color` param sets the color of the primary Pay button and the sheet's primary accent (selected option highlights and similar). Use it to match your brand.

| Form                | Example     | Notes                                     |
| ------------------- | ----------- | ----------------------------------------- |
| 6-digit hex         | `1A73E8`    | most common, recommended                  |
| 3-digit shorthand   | `F0A`       | expands to `FF00AA`                       |
| 8-digit with alpha  | `1A73E8CC`  | `RRGGBBAA`, last byte is opacity          |
| With `#` prefix     | `#1A73E8`   | works but see below                       |
| Percent-encoded `#` | `%231A73E8` | output of `encodeURIComponent('#1A73E8')` |

Hex digits are case-insensitive. All five forms above resolve to the same color when the underlying hex matches.

<Info>
  **Drop the `#`.** In a `stripe://` command, a literal `#` would normally be treated as a fragment delimiter, which truncates everything that follows. The Despia runtime tolerates a raw `#` in any position of a native feature command, but omitting it (or percent-encoding it as `%23`) is the portable habit and keeps the command string unambiguous if you ever log it, parse it, or copy it into another system.
</Info>

```javascript theme={null}
if (isDespia) {
    despia(`stripe://payment?publishable_key=${publishable_key}&payment_intent_client_secret=${client_secret}&accent_color=1A73E8`)
}
```

Invalid color values (`accent_color=blue`, `accent_color=zzz`, an empty string) are silently ignored. The sheet renders with Stripe's default styling and the payment still completes normally, no `failed` event is emitted. Guard against typos by validating the hex on the page before firing the action, since the runtime will not surface the mistake.

### Corner radius

The `corner_radius` param sets the general corner radius applied to the sheet's input fields and buttons together, including the secondary back button. The value is a non-negative number of points, with no units and no `#` prefix.

```javascript theme={null}
if (isDespia) {
    despia(`stripe://payment?publishable_key=${publishable_key}&payment_intent_client_secret=${client_secret}&corner_radius=8`)
}
```

Decimal values are accepted, so `corner_radius=12.5` is valid. Invalid, negative, or non-finite values (`corner_radius=round`, `corner_radius=-4`, an empty string) are silently ignored and the sheet renders with Stripe's default corner radius.

### Action corner radius

The `action_corner_radius` param overrides the corner radius of the primary Pay button independently of the general radius. Same format as `corner_radius`: a non-negative number of points.

The inheritance rules are the most useful part:

* **Only `corner_radius` set.** Inputs and the Pay button both use it. The Pay button inherits automatically, you do not need to set `action_corner_radius` at all.
* **Both set.** Everything uses `corner_radius` except the Pay button, which uses `action_corner_radius` as an independent override.
* **Only `action_corner_radius` set.** Just the Pay button is rounded, the rest of the sheet stays at Stripe's default.

```javascript theme={null}
// Everything at radius 8, Pay button as a pill at 24
despia(`stripe://payment?publishable_key=${pk}&payment_intent_client_secret=${cs}&corner_radius=8&action_corner_radius=24`)

// Everything (including the Pay button) at radius 12
despia(`stripe://payment?publishable_key=${pk}&payment_intent_client_secret=${cs}&corner_radius=12`)
```

Invalid `action_corner_radius` values fall back to inheriting from `corner_radius` (or to Stripe's default if neither is set). No `failed` event is emitted for a bad value.

### Combining styling options

All four styling params are independent and may be combined freely. Use any subset, or omit them all for Stripe's default appearance.

```javascript theme={null}
if (isDespia) {
    despia(`stripe://payment?publishable_key=${publishable_key}&payment_intent_client_secret=${client_secret}&theme=dark&accent_color=1A73E8&corner_radius=8&action_corner_radius=24`)
}
```

The same four params apply identically to the `stripe://manage` action covered below.

***

## Result events

The Despia runtime fires `window.stripeEvent` exactly once per `despia('stripe://payment?...')` call, or zero times if the listener was missing when the result arrived. There are no intermediate `presented`, `processing`, or `dismissing` events. The `method` field on the payload is always the string `paymentSheet`, even though the action is `payment`, so always match on `status`.

```javascript theme={null}
// status: 'completed'
{ "method": "paymentSheet", "status": "completed" }

// status: 'canceled'
{ "method": "paymentSheet", "status": "canceled" }

// status: 'failed' (validation, sheet never shown)
{ "method": "paymentSheet", "status": "failed", "error": "missing param" }

// status: 'failed' (Stripe SDK error)
{ "method": "paymentSheet", "status": "failed", "error": "The payment intent client secret is invalid." }
```

The literal string `"missing param"` is the only stable matchable error value. It fires in two cases: when `publishable_key` or `payment_intent_client_secret` is missing or empty, and when the optional saved-card pair (`customer_id`, `ephemeral_key_secret`) is half-supplied with exactly one of the two values rather than both or neither. Every other failure (bad client secret, test and live mode mismatch, expired ephemeral key, API version mismatch, network failure, terminal decline) returns Stripe's localized SDK message. Log or display these messages, but never branch on their contents since they are locale-dependent.

```javascript theme={null}
window.stripeEvent = function (event) {
    if (event.method !== 'paymentSheet') return

    if (event.status === 'failed') {
        if (event.error === 'missing param') {
            console.error('Called stripe://payment with missing or half-supplied required params')
            return
        }
        console.warn('Stripe error:', event.error)
    }
}
```

***

## Confirm on your backend before fulfilling

The `completed` status is the client-side signal that the user finished the flow. Always confirm the final Payment Intent status through a Stripe webhook on your backend before unlocking the order. Network drops mid-confirmation, refunds initiated immediately after capture, and asynchronous payment methods all mean the client signal on its own is not sufficient.

```javascript theme={null}
window.stripeEvent = function (event) {
    if (event.method !== 'paymentSheet') return

    if (event.status === 'completed') {
        // Ask your backend to read the Payment Intent status from Stripe
        // and only then unlock the purchase in your UI.
        fetch('/api/confirm-payment', { method: 'POST' })
    }
}
```

***

## Test versus live keys

A `pk_test_...` publishable key requires a test-mode client secret, and `pk_live_...` requires a live-mode secret. Mismatched modes resolve to a `failed` event with an SDK message about an invalid client secret, so the keys must always match.

<Warning>
  Do not branch on `window.location.hostname` to choose the key. Despia's [local server](/local-server/introduction) serves the production web build from `http://localhost` on the device, so a `hostname === 'localhost'` shortcut classifies every production install as test mode and the Payment Sheet never succeeds in the wild. The same applies to checking `127.0.0.1` or the lack of a public domain. None of these distinguish dev from production inside a Despia app.
</Warning>

The reliable pattern is to let the server return the publishable key alongside the client secret. The server already knows which mode it is in, since it holds the secret key, so it can hand back the publishable key that pairs with it. Both keys come from the same environment, so they cannot be crossed.

```javascript theme={null}
// Your server endpoint, e.g. /api/create-payment-intent
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)

app.post('/api/create-payment-intent', async (req, res) => {
    const intent = await stripe.paymentIntents.create({
        amount: 1999,
        currency: 'usd',
        automatic_payment_methods: { enabled: true }
    })

    res.json({
        client_secret:   intent.client_secret,
        publishable_key: process.env.STRIPE_PUBLISHABLE_KEY
    })
})
```

Set `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` to the test pair in your dev environment and the live pair in production. The web app never needs to know which is which, it just forwards whatever the server returned into the `despia('stripe://payment?...')` call.

***

## Saved cards on payment

By default, `stripe://payment` is a guest checkout. The Payment Sheet shows a blank card form, and the card the customer enters is not saved to any Stripe customer. To let a returning customer pick a previously saved card, and to save the new one for next time, pass the optional saved-card pair on the same `stripe://payment` command: `customer_id` and `ephemeral_key_secret`. This is a different flow from `stripe://manage`, which opens a dedicated management UI without taking a payment.

| Param                  | Description                                                                                                             |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `customer_id`          | The Stripe customer (`cus_...`) the saved cards belong to.                                                              |
| `ephemeral_key_secret` | Server-created customer ephemeral key (`ek_...`) for that customer. API-version-pinned and short-lived, about one hour. |

Both values are created server-side and forwarded as parameters on the command. The native runtime makes no Stripe network call of its own.

<Warning>
  **All or nothing.** Pass both parameters to enable saved cards, or neither for guest checkout, which is the default and the original behavior. Passing exactly one is treated as a missing required param and returns `{ method: 'paymentSheet', status: 'failed', error: 'missing param' }` with no sheet shown. Existing integrations without these params continue to work unchanged as guest checkouts.
</Warning>

Add the pair to your existing Payment Intent endpoint so a single request returns everything the page needs. Pin the ephemeral key's API version to whatever your mobile SDK expects, and attach the Payment Intent to the same customer so the charge is recorded against them.

```javascript theme={null}
// Your server endpoint, e.g. /api/create-payment-intent
app.post('/api/create-payment-intent', async (req, res) => {
    const customerId = req.user.stripeCustomerId   // however you store it

    const ephemeralKey = await stripe.ephemeralKeys.create(
        { customer: customerId },
        { apiVersion: '2024-06-20' }               // pin to the mobile SDK version
    )

    const intent = await stripe.paymentIntents.create({
        amount: 1999,
        currency: 'usd',
        customer: customerId,                       // attach to the same customer
        automatic_payment_methods: { enabled: true }
    })

    res.json({
        publishable_key:      process.env.STRIPE_PUBLISHABLE_KEY,
        client_secret:        intent.client_secret,
        customer_id:          customerId,
        ephemeral_key_secret: ephemeralKey.secret
    })
})
```

On the page, destructure all four fields and forward them into the command. The existing guest-checkout call site only needs two lines added.

```javascript theme={null}
async function pay() {
    const res = await fetch('/api/create-payment-intent', { method: 'POST' })
    const {
        publishable_key,
        client_secret,
        customer_id,
        ephemeral_key_secret
    } = await res.json()

    if (isDespia) {
        despia(`stripe://payment?publishable_key=${publishable_key}&payment_intent_client_secret=${client_secret}&customer_id=${customer_id}&ephemeral_key_secret=${ephemeral_key_secret}`)
    }
}
```

<Info>
  The Payment Intent must be created with the same `customer` id on the server, not just the ephemeral key. Without it, the saved card is reusable but the payment will not be attached to that customer for receipts, invoicing, dispute records, or revenue analytics. The customer attachment is what makes the saved card actually saveable in the first place.
</Info>

The same `Stripe-Version` pinning gotcha applies here as on `stripe://manage`. A mismatch between the ephemeral key's API version and the mobile SDK's expected version returns a `failed` event with an SDK message about the API version. Ask the mobile team for the exact version, or pin one centrally on the server and update it in lockstep with SDK upgrades.

***

## Manage saved cards

Call `despia('stripe://manage?...')` to open Stripe's native CustomerSheet. The signed-in customer can add new cards, remove existing ones, and pick which card is their default. Use this for a "Payment methods" or "Wallet" screen, and only on screens where Stripe is the actual payment rail.

<Warning>
  This manages Stripe-stored cards only, used for direct Stripe charges. App Store and Google Play subscriptions managed through [RevenueCat](/payments/revenuecat) are not shown or controlled here. Surface "Manage cards" only where Stripe is the rail, never as a generic billing entry point, or users will expect to manage their subscription here and find it missing.
</Warning>

### Backend setup

For the signed-in customer, your server must produce three values and return them to the page:

1. The Stripe customer id (`cus_...`).
2. An **ephemeral key** for that customer, created with the Stripe API version your mobile SDK expects. Returns `ephemeral_key_secret` (`ek_...`). The key is short-lived (about an hour), so generate it on demand right before opening the sheet, not at app start.
3. (Recommended) A **SetupIntent** for that customer. Returns `client_secret` (`seti_..._secret_...`). Without it, the sheet still lists and selects existing cards but the customer cannot add a new one.

```bash theme={null}
# 1. Ephemeral key (API version must match the mobile SDK)
curl https://api.stripe.com/v1/ephemeral_keys \
  -u "sk_live_xxx:" \
  -H "Stripe-Version: 2024-06-20" \
  -d customer=cus_xxx

# 2. SetupIntent for adding a new card
curl https://api.stripe.com/v1/setup_intents \
  -u "sk_live_xxx:" \
  -d customer=cus_xxx \
  -d "automatic_payment_methods[enabled]=true"
```

The web app forwards these secrets as parameters on the `stripe://manage` command. The native runtime makes no Stripe network calls of its own. See the [Ephemeral Keys API reference](https://docs.stripe.com/api/ephemeral_keys/create) and the [SetupIntents API reference](https://docs.stripe.com/api/setup_intents) for the full parameter list.

<Warning>
  The ephemeral key must be created with the exact `Stripe-Version` header your mobile SDK expects. A mismatch fails the sheet with an SDK error. Ask the mobile team for the pinned version, or pin one centrally on your server and update it in lockstep with SDK upgrades.
</Warning>

### How it works

Same fire-and-listen pattern as the payment action. Both share `window.stripeEvent`, so route on `event.method`: `paymentSheet` for charges, `customerSheet` for card management.

```javascript theme={null}
import despia from 'despia-native'

const isDespia = navigator.userAgent.toLowerCase().includes('despia')

window.stripeEvent = function (event) {
    if (event.method === 'customerSheet') {
        if (event.status === 'selected') {
            // Customer confirmed a card selection, possibly a newly added one.
            // Refresh saved cards from your backend if you display them.
        } else if (event.status === 'canceled') {
            // Customer closed the sheet without confirming.
        } else if (event.status === 'failed') {
            console.warn('CustomerSheet error:', event.error)
        }
    }
}

async function manageCards() {
    const res = await fetch('/api/stripe/customer-sheet-setup', { method: 'POST' })
    const {
        publishable_key,
        customer_id,
        ephemeral_key_secret,
        setup_intent_client_secret
    } = await res.json()

    if (isDespia) {
        despia(`stripe://manage?publishable_key=${publishable_key}&customer_id=${customer_id}&ephemeral_key_secret=${ephemeral_key_secret}&setup_intent_client_secret=${setup_intent_client_secret}`)
    }
}
```

### Parameters

| Param                                                            | Required | Notes                                                                          |
| ---------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------ |
| `publishable_key`                                                | Yes      | `pk_live_xxx` or `pk_test_xxx`. Must match the mode of the ephemeral key.      |
| `customer_id`                                                    | Yes      | The Stripe customer (`cus_...`).                                               |
| `ephemeral_key_secret`                                           | Yes      | From your server (`ek_...`). API-version-pinned and short-lived.               |
| `setup_intent_client_secret`                                     | No       | From your server (`seti_..._secret_...`). Omit to disable the "add card" flow. |
| `theme`, `accent_color`, `corner_radius`, `action_corner_radius` | No       | Same styling as the `payment` action.                                          |

Missing or empty `publishable_key`, `customer_id`, or `ephemeral_key_secret` returns `{ method: 'customerSheet', status: 'failed', error: 'missing param' }` and the sheet is not shown.

### CustomerSheet result events

The shape mirrors PaymentSheet, with `method: 'customerSheet'` and a different status set. There is no `completed` status, since managing cards is not a payment, and no `selected` status on `paymentSheet` either, so a strict `method` check is enough to route correctly.

```javascript theme={null}
// status: 'selected'
{ "method": "customerSheet", "status": "selected" }

// status: 'canceled'
{ "method": "customerSheet", "status": "canceled" }

// status: 'failed' (validation, sheet never shown)
{ "method": "customerSheet", "status": "failed", "error": "missing param" }

// status: 'failed' (Stripe SDK error)
{ "method": "customerSheet", "status": "failed", "error": "The ephemeral key has expired." }
```

The `selected` event fires when the customer confirms a card selection, including immediately after adding a new card via the SetupIntent flow. The chosen card's id, brand, and last four digits are intentionally not included in the event. Read the customer's saved methods from your backend if you need to display the current default.

***

## Resources

<CardGroup cols={2}>
  <Card title="NPM Package" icon="npm" href="https://www.npmjs.com/package/despia-native">
    despia-native
  </Card>

  <Card title="Support" icon="envelope" href="mailto:support@despia.com">
    [support@despia.com](mailto:support@despia.com)
  </Card>
</CardGroup>
