Stripe

Connect Stripe Billing so web subscriptions live in the same entitlement model and revenue ledger as your App Store and Google Play sales.

Stripe powers card, wallet, and bank payments for the web side of your business. Connect Stripe Billing to CashSDK and a checkout on the web resolves to the same entitlement your mobile SDK already respects: one customer, one set of access, one revenue number to reconcile.

How it works#

1
Connect your Stripe account

Add a restricted Stripe API key in your dashboard, one for Stripe's test mode and one for live mode. CashSDK reads your prices, subscriptions and payments. It never sees card data.

2
Map prices to entitlements

Each Stripe Price maps to a CashSDK product and the entitlement it unlocks, so a subscription resolves to pro (or whatever you name it) everywhere.

3
Reconcile with signed webhooks

Every Stripe event (payments, renewals, cancellations, refunds) is verified against its signature, retried, and merged into your unified revenue timeline.

Prerequisites#

  • A Stripe account with Stripe Billing enabled.
  • Products and recurring Prices created in Stripe (or create them as you map the catalog).
  • Access to your Stripe API keys and Webhooks settings.

Connect Stripe#

An app has a Sandbox connection for Stripe's test mode and a Production connection for live mode, side by side. Start with Sandbox. Each connection has its own key and its own webhook URL. When you are ready for real payments, add Production as Go live with Stripe describes, and keep Sandbox for testing.

1
Create a restricted API key

In the Stripe dashboard, in test mode, go to Developers → API keys → Create restricted key and grant:

PermissionAccess
Checkout Sessions, Customers, Customer portalWrite
Prices, Products, Subscriptions, Invoices, Charges, Refunds, Payment Intents, Disputes, EventsRead

All of them are required; only Account read is optional. The reads let CashSDK re-read each subscription before it changes access and sync your existing Stripe data. The writes let it start a checkout and open the customer portal. Copy the rk_test_… key.

2
Add it to CashSDK

In your dashboard, open Your App → Marketplace → Stripe → Connect and paste the restricted key. It is stored encrypted at rest. Choose the environment that matches the key: Sandbox for rk_test_…, Production for rk_live_…. A key in the other mode is refused. Leave the signing secret empty for now.

3
Register the webhook

Copy the webhook URL the connection shows and add it in Stripe, in the same mode as the key, under Developers → Webhooks. Subscribe to these events:

text
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
customer.subscription.paused
customer.subscription.resumed
invoice.paid
invoice.payment_failed
invoice.voided
invoice.marked_uncollectible
charge.refunded
charge.dispute.funds_withdrawn
charge.dispute.funds_reinstated
charge.dispute.closed
checkout.session.completed
checkout.session.expired
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed

Then click Reconnect, paste the key and Stripe's signing secret (whsec_…), and click Activate.

4
Sync what Stripe already holds

Click Sync from Stripe on the connection, so customers who subscribed before you connected get access and past payments count. See Sync your existing Stripe data.

Use a restricted key, not your secret key, and keep it server-side. CashSDK holds it, your app never does. In the browser your app only ever uses a CashSDK publishable key (csk_pk_…).

To rotate the Stripe key or the webhook signing secret, create the new one in Stripe, then click Reconnect on the connection and paste it. An active connection stays active, with nothing to activate again.

Map prices to entitlements#

Each Stripe Price is a product in CashSDK with the store Stripe and the Stripe Price ID (price_…) as its identifier, attached to the entitlement it unlocks: Auto-renewable for a recurring price, Non-consumable for a one-time price. Test and live prices have different IDs, so each is a product of its own. With your own Checkout, coupons and trials stay in Stripe: CashSDK reads the outcome of each payment. Checkout started by the SDK takes its free trial from the product's introductory offer in CashSDK.

Import your Stripe prices#

1
Open the import

Go to Your App → Products and click Import from Stripe. The button shows once Stripe is connected with an API key, and the import needs the admin role. The Import link on the Stripe group of the products table opens the same dialog. With both connections, import from each: the test prices from Sandbox, the live prices from Production.

2
Pick the prices

