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 withbilling.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_subscriptionin a Next.js Server Component / Remix loader.
Install
pnpm add @monetize.software/sdkImport 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 → Settings → API keys → Create. 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
Per-request (recommended for serverless)
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 method | Endpoint | Auth |
|---|---|---|
billing.createCheckout({...}) | POST /api/v1/paywall/[id]/start-checkout | apiKey (or Bearer) |
billing.getPrices({...}) | GET /api/v1/paywall/[id]/bootstrap | public |
billing.getCustomerPortalUrl() | POST /api/v1/paywall/[id]/get-customer-portal | apiKey (or Bearer) |
billing.createSupportTicket({...}) | POST /api/v1/paywall/[id]/support/ticket | apiKey (or Bearer) |
billing.listPurchases() | GET /api/v1/paywall/[id]/user | apiKey + identity (or Bearer) |
billing.cancelSubscription({...}) | POST /api/paywall/cancel-subscription | apiKey + identity (or Bearer) |
billing.creditTokens({...}) / billing.debitTokens({...}) | POST /api/v1/paywall/[id]/balances | apiKey + 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— thetoken_typefrom 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
ApiGatewayClientdebits.
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):
code | Cause |
|---|---|
apikey_required | Called without apiKey (e.g. from the browser). Token changes are server-only. |
identity_required | No identity.email / identity.userId set — call setIdentity first. |
identity_not_found | No user with that email/userId has interacted with this paywall. |
insufficient | A 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:
- Webhooks → Create — endpoint setup.
- Webhooks → Events — full payload format and signing.
- Quickstart → Web — Node/Next.js handler example.
// 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:
| Code | What happened |
|---|---|
identity_required | createCheckout / 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
-
apiKeyis read from a secret manager (env var, KMS, Vault) — never hardcoded. -
apiOriginis your custom domain, notappbox.spaceand not a hardcoded fallback. - Every
createCheckoutcall has a freshidempotencyKey(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-Keyon the wire. - CI grep check: no
apiKeysubstring in the client bundle. - Logs scrub
AuthorizationandX-Api-Keyheaders before shipping to Datadog/Sentry.