Server SDK
Issue CashSDK web sessions, end them on sign-out, check access, open Stripe's customer portal and verify CashSDK webhooks with cashsdk-node.
cashsdk-node runs on your backend. It holds your server key, so it must never be bundled into
browser code.
npm install cashsdk-node
# or: pnpm add cashsdk-node
# or: yarn add cashsdk-nodeNode.js 20.19+ or 22.12+. It is an ES module; on those versions a CommonJS app (NestJS, older
Express setups) can require("cashsdk-node") too.
import { CashSDKServer } from "cashsdk-node";
export const cashsdk = new CashSDKServer({
secretKey: process.env.CASHSDK_SECRET_KEY!, // csk_rk_test_… or csk_rk_live_…
environment: "production", // must match the key: test is sandbox, live is production
});
Use a restricted key with Create customer web sessions, plus Customers for access
checks. Create customer web sessions also opens the billing portal. The constructor refuses a key whose mode contradicts environment.
Issue sessions#
const session = await cashsdk.createWebSession({
customerId: user.id, // from your verified authentication
origin: "https://app.example.com", // an origin you allowed on Web and Stripe
// scopes: see below. ttlSeconds: 60 to 600, default 600.
});
// → { token, expiresAt, expiresInSeconds, customerId, environment, scopes }
Return it to your own signed-in page with Cache-Control: no-store. The browser SDK accepts the
whole object from getSessionToken. expiresAt is a time on CashSDK's clock. expiresInSeconds
counts from the moment CashSDK answered, so it stays right on a device whose clock is off.
cashsdk-web and the Swift CashSDKCommerce renew a session from expiresInSeconds when it is
there, and fall back to expiresAt.
Reuse a session until it expires. Minting has its own rate limit, separate from other server
calls: 20,000 sessions a minute per server key and 120 a minute per customer. Past either limit
the call fails with rate_limited and a Retry-After header. A page that asks for a new session
on every request reaches the per-customer limit quickly.
| Scope | Allows |
|---|---|
customer:read | The customer's id and environment (default) |
entitlements:read | Their access (default) |
subscriptions:read | Their subscriptions (default) |
offerings:read | The current offering (default) |
checkout:create, checkout:read | Starting and reading a checkout. Ask for them only on pages that sell. |
billing:manage | Opening the customer's Stripe customer portal. Ask for it only on account pages. |
A session is refused with origin_not_allowed when the origin is not allowed or sessions are
turned off in that environment, and with customer_erased for a customer you erased. A pricing
page that starts a checkout asks for the default scopes plus checkout:create and
checkout:read:
const session = await cashsdk.createWebSession({
customerId: user.id,
origin: "https://app.example.com",
scopes: [
"customer:read", "entitlements:read", "subscriptions:read", "offerings:read",
"checkout:create", "checkout:read",
],
});
Examples#
export async function POST() {
const user = await auth(); // your authentication
if (!user) return new Response(null, { status: 401 });
const session = await cashsdk.createWebSession({ customerId: user.id, origin: process.env.APP_ORIGIN! });
return Response.json(session, { headers: { "Cache-Control": "no-store" } });
}
app.post("/api/cashsdk/session", requireUser, async (req, res) => {
const session = await cashsdk.createWebSession({ customerId: req.user.id, origin: process.env.APP_ORIGIN! });
res.set("Cache-Control", "no-store").json(session);
});
Sign-out#
Each browser tab holds its own session. End all of them when the customer signs out:
await cashsdk.revokeWebSessions(user.id); // → { revoked: 2 }
Check access#
const access = await cashsdk.getEntitlements(user.id);
const isPro = access.entitlements.some((item) => item.identifier === "pro");
It returns the same entitlements the browser sees, in the key's environment, whatever store the purchase came from. Check it before serving anything paid for: the browser can be changed by its user, your server cannot.
Open the billing portal#
Send a signed-in customer to Stripe's customer portal from your backend, for example from an account page that renders on the server:
const portal = await cashsdk.createBillingPortalSession({
customerId: user.id, // from your verified authentication
returnUrl: "https://app.example.com/account", // HTTPS, or loopback HTTP in sandbox
// configuration: "bpc_…", // a Stripe portal configuration; default otherwise
});
// → { customerId, environment, provider: "stripe", url }
return Response.redirect(portal.url, 303);
CashSDK opens only the customer's own Stripe customer, and refuses with no_billing_account when
there is none in the key's environment, or billing_account_conflict when the Stripe customer it
found is linked to another customer too. The link expires within minutes: redirect at once rather
than storing it. Setup in Stripe is in
Getting paid on the web.
Verify CashSDK webhooks#
CashSDK can send your server webhooks when access changes. Verify each one over the exact bytes you received before you trust it:
import express from "express";
import { CashSDKError, verifyWebhook, type WebhookEvent } from "cashsdk-node";
// Add the new secret next to the old one while you rotate.
const secrets = [process.env.CASHSDK_WEBHOOK_SECRET!];
app.post("/webhooks/cashsdk", express.raw({ type: "application/json" }), (req, res) => {
let event: WebhookEvent;
try {
event = verifyWebhook(req.body, req.get("X-CashSDK-Signature") ?? "", { secrets });
} catch (error) {
if (error instanceof CashSDKError) {
res.status(400).send(error.code); // not a webhook you can trust
return;
}
throw error;
}
// Store event.id first: a webhook can arrive more than once.
res.sendStatus(200);
});
verifyWebhook throws a CashSDKError when the request is not a webhook you can trust. Catch it
and answer 400, so a forged or stale request is not logged as a crash on your side:
| Code | Cause |
|---|---|
invalid_signature | The signature does not match any secret, or the timestamp is more than five minutes away |
invalid_webhook | The body is not JSON, or has no event id and type |
invalid_webhook_configuration | The body is over 1 MB, or secrets is empty or holds an empty value |
Check at startup that CASHSDK_WEBHOOK_SECRET is set, so the last code can only mean an oversized
body. CashSDK retries any answer that is not 2xx, so a delivery you refused while you rotate
secrets arrives again. verifyWebhook does not verify Stripe or PayPal webhooks; those go to
CashSDK, not to you.
Errors#
Failures throw CashSDKError with code, status, retryable and requestId. The codes are listed
in the reference. The client has a 15 second timeout by default
(timeoutMs, 100 to 60000) and accepts an AbortSignal on every call.