The dialog lists every active price in the connected Stripe account with its product name, amount and billing period. Tick the ones to import, or Select all that can be imported. A price marked Already imported is a product already. A price marked Price differs is a product already, but with a different amount, currency or billing period than Stripe's (see below). A price marked Not supported shows why: metered, free, tiered, a customer-chosen amount, or package pricing.

3
Choose the entitlement and import

Pick the Entitlement the new products unlock, then click Import. With None, I will map access later, the new products are listed under Needs mapping until you attach an entitlement. The summary lists what was created, what was already imported, what conflicts with Stripe, and what was skipped and why.

CashSDK reads each picked price from Stripe again before it creates anything, so the amount, currency and billing period always match Stripe. A recurring price becomes an auto-renewable product (a monthly price is P1M, a quarterly one P3M, a yearly one P1Y) and is also mapped for checkout started by the SDK. A one-time price becomes a non-consumable product. Importing a price twice changes nothing: an existing product keeps its settings.

Each product's name is the Stripe product's name. Recurring prices of one Stripe product share a subscription group, which is the Stripe product ID (prod_…), so a customer moves between them as an upgrade or a downgrade. Prices of two different Stripe products are never in one group, even when the products have the same name. Products imported before this rule keep the group they were given (the Stripe product's name); edit the group on the Products page if two different Stripe products share one.

When a product's price differs from Stripe#

A product with the Price ID as its identifier can already exist with a different amount, currency or billing period, for example one made by hand. Checkout started by the SDK compares the product with the Stripe price and refuses a mismatch (catalog_price_mismatch), so the import does not map that price. It reports it as a conflict: "the product's price differs from Stripe: update the product to Stripe's price, or remove it from your offerings". The product is left as it is.

To fix it from the import, tick Update products whose price differs from Stripe, pick those prices and import. It sets their amount, currency and billing period to Stripe's and then maps them; the name and entitlements stay. It is off by default and never turns a one-time product into a subscription or the reverse: edit that on the Products page.

When you archive a price in Stripe#

Archiving a price, or its product, in Stripe does not change the CashSDK product: the paywall keeps showing it, and checkout for it fails. The import dialog lists every active Stripe product whose price is archived, or no longer in the connected account, under no longer sold in Stripe. Remove those products from your offerings. CashSDK does not archive them for you.

To add a price by hand instead, create a product under Your App → Products → New product with the store Stripe and the Price ID as its identifier.

Sync your existing Stripe data#

Webhooks only tell CashSDK about what happens after you connect. A sync reads what your Stripe account already holds: subscriptions that started before you connected, past payments and refunds.

1
Start the sync

Open Your App → Marketplace and click Sync from Stripe under the Stripe connection, or Sync from Stripe next to Import from Stripe on Your App → Products. Each connection syncs its own environment: Sandbox reads Stripe's test mode, Production its live mode. The connection must be active and made with an API key, and the sync needs the admin role.

2
Watch it run

The panel shows which kind it is reading and, per kind (Subscriptions, Paid invoices, One-time checkouts, Refunds), how many objects it read from Stripe, how many CashSDK recorded and how many it skipped. It runs in the background, so you can leave the page and come back.

3
Check the result

When it finishes, the panel shows the time of the last sync and any error, for example a restricted key that cannot read Invoices or Charges. Add the missing read access in Stripe and click Sync from Stripe again.

What a sync reads, in this order:

From StripeRecorded as
Every subscription, whatever its statusIts current access, read exactly as What changes access describes. A canceled one grants nothing
Paid invoicesOne payment each, as invoice.paid would record it
Paid one-time Checkout SessionsOne purchase per line item, as checkout.session.completed would record it
Charges with a refundThe refund, against the payment it reverses

It is safe to run again. Each payment is recorded once, under its Stripe PaymentIntent, so a second sync adds no revenue, sends your webhooks no second event and changes no access. Skipped means there was nothing to record: an unpaid session, a charge with no refund, a session sold as a subscription (its invoices count instead), or a subscription already up to date.

A subscription with no cashsdk_user_id in its metadata grants nobody access. CashSDK keeps it, the panel says how many are waiting, and access is granted the moment you link that Stripe customer to a user, on Your App → Web and Stripe → Customer identity or with the link_marketplace_user MCP tool. A subscription whose latest payment you refunded in full stays without access even though Stripe still lists it as active.

A sync reads everything Stripe holds in that mode. What happened before the connection was activated is recorded as history and sends no webhooks to your server. Your agent can run the same sync with the sync_stripe MCP tool and read its progress with get_stripe_sync; both accept an optional since date that limits payments and refunds to those created after it.

What changes access#

Stripe reportsAccess in CashSDK
Subscription activeGranted until the current period ends
Subscription trialingGranted until the trial ends, marked as a trial and left out of paid MRR
cancel_at_period_endKept until the period ends, then ends
A new subscription whose first invoice is not paid yet, such as a bank debit that has not settledNothing until the invoice is paid
A first invoice voided, marked uncollectible or failedEnded
Subscription past_due (a renewal payment failed)Kept to the end of the last paid period plus 7 days while Stripe retries. A successful retry restores the new period
Payment collection pausedKept to the end of the paid period, not beyond
unpaid, paused, incomplete_expired, canceledEnded
A subscription with several itemsEach item grants its own product
An item removed from a subscriptionThat product ends when Stripe reports the change
Paid one-time Checkout SessionEach line item is a purchase. A non-consumable product unlocks for good
Full refundEnds what the payment bought
Partial refundAdjusts revenue only
Dispute, funds withdrawnEnds what the payment bought, like a full refund
Dispute, funds reinstatedRestores it
Dispute lostCounted once; the withdrawal already ended access

Stripe can deliver events late, twice or out of order. Before it changes access, CashSDK reads the subscription from Stripe again, so a late or repeated event cannot roll access back. A status update about a period you refunded in full does not give that period back; a later, paid period does.

The customer is the cashsdk_user_id in the subscription's metadata, or the Checkout Session's client_reference_id for a one-time purchase. Without either, CashSDK keeps the event under the Stripe customer and, once you link that customer to a user, moves its subscriptions, one-time purchases and revenue to the user.

How CashSDK handles Stripe events#

  • Each event's signature is checked with the connection's signing secret. Bodies up to 2 MB are accepted.
  • An event that cannot be applied is retried for about three days: after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 6 hours. After that it is set aside. Resend on the event in Stripe's dashboard starts it again.
  • A restricted key without read access to Subscriptions makes every subscription event fail this way. Keep the permissions complete.
  • While a connection is paused, its events are held and applied when you activate it again.
  • A disconnected connection answers Stripe with 410 Gone. Remove its endpoint in Stripe.
  • When several CashSDK apps share one Stripe account, each ignores the objects another app created, which carry that app's cashsdk_app_id in their metadata.

Sell on the web#

Keep your own Stripe Checkout and put your user id in the subscription's metadata as cashsdk_user_id. Your web app then reads the customer's access with the Web SDK, and the same entitlement unlocks your iOS and Android apps:

ts
import { createCashSDK } from "cashsdk-web";

const cashsdk = createCashSDK({ publishableKey: "csk_pk_…", environment: "production", getSessionToken });
await cashsdk.initialize();

const { active } = await cashsdk.waitForEntitlement("pro", { timeoutMs: 60000 }); // on your return page

The full walkthrough, including the metadata field and the return page, is Getting paid on the web.

Let subscribers update their card, cancel and read their invoices in Stripe's hosted customer portal, opened from the web SDK with openBillingPortal (see Let customers manage their subscription). Save the portal settings in Stripe first, in test and in live mode. Changes made there reach CashSDK through the same webhooks.

Test and go live#

1
Run a test checkout

With the Sandbox connection (rk_test_…) and your app on environment: "sandbox", complete a checkout using a Stripe test card (for example 4242 4242 4242 4242). Confirm it grants the entitlement: the customer shows it under Customers.

2
Tax (optional)

Stripe Tax works in your own Checkout as usual. For checkout started by the SDK, turn it on per environment, as Checkout settings describes. CashSDK records the amount Stripe collected for each payment.

3
Go live

Add the Production connection with your rk_live_… key and register its own webhook URL in live mode. Import the live prices, which have new IDs. Then move your server to a live CashSDK key (csk_rk_live_…): CashSDK server keys are tied to one mode, so a test key reads only Sandbox and a live key only Production. The publishable key stays the same. Go live with Stripe has every step, including a first live purchase and refund.

Next steps#