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.

sh
npm install cashsdk-node
# or: pnpm add cashsdk-node
# or: yarn add cashsdk-node

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

ts
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#

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

ScopeAllows
customer:readThe customer's id and environment (default)
entitlements:readTheir access (default)
subscriptions:readTheir subscriptions (default)
offerings:readThe current offering (default)
checkout:create, checkout:readStarting and reading a checkout. Ask for them only on pages that sell.
billing:manageOpening 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:

ts
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#

Next.js route handler
ts
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" } });
}
Express
ts
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:

ts
await cashsdk.revokeWebSessions(user.id); // → { revoked: 2 }

Check access#

ts
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:

ts
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:

ts
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:

CodeCause
invalid_signatureThe signature does not match any secret, or the timestamp is more than five minutes away
invalid_webhookThe body is not JSON, or has no event id and type
invalid_webhook_configurationThe 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.