Skip to Content
SDK v3newServer-side guide

Headless / server-side

SDK 3.0 works on the server (Node, Bun, Deno, Cloudflare Workers, Edge functions) without any UI. This is the modern equivalent of the legacy “Server-Side SDK” — same endpoints, typed wrappers, and one extra surface: the metered API Gateway.

Backend not on a JS runtime? (Python, Go, PHP, Ruby, .NET, Rust…) — the SDK is JS-only, but the underlying contract is plain HTTPS. See the REST API reference for endpoint specs and curl examples.

One auth path for headless integrations: apiKey + identity. The server-SDK key from the dashboard identifies you (the paywall owner); identity.email (or identity.userId) names the user you’re acting on behalf of. Every server method — createCheckout, getCustomerPortalUrl, listPurchases, cancelSubscription, createSupportTicket, creditTokens / debitTokens — accepts this combination. You don’t need to register users in monetize.software’s auth.

When you need this

  • Custom checkout UX — your own pricing page, you create checkout sessions on the backend and redirect users.
  • Customer portal — your own UI for cancel/renew/payment-history.
  • Metered AI billing — your backend already talks to OpenAI/Anthropic (you own the provider keys); after each successful call, debit credits from the user’s balance via billing.debitTokens() (and grant them with billing.creditTokens()).
  • Webhook fan-out — receive Stripe/Paddle/Freemius webhooks, persist subscription state in your DB, then notify your app.
  • Server-rendered access checks — read has_active_subscription in a Next.js Server Component / Remix loader.

Install

pnpm add @monetize.software/sdk

Import from core to skip the UI bundle:

import { AuthClient, BillingClient, ApiGatewayClient, QuotaExceededError, PaywallError } from '@monetize.software/sdk/core';

core is ≤ 8 KB gz with zero Preact dependencies — safe for Edge functions.

API key — get one

Dashboard → SettingsAPI keysCreate. Copy the sk_… string; it appears once. The key is bound to your account; it can act on any paywall you own.

Never put apiKey into client code. The SDK detects window.document and throws apikey_in_browser from the constructor — a server-SDK key in a bundle exposes your whole account, so a naive client integration fails loudly instead of silently leaking. Read the key from env on a trusted backend. (Deliberate e2e tests that inject the key into a browser can pass allowInsecureBrowserUsage: true to downgrade the throw to a console.error — never use it in production.)

Initialization

For Lambda / Edge functions that live for one request — create a fresh BillingClient per handler:

