Web purchases run through Stripe rather than an app store. When a customer
signs in with the same app_user_id on web and mobile, entitlements bought in
a Stripe checkout unify with those bought on iOS and Android automatically. See
Entitlements.
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#
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.
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.
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.
In the Stripe dashboard, in test mode, go to Developers → API keys → Create restricted key and grant:
| Permission | Access |
|---|---|
| Checkout Sessions, Customers, Customer portal | Write |
| Prices, Products, Subscriptions, Invoices, Charges, Refunds, Payment Intents, Disputes, Events | Read |
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.
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.
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:
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.
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#
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.
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.
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.
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.
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.
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 Stripe | Recorded as |
|---|---|
| Every subscription, whatever its status | Its current access, read exactly as What changes access describes. A canceled one grants nothing |
| Paid invoices | One payment each, as invoice.paid would record it |
| Paid one-time Checkout Sessions | One purchase per line item, as checkout.session.completed would record it |
| Charges with a refund | The 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.
CashSDK also checks your current Stripe subscriptions every four hours, and the subscriptions Stripe's events say changed in that time. A renewal, a trial that converted or a cancellation whose webhook never arrived is corrected on that pass, without a sync. It needs read access to Events for cancellations.
What changes access#
| Stripe reports | Access in CashSDK |
|---|---|
Subscription active | Granted until the current period ends |
Subscription trialing | Granted until the trial ends, marked as a trial and left out of paid MRR |
cancel_at_period_end | Kept until the period ends, then ends |
| A new subscription whose first invoice is not paid yet, such as a bank debit that has not settled | Nothing until the invoice is paid |
| A first invoice voided, marked uncollectible or failed | Ended |
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 paused | Kept to the end of the paid period, not beyond |
unpaid, paused, incomplete_expired, canceled | Ended |
| A subscription with several items | Each item grants its own product |
| An item removed from a subscription | That product ends when Stripe reports the change |
| Paid one-time Checkout Session | Each line item is a purchase. A non-consumable product unlocks for good |
| Full refund | Ends what the payment bought |
| Partial refund | Adjusts revenue only |
| Dispute, funds withdrawn | Ends what the payment bought, like a full refund |
| Dispute, funds reinstated | Restores it |
| Dispute lost | Counted 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_idin 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:
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#
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.
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.
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.