Getting paid on the web
Keep your own Stripe Checkout and let its signed events grant CashSDK access, start a hosted Stripe Checkout from the web SDK, and open Stripe's customer portal.
Access on the web comes from your payment provider's signed events, never from the browser. There are two ways to take the payment.
| Your own Stripe Checkout | Checkout started by the SDK | |
|---|---|---|
| Who creates the checkout | Your server, as today | CashSDK, from cashsdk.checkout() |
| What it sells | Whatever your Checkout sells | The Stripe prices in your current offering: subscriptions and one-time products |
| What you add | A Stripe connection and your user id on each purchase | A Stripe connection and the prices in your offering |
Both start with the Stripe connection below. When you are ready for real payments, follow Go live with Stripe.
Connect Stripe#
An app can have two Stripe connections, side by side: Sandbox for Stripe's test mode and Production for live mode. Build and test against Sandbox. Connect Production when you go live, and keep Sandbox for testing afterwards. Each connection has its own webhook URL, and access granted in Sandbox stays in Sandbox.
In Stripe's test mode, open Developers → API keys → Create restricted key and give it these permissions:
| Permission | Access |
|---|---|
| Checkout Sessions, Customers, Customer portal | Write |
| Prices, Products, Subscriptions, Invoices, Charges, Refunds, Payment Intents, Disputes, Events | Read |
Give the key all of them; only Account read is optional. Without read access to Subscriptions, Stripe's subscription events cannot be applied and end up set aside after their retries. The write permissions let CashSDK create the Checkout Session, the Stripe customer it reuses for your customer, and the billing portal session.
Open Your App → Marketplace → Stripe → Connect, paste the key and choose the environment
that matches it: Sandbox for rk_test_…, Production for rk_live_…. A key in the other
mode is refused with environment_mismatch. Leave the signing secret empty for now. The
connection now shows its webhook URL.
In Stripe's test mode, open Developers → Webhooks, add an endpoint with that URL and select 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
Back in CashSDK, click Reconnect, paste the key again and the endpoint's signing secret
(whsec_…), then click Activate. A connection without a signing secret cannot be
activated.
Rotate the key or the signing secret#
Create the new restricted key in Stripe, or roll the endpoint's signing secret, then click Reconnect on the connection and paste the new value. An active connection stays active, so sync, reconciliation and checkout keep running without a second Activate. Delete the old key in Stripe once CashSDK has the new one.
Your own Stripe Checkout#
Nothing about how you sell changes. CashSDK listens to your Stripe account and keeps each customer's access in step with their subscription: granted when it starts, extended when it renews, kept until the paid period ends after a cancellation, and removed when the current payment is refunded in full or disputed. Before it changes access, CashSDK reads the subscription from Stripe again, so a late or out-of-order event cannot roll access back.
One CashSDK product per Stripe price, with the store Stripe and the Stripe Price ID
(price_…) as its identifier.
Import your Stripe prices. Open Your App → Products and click Import from Stripe (the button shows once Stripe is connected; you need the admin role). The dialog lists the active prices in your Stripe account with their product name, amount and billing period:
- Tick the prices to import, or Select all that can be imported.
- Pick the Entitlement each new product unlocks, such as
pro. With None, I will map access later, the new products show under Needs mapping until you attach one. - Click Import. The summary says how many products were created, how many prices were already imported, and why any were skipped.
A recurring price becomes an auto-renewable product with the same billing period (a monthly
price is P1M, a yearly one P1Y), and a one-time price a non-consumable. The name, amount
and currency come from Stripe, never from the dialog. Prices marked Already imported are
left as they are, so importing again creates nothing new. Prices marked Not supported
(metered, tiered, free, a customer-chosen amount, package pricing) cannot be imported.
Or by hand. Under Your App → Products → New product, choose the store Stripe, use the Price ID as the identifier, set the type to auto-renewable, and attach the entitlement.
When you create the Checkout Session, put your user id in the subscription's metadata. It must
be the same customerId your server uses for web sessions, and the same app user id your mobile
apps use, so the customer is one person everywhere.
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_…", quantity: 1 }],
subscription_data: { metadata: { cashsdk_user_id: user.id } },
success_url: "https://app.example.com/billing/return",
cancel_url: "https://app.example.com/pricing",
});
A Stripe Payment Link cannot put a different user id on each subscription, and CashSDK reads
client_reference_id only on one-time payments. A subscription bought through a Payment Link
therefore waits under its Stripe customer until you
link that customer to your user, or until your server
adds cashsdk_user_id to the subscription's metadata.
Stripe sends its events a few seconds after the payment. On your return page, wait for the entitlement instead of asking the customer to reload:
const { active } = await cashsdk.waitForEntitlement("pro", { timeoutMs: 60000 });
active: false means the event has not arrived yet. It is not a failed payment.
Sync your existing Stripe data#
Subscriptions, payments and refunds that already exist in Stripe reach CashSDK when you sync. Once the connection is active, open Your App → Marketplace and click Sync from Stripe under the Stripe connection (the same button sits next to Import from Stripe on Your App → Products; you need the admin role). Each connection syncs its own environment. It pulls everything Stripe holds: your existing subscriptions, payments and refunds. It is safe to run again: nothing is recorded twice and no webhook is sent twice. What happened before the connection was activated is recorded as history and sends no webhooks to your server.
The panel shows the kind being read, then per kind (Subscriptions, Paid invoices, One-time checkouts, Refunds) how many objects were read, recorded and skipped, the time of the last sync and any error. An error usually names a read permission the restricted key lacks; add it in Stripe and sync again.
- An
active,trialingorpast_duesubscription grants access exactly as its webhook would. A canceled one grants nothing. - Paid invoices and paid one-time Checkout Sessions become revenue, and refunded charges reverse it.
- A subscription with no
cashsdk_user_idgrants nobody access. CashSDK keeps it, the panel says how many are waiting, and it applies the moment you link that Stripe customer to a user. - A subscription whose latest payment you refunded in full stays without access, even though Stripe still lists it as active.
CashSDK also re-reads your current Stripe subscriptions every four hours, plus the ones Stripe's events say changed, so a renewal or cancellation whose webhook never arrived is corrected without a sync. Details: Sync your existing Stripe data.
Link a Stripe customer to your user#
A Stripe customer that no event names (no cashsdk_user_id, no client_reference_id on a
one-time payment) is kept until you say who it is. Confirm that the person owns the account you
link it to, because a link moves paid access:
- In the dashboard, open Your App → Web and Stripe → Customer identity, click Link customer next to the waiting Stripe customer and enter your user id. You need the owner or admin role.
- Through the MCP server, the
link_marketplace_usertool does the same. It needs an owner or admin OAuth sign-in or a write-enabled MCP token. A setup token cannot link customers.
Both call POST /v1/apps/{appId}/marketplace/user-links, which takes a dashboard session or an MCP
token. A server key (csk_sk_… or csk_rk_…) cannot call it. CashSDK then applies everything
that was waiting for that customer.
One-time purchases#
Sell a lifetime unlock with a Checkout Session in payment mode. Create the product in CashSDK
with the Stripe Price ID and the type Non-consumable, and name the customer with
client_reference_id:
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: "price_…", quantity: 1 }],
client_reference_id: user.id,
success_url: "https://app.example.com/billing/return",
cancel_url: "https://app.example.com/pricing",
});
When the session is paid, CashSDK reads its line items from Stripe and records one purchase per
item. A payment method that settles later (a bank debit) counts once Stripe sends
checkout.session.async_payment_succeeded. A full refund of the payment takes the unlock back.
Failed payments, disputes and trials#
When Stripe cannot collect a renewal, the subscription becomes past_due and Stripe retries the
card. The customer keeps access to the end of the last paid period plus 7 days. A retry that
succeeds restores the new period; if Stripe gives up, the subscription ends and so does access.
A new subscription paid by a bank debit grants nothing until its first invoice is paid, and ends if
that invoice is voided, uncollectible or failed. A dispute whose funds are withdrawn removes access
like a refund, and access returns if the funds are reinstated. A trialing subscription grants
access and is reported as a trial, so it is left out of paid MRR. The full table is on the
Stripe page.
Your iOS and Android apps#
A Stripe subscription unlocks your native apps too. Add Web to the same CashSDK app your iOS and Android builds use, and identify the customer with the same user id everywhere:
- The native SDKs read the same entitlements, so
entitlements.isActive("pro")is true on the phone for a customer who subscribed on your website. offerings()in the native SDKs only returns App Store and Google Play packages. Stripe packages in the same offering are shown to the web SDK and left out on the phone.restore()restores store purchases. A customer whose access comes only from Stripe already has it after the next entitlement refresh, so there is nothing to restore.
Linking from inside a native app to a purchase on your website is subject to App Store and Google Play rules, which differ by country. Check them before you add such a link.
Checkout started by the SDK#
CashSDK creates a hosted Stripe Checkout for a package in your current offering. The price, currency and customer come from CashSDK and Stripe, never from the browser, and every request is idempotent. Chrome extensions and direct Mac apps use the same flow.
Before the first checkout#
- The session's scopes. Your backend adds
checkout:createandcheckout:readto the default scopes on pages that sell. See Server SDK. - An active Stripe connection made with a restricted key that has the write permissions above,
in the environment the session belongs to. A connection made through Stripe sign-in (OAuth) is
refused with
connection_unavailable. - The product in your current offering. A Stripe product whose price, currency and billing
period match the Stripe price exactly. Import from Stripe creates it that way and records the
price mapping checkout reads. A mismatch is refused with
catalog_price_mismatch. - Prices of the right mode. Test prices sell only in Sandbox and live prices only in Production. Go live with Stripe shows how to add the live ones next to the test ones.
Buy a package#
purchase() does the bookkeeping: it keeps one idempotency key per customer and package, saves it
before the request, and resumes the customer's open checkout instead of starting a second one. Call
it from a click, then send the customer to Stripe.
const offerings = await cashsdk.getOfferings();
const pack = offerings.current?.packages.find((item) => item.purchasable);
const checkout = await cashsdk.purchase(pack!.id, {
successUrl: `${location.origin}/billing/return?package=${encodeURIComponent(pack!.id)}`,
cancelUrl: `${location.origin}/pricing`,
});
if (checkout.action?.type === "redirect") location.assign(checkout.action.url);
purchasable is false for a package this session cannot buy: checkout is off, the session lacks
checkout:create, the product is not a fixed Stripe price, or the customer already has it. On the
return page:
const packageId = new URL(location.href).searchParams.get("package")!;
const outcome = await cashsdk.waitForPurchase(packageId, { timeoutMs: 60000 });
if (outcome?.settled && outcome.checkout.status === "complete") {
await cashsdk.waitForEntitlement("pro", { timeoutMs: 60000 });
}
An attempt that is over makes room for the next. An expired or failed checkout (nothing was
charged) is replaced by a new one on the next purchase(). A complete one is returned once, and
the next call starts a new attempt. getPurchase(packageId) reads the current attempt, or null.
Attempts are kept in sessionStorage. Pass purchaseStorage (an object with get, set and
remove) to createCashSDK to keep them elsewhere, as the
Chrome example does
with chrome.storage.local, so an attempt survives the service worker stopping.
The lower-level calls#
purchase() is built on checkout() and waitForCheckout(). Use them when you keep the attempt
yourself: store the idempotency key before the request and reuse it on any retry.
const attempt = sessionStorage.getItem("checkout-attempt") ?? crypto.randomUUID();
sessionStorage.setItem("checkout-attempt", attempt);
const checkout = await cashsdk.checkout(
{ packageId: pack!.id, successUrl: `${location.origin}/billing/return`, cancelUrl: `${location.origin}/pricing` },
{ idempotencyKey: attempt },
);
sessionStorage.setItem("checkout-id", checkout.id);
if (checkout.action?.type === "redirect") location.assign(checkout.action.url);
// On the return page
const { checkout: done, settled } = await cashsdk.waitForCheckout(sessionStorage.getItem("checkout-id")!, { timeoutMs: 60000 });
if (settled && done.status === "complete") sessionStorage.removeItem("checkout-attempt");
A completed checkout grants nothing by itself. Access follows Stripe's signed events, usually within seconds.
What the customer sees#
- Subscriptions and one-time products. A recurring Stripe price opens a subscription checkout. A one-time price (a non-consumable product) opens a payment checkout.
- Free trials. When the product has an active Introductory offer whose discount is Free trial (under Your App → Offerings → Offers), and the customer is eligible, the subscription starts with that trial. A customer who already bought in the product's subscription group is not eligible, the same rule the paywall uses. A discounted introductory price is not applied: use a Stripe promotion code for that.
- Promotion codes. Off by default. When you turn them on for an environment, the hosted page shows Stripe's promotion code field, and the codes are the ones you create in Stripe.
- Stripe Tax. Off by default. When you turn it on for an environment, Stripe calculates tax and the page asks for a billing address, which is saved on the Stripe customer. Set up Stripe Tax in that Stripe mode first.
- Cards only. The page offers cards, with the wallets Stripe shows for cards. Bank debits (SEPA, ACH, Bacs, BECS) are not offered. A debit makes a subscription active before the money arrives, and when it fails Stripe voids the invoice but keeps the subscription active, which would grant access nobody paid for.
- 60 minutes. The Checkout Session expires an hour after it is created. Start a new attempt, with a new key, after that.
- Your custom domain. If your Stripe account serves Checkout and the customer portal from a
custom domain, their URLs are on that domain. The SDKs open those pages under Stripe's own paths
(
/c/pay/and/p/session/) on an HTTPS domain. - One Stripe customer per customer. CashSDK creates one Stripe customer for each of your
customers in each Stripe account, with
cashsdk_user_idandcashsdk_app_idin its metadata. Every later checkout and the billing portal reuse it, so saved cards and invoices stay together.
Checkout settings#
Checkout is on by default in each environment. Promotion codes and Stripe Tax are off. Change them
per environment, Sandbox and Production separately, on Your App → Web and Stripe: Allow SDK
checkout, Accept Stripe promotion codes and Calculate tax with Stripe Tax. Agents use the
configure_commerce MCP tool. The route behind both is PUT /v1/apps/{appId}/web/configuration
with checkoutEnabled, allowPromotionCodes and automaticTax. Send the environment's current
allowedOrigins and enabled with every call; a checkout setting left out keeps its value. It needs the admin role, through a dashboard session or an MCP
token. Changing only the checkout settings leaves customer sessions alone. Changing the allowed
origins, or turning sessions on or off, ends every session in that environment.
Refusals and what to do#
| Code | Status | What happened | What to do |
|---|---|---|---|
already_subscribed | 409 | The customer has active access in this product group, from any store | Open the billing portal so they can change their plan |
already_owned | 409 | The customer owns this one-time product | Show it as owned |
already_purchased | 409 | Another checkout for this plan was just paid, in another tab or window | Wait for access with waitForEntitlement |
checkout_in_progress | 409 | Another checkout for this plan is being created right now | Retry after the Retry-After seconds (2). retryable is true |
checkout_uncertain | 503 | CashSDK could not confirm the request | Retry with the same idempotency key |
checkout_unavailable | 403, 503 | Checkout is off for the app or the environment, or the service is unavailable | Check the checkout settings. Retry a 503 later |
invalid_publishable_key | 403 | The publishable key is missing, malformed or revoked. This is a configuration error, not an expired session | Fix the key the page uses |
insufficient_scope | 403 | The session lacks checkout:create or checkout:read | Ask for the scopes when your backend mints the session |
unsupported_checkout, invalid_mapping | 400 | The package is not a Stripe product of the current offering, or its price is not a fixed Stripe price | Fix the product and its Stripe price |
catalog_price_mismatch | 409 | The Stripe price differs from the product | Import the price again, or update the product |
connection_unavailable, connection_rejected | 409 | No active key connection in this environment, or Stripe refused its key | Fix the Stripe connection |
provider_rejected | 409 | Stripe refused the request and created nothing | Check the price and the connection, then start a new attempt |
provider_request_failed | 503 | Stripe did not answer | Retry with the same idempotency key |
idempotency_conflict | 409 | The key was used for a different request | Start a new attempt with a new key |
The full list is in the reference.
Retries and open checkouts#
Send the same idempotency key again and CashSDK returns the original checkout instead of creating
another, even if the customer bought the plan in the meantime. A refusal such as
already_subscribed only applies to a new key. Once a new checkout for a plan exists, CashSDK
expires the customer's other open checkouts for that plan, so the customer is not left with two
payable pages.
checkout.status | Meaning |
|---|---|
requires_action | Send the customer to checkout.action.url |
pending | Still being created. Try again with the same idempotency key |
complete | Stripe finished the checkout. Access follows from Stripe's events |
expired | The customer did not pay in time, or a newer checkout replaced it. Start a new attempt with a new key |
failed | Stripe refused the request and created nothing. Fix the connection or price, then start a new attempt |
needs_review | The outcome is unknown after Stripe's retry window. Do not start a replacement automatically |
Let customers manage their subscription#
Stripe's customer portal lets a customer update their card, cancel, and read and download their invoices, on a page Stripe hosts. CashSDK opens a short-lived portal session for the signed-in customer's own Stripe customer. Whatever they change there reaches CashSDK through the Stripe events you already send, and access follows it.
In Stripe, open Settings → Billing → Customer portal, choose what customers may do there
(update payment methods, cancel, switch plans, see invoices) and save. Do it in test mode and
again in live mode: until the settings are saved in a mode, Stripe refuses portal sessions in
it, and CashSDK reports provider_rejected.
The restricted key of the connection needs write access to Customer portal, as listed in Connect Stripe. It also reads Customers, Subscriptions and Checkout Sessions to find the customer.
billing:manage is not a default scope. Request it in the session your backend issues for
pages that show a billing button:
const session = await cashsdk.createWebSession({
customerId: user.id,
origin: "https://app.example.com",
scopes: ["customer:read", "entitlements:read", "subscriptions:read", "billing:manage"],
});
openBillingPortal returns the portal URL and never navigates by itself. The URL expires
within minutes, so ask for it when the customer clicks and send them there at once.
document.querySelector("#manage-billing")!.addEventListener("click", async () => {
const { url } = await cashsdk.openBillingPortal({ returnUrl: `${location.origin}/account` });
location.assign(url);
});
The return URL must be on the session's origin. In React, use
useBillingPortal. From your backend, use
createBillingPortalSession.
Which Stripe customer it opens#
A portal shows everything a Stripe customer pays for, so CashSDK only opens one it can show is
this customer's. It looks at the customer's Stripe subscriptions in this environment, the checkouts
CashSDK started for them, and the customer links you made,
and confirms each one with Stripe. It skips a Stripe customer when Stripe's records name another
user (cashsdk_user_id in the metadata), when CashSDK links it or any of its subscriptions to
another customer, or when it belongs to the other environment. The browser cannot name a Stripe
customer.
| Code | Status | Meaning |
|---|---|---|
no_billing_account | 404 | No Stripe customer of this customer exists in this environment yet, for example before their first payment |
billing_account_conflict | 409 | The only Stripe customer found is linked to another customer too. Correct the link, then try again |
insufficient_scope | 403 | The session lacks billing:manage |
invalid_return_url | 400 | The return URL is not on the session's origin, or has a fragment |
connection_unavailable | 409 | No active Stripe connection with an API key in this environment |
connection_rejected | 409 | Stripe refused the key. Give it write access to Customer portal |
provider_rejected | 409 | Stripe refused the request. Save the customer portal settings for this mode |
rate_limited | 429 | More than 20 portal sessions a minute for this customer. Wait retryAfterMs |
provider_request_failed, billing_portal_unavailable | 503 | Stripe or CashSDK did not answer. Try again |
Other providers#
Web commerce is generally available with Stripe only. Paddle, Lemon Squeezy, Polar, FastSpring, Gumroad, Recurly and Chargebee are not generally available for it. Their connections report subscription state the same way Stripe does, so their subscriptions grant and end access once they are connected in the Marketplace. Put your user id where the connection reads it:
| Provider | Where the customer id goes |
|---|---|
| Stripe | cashsdk_user_id in the subscription's metadata |
| Paddle | cashsdk_user_id in the subscription's custom_data |
| Lemon Squeezy | cashsdk_user_id in the checkout's custom data (meta.custom_data) |
| Polar | cashsdk_user_id in the subscription's metadata |
| FastSpring | cashsdk_user_id as a subscription tag |
| Chargebee | cashsdk_user_id in the subscription's meta_data, or a cf_cashsdk_user_id custom field |
| Recurly | Your user id as the account code |
| Gumroad | The buyer's email address, which must then be your user id |
Checkout started by the SDK and the billing portal work with Stripe only, and only Stripe is tested end to end. The other providers grant access from their own checkout.
PayPal records payments and refunds, but does not grant access yet, and is not generally available. See the PayPal page.
Carts, seats, usage billing, plan changes started by the SDK and embedded checkout are not available.