import { BillingClient } from '@monetize.software/sdk/core'; export async function POST(req: Request) { const billing = new BillingClient({ paywallId: process.env.PAYWALL_ID!, apiOrigin: process.env.PAYWALL_ORIGIN!, apiKey: process.env.MONETIZE_API_KEY! }); const { url } = await billing.createCheckout({ priceId: 'price_123', // ... }); return Response.json({ url }); }

No shared state, nothing to clean up.

Server-side billing methods

Everything in BillingClient works server-side. The methods that traditionally lived in the legacy “Server-Side SDK”:

SDK methodEndpointAuth
billing.createCheckout({...})POST /api/v1/paywall/[id]/start-checkoutapiKey (or Bearer)
billing.getPrices({...})GET /api/v1/paywall/[id]/bootstrappublic
billing.getCustomerPortalUrl()POST /api/v1/paywall/[id]/get-customer-portalapiKey (or Bearer)
billing.createSupportTicket({...})POST /api/v1/paywall/[id]/support/ticketapiKey (or Bearer)
billing.listPurchases()GET /api/v1/paywall/[id]/userapiKey + identity (or Bearer)
billing.cancelSubscription({...})POST /api/paywall/cancel-subscriptionapiKey + identity (or Bearer)
billing.creditTokens({...}) / billing.debitTokens({...})POST /api/v1/paywall/[id]/balancesapiKey + identity (server-only)

Create a checkout from your backend

const checkout = await billing.createCheckout({ priceId: 'price_123', successUrl: 'https://app.example.com/success?session=__SESSION__', errorUrl: 'https://app.example.com/checkout-failed', // Identity (email + your stable userId) is set on BillingClient via // billing.setIdentity(...) before this call — see the Initialization section. idempotencyKey: crypto.randomUUID() // mandatory for production }); return Response.redirect(checkout.url, 303);

With apiKey you can call createCheckout for any user — pass their email via billing.setIdentity({ email }) before the call. Without identity the call throws identity_required.

Get prices

const prices = await billing.getPrices(); return Response.json({ prices });

Locale overrides are applied automatically based on bootstrap.settings.locale_default. For per-user locale, pass Accept-Language via a custom fetch — or skip and resolve locale in your frontend.

Customer portal URL

billing.setIdentity({ email: req.user.email }); const { url } = await billing.getCustomerPortalUrl(); return Response.json({ url });

For Stripe/Paddle/Chargebee the response is a one-time login URL to the acquirer-hosted portal. Freemius doesn’t have a hosted portal — the backend returns forbidden (status: 403). See Customer portal.

Create a support ticket

const { ticket } = await billing.createSupportTicket({ subject: 'Refund request', content: 'I would like to cancel and request a refund...', email: req.user.email, files: [pdfFile] // optional — switches to multipart/form-data }); return Response.json({ ticketId: ticket.id });

Bearer auth, if present, ignores the email field and uses the session email (anti-spoofing). With apiKey only — email (or identity.email) is mandatory.

Token credit and debit

For tokenized paywalls you can adjust a user’s balance straight from your backend — credit tokens (a promo grant, a refund, a manual top-up) or debit them (your backend called OpenAI/Anthropic itself and only needs to deduct credits). Server-SDK only: a browser/Bearer client must never be able to change its own balance, so these throw apikey_required without an apiKey.

billing.setIdentity({ email: 'user@example.com' }); // or { userId: 'your-stable-id' } // Grant 100 tokens of a query type (creates the type if the user has none yet) const { count } = await billing.creditTokens({ type: 'gpt-4', amount: 100 }); // → { type: 'gpt-4', count: 100 } — the new balance for that type // Debit 5 tokens — throws PaywallError('insufficient') if it would go below 0 await billing.debitTokens({ type: 'gpt-4', amount: 5 });
  • type — the token_type from your paywall’s tokenization config.
  • amount — a positive integer. Both methods return { type, count } with the new balance for that type.
  • The change is atomic on the backend: a manual credit/debit won’t clobber, and won’t be clobbered by, concurrent ApiGatewayClient debits.

Daily-trial safe. A credit above the daily-trial limit is not clawed back — the daily top-up only raises balances up to the trial limit, it never reduces a higher balance. So you can safely grant a user more tokens than their trial allowance.

Errors (thrown as PaywallError with these codes):

codeCause
apikey_requiredCalled without apiKey (e.g. from the browser). Token changes are server-only.
identity_requiredNo identity.email / identity.userId set — call setIdentity first.
identity_not_foundNo user with that email/userId has interacted with this paywall.
insufficientA debit would drop the balance below zero. No partial debit — the balance is left unchanged.

Tokenized paywalls only. Works only when the paywall has tokenization enabled and at least one configured token_type. For “regular” subscription paywalls — meaningless.

Legacy raw endpoint. Pre-3.0 integrations used POST /api/v1/withdraw-tokens directly (raw fetch, paywall_id + user_id in the body, debit only). It still works for back-compat, but new integrations should use billing.debitTokens() / billing.creditTokens() — same auth model as the other server methods, with credit support and atomic updates.

Why not ApiGatewayClient server-side?

The gateway exists so browser-only apps (SPA, Chrome extension, Telegram Mini App) can do metered AI without shipping OPENAI_API_KEY to the client. If you already have a backend, your backend has the provider keys — calling our gateway from there would just add a network hop. Talk to OpenAI/Anthropic directly and debit via billing.debitTokens() after success.

Listing purchases and cancelling subscriptions

Both listPurchases() and cancelSubscription() work with apiKey + identity — pass the user’s email (or your stable userId) and operate on them from your backend:

billing.setIdentity({ email: user.email, userId: user.id }); const purchases = await billing.listPurchases(); // purchases: PaywallPurchaseDetailed[] — rich shape with status, intervals, prices await billing.cancelSubscription({ subscriptionId: purchases[0].id, reason: 'too expensive' });

The backend verifies that the identity is linked to your paywall — owner of paywall A can’t query or cancel subscriptions of users who never interacted with paywall A. Across paywalls is impossible by design.

For most integrations you don’t need listPurchases at all — webhooks (subscription.created/updated/cancelled) give you authoritative state in your own DB. Use listPurchases when you want fresh state on demand without waiting for webhook delivery (e.g., right after a checkout-success redirect).

For cancelSubscription, the alternative is getCustomerPortalUrl() — redirect the user to the acquirer’s hosted portal (Stripe/Paddle/Chargebee/Overpay). Use it when you don’t want to build cancel UX yourself. Freemius has no hosted portal — getCustomerPortalUrl() returns 403 there, so for Freemius you call cancelSubscription() directly.

Server-rendered access checks

For Next.js Server Components / Remix loaders, read has_active_subscription directly without instantiating a UI:

// app/dashboard/page.tsx import { BillingClient } from '@monetize.software/sdk/core'; export default async function Dashboard() { const user = await yourAuth.requireUser(); // your own session check if (!user) return <SignInPrompt />; const billing = new BillingClient({ paywallId: process.env.PAYWALL_ID!, apiOrigin: process.env.PAYWALL_ORIGIN!, apiKey: process.env.MONETIZE_API_KEY!, identity: { email: user.email, userId: user.id } }); const paywallUser = await billing.getUser(); if (!paywallUser.has_active_subscription) return <UpgradePrompt />; return <PremiumDashboard />; }

Most integrations have authoritative state in their own DB already (synced via webhooks) — you typically read straight from there, no SDK call needed. Use billing.getUser() when you want a fresh check right after the user came back from checkout, or when your webhook handler hasn’t run yet.

No storage adapter needed in this scenario. apiKey lives in env, identity comes from your auth per request, subscription state lives in your DB (via webhooks). The SDK’s in-memory default is fine for one-shot calls and long-running workers alike. See Storage adapters only if you’re doing something unusual (e.g., your own caching layer).

Webhooks

Server-side billing pairs with webhooks for source-of-truth state. Front-end events update UX, but webhooks update your DB. See:

// Minimal Node webhook handler — verify HMAC-SHA256, persist subscription import crypto from 'node:crypto'; export async function POST(req: Request) { const raw = await req.text(); const sig = req.headers.get('x-signature') ?? ''; const expected = crypto .createHmac('sha256', process.env.MONETIZE_WEBHOOK_SECRET!) .update(raw) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return new Response('bad signature', { status: 401 }); } const event = JSON.parse(raw); switch (event.type) { case 'subscription.created': case 'subscription.updated': await upsertSubscription(event.data.user.email, event.data.subscription); break; case 'subscription.cancelled': await markCancelled(event.data.user.email); break; } return new Response('ok'); }

Custom fetch

BillingClient, AuthClient, ApiGatewayClient all accept fetch?: typeof fetch — pass undici on Node 18 for connection pooling, or a Cloudflare-specific fetch wrapper for KV-bound logging:

import { fetch as undiciFetch } from 'undici'; const billing = new BillingClient({ paywallId, apiOrigin, apiKey, fetch: undiciFetch as typeof fetch });

Custom fetch sees X-Api-Key and Authorization: Bearer ... on every request. Never pass a fetch implementation you don’t fully control — and don’t log init.headers in production.

Error handling

Same PaywallError / QuotaExceededError taxonomy as the browser SDK. Codes most relevant to server flows:

CodeWhat happened
identity_requiredcreateCheckout / getCustomerPortalUrl without identity.email (and no Bearer)
identity_not_on_paywall (status: 404)listPurchases / cancelSubscription for an identity (email/userId) not linked to this paywall — owner can only act on users that interacted with the paywall.
already_purchased (status: 409)createCheckout for a user that already has an active subscription. Retry with ignoreActivePurchase: true for upgrade/renewal.
forbidden (status: 403)getCustomerPortalUrl for an acquirer without a hosted portal (Freemius) or with no active subscription
not_enough_queries (status: 402, QuotaExceededError)gateway.call — quota exhausted

Full catalogue — in Error codes.

Production checklist

  • apiKey is read from a secret manager (env var, KMS, Vault) — never hardcoded.
  • apiOrigin is your custom domain, not appbox.space and not a hardcoded fallback.
  • Every createCheckout call has a fresh idempotencyKey (UUID v4) — protects against double-submits on flaky networks.
  • Storage adapter is per-user when using Bearer auth in a long-running worker.
  • BillingClient.destroy() is called on graceful shutdown (SIGTERM).
  • Webhook handler verifies HMAC-SHA256 and is idempotent — Paddle re-sends subscription.updated.
  • HTTPS everywhere; no plaintext X-Api-Key on the wire.
  • CI grep check: no apiKey substring in the client bundle.
  • Logs scrub Authorization and X-Api-Key headers before shipping to Datadog/Sentry.

See also