Skip to main content

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.

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.
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, 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 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.
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.

Installation

npm install despia-native
import despia from 'despia-native';

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.
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:
{
  "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 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, the Create a Payment Intent endpoint, and the Payment Intents guide.

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().
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. 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.
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.
FormExampleNotes
6-digit hex1A73E8most common, recommended
3-digit shorthandF0Aexpands to FF00AA
8-digit with alpha1A73E8CCRRGGBBAA, last byte is opacity
With # prefix#1A73E8works but see below
Percent-encoded #%231A73E8output of encodeURIComponent('#1A73E8')
Hex digits are case-insensitive. All five forms above resolve to the same color when the underlying hex matches.
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.
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.
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.
// 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.
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.
// 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.
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.
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.
Do not branch on window.location.hostname to choose the key. Despia’s local server 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.
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.
// 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.
ParamDescription
customer_idThe Stripe customer (cus_...) the saved cards belong to.
ephemeral_key_secretServer-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.
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.
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.
// 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.
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}`)
    }
}
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.
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.
This manages Stripe-stored cards only, used for direct Stripe charges. App Store and Google Play subscriptions managed through 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.

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.
# 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 and the SetupIntents API reference for the full parameter list.
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.

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.
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

ParamRequiredNotes
publishable_keyYespk_live_xxx or pk_test_xxx. Must match the mode of the ephemeral key.
customer_idYesThe Stripe customer (cus_...).
ephemeral_key_secretYesFrom your server (ek_...). API-version-pinned and short-lived.
setup_intent_client_secretNoFrom your server (seti_..._secret_...). Omit to disable the “add card” flow.
theme, accent_color, corner_radius, action_corner_radiusNoSame 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.
// 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

NPM Package

despia-native