@cashly-billing/sdk
v0.1.0
Published
Cashly SDK — server-side TypeScript/Node client for the Cashly billing API
Maintainers
Readme
@cashly-billing/sdk
Server-side TypeScript/Node SDK for the Cashly billing API.
npm install @cashly-billing/sdk
# or
pnpm add @cashly-billing/sdkRequires Node ≥ 20 (uses globalThis.fetch).
Quickstart
import { Cashly } from '@cashly-billing/sdk';
const cashly = new Cashly({
secretKey: process.env.CASHLY_SECRET_KEY!, // sk_test_... or sk_live_...
});
// When a user signs up in your app
const customer = await cashly.customers.upsertByExternalId({
externalId: user.id,
email: user.email,
name: user.fullName,
});
// Before a gated action
const check = await cashly.entitlements.check({
customerExternalId: user.id,
feature: 'max_contacts',
quantity: 1,
});
if (!check.allowed) {
throw new Error('Upgrade required');
}
// After the action
await cashly.entitlements.track({
customerExternalId: user.id,
feature: 'sms_quota',
quantity: 1,
idempotencyKey: `sms-${message.id}`,
});Configuration
new Cashly({
secretKey: 'sk_live_...', // required
baseUrl: 'https://api.cashlybilling.com', // override for self-hosted / staging
timeout: 30_000, // per-request timeout in ms
maxRetries: 2, // transient failures (network + 5xx + 429)
appName: 'my-saas', // appears in User-Agent for our logs
});The SDK auto-retries network errors, 5xx responses, and 429 rate-limits with exponential backoff (250ms → 500ms → 1s → 2s → 4s). It never retries 4xx client errors.
Resources
cashly.customers
Manage the end-customers of your SaaS.
// Create
await cashly.customers.create({ externalId: 'user_1', email: '[email protected]' });
// Upsert — safe to call on every sign-in
await cashly.customers.upsertByExternalId({
externalId: user.id,
email: user.email,
});
// Look up (returns null when not found)
const existing = await cashly.customers.lookupByExternalId('user_1');
// Read
await cashly.customers.get(id);
await cashly.customers.list({ search: 'acme', pageSize: 50 });
// Update / soft delete
await cashly.customers.update(id, { name: 'Acme Inc' });
await cashly.customers.delete(id);cashly.subscriptions
// Create — uses plan's trialDays unless overridden
await cashly.subscriptions.create({ customerId, planId, trialDays: 14 });
// Cancel (default: end of current period)
await cashly.subscriptions.cancel(id, { when: 'immediate', reason: 'switched_tools' });
// Revert a scheduled cancellation
await cashly.subscriptions.reactivate(id);
// Change plan
await cashly.subscriptions.changePlan(id, { planId: 'plan_pro', effective: 'now' });cashly.entitlements
The hot path. Use check before gated actions, track after.
// BOOLEAN — allowed iff value === 'true'
await cashly.entitlements.check({ customerExternalId, feature: 'custom_domain' });
// LIMIT — allowed iff quantity ≤ limit (stateless)
await cashly.entitlements.check({ customerExternalId, feature: 'max_users', quantity: 12 });
// QUOTA — allowed iff used + quantity ≤ limit (stateful, resets per period)
const r = await cashly.entitlements.check({ customerExternalId, feature: 'sms', quantity: 1 });
// r.used, r.remaining, r.resetAt are populated
// METERED — always allowed; track is what matters
await cashly.entitlements.track({
customerExternalId,
feature: 'api_calls',
quantity: 1,
idempotencyKey: `api-${request.id}`, // dedupe-safe key
});
// Full snapshot — useful for billing dashboards
const snap = await cashly.entitlements.snapshot({ customerExternalId });idempotencyKey is required for track. Send the same key on retries and
usage will never double-count.
cashly.portal
Generate Customer Portal session URLs. Wire to a "Manage subscription" button in your app.
app.post('/billing-portal', requireAuth, async (req, res) => {
const { url } = await cashly.portal.createSession({
customerExternalId: req.user.id,
returnUrl: `${process.env.APP_URL}/dashboard`,
});
res.redirect(url);
});The URL is valid for 5 minutes and consumed on first visit.
cashly.plans / cashly.invoices / cashly.paymentMethods
Read-only. Pricing/catalog mutations happen in the Cashly dashboard.
const plans = await cashly.plans.list({ publicOnly: true });
const invoices = await cashly.invoices.list({ customerId });
const cards = await cashly.paymentMethods.list({ customerId });To download an invoice PDF, use invoice.pdfUrl.
Webhooks
Verify incoming events with HMAC. Use raw body — do NOT JSON.parse first.
Express
import express from 'express';
app.post(
'/cashly-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = cashly.webhooks.verify({
rawBody: req.body, // Buffer
signature: req.headers['cashly-signature'],
secret: process.env.CASHLY_WEBHOOK_SECRET!,
});
switch (event.type) {
case 'subscription.canceled':
// event.data is fully typed as Subscription
break;
case 'charge.failed':
break;
case 'invoice.paid':
break;
}
res.send('ok');
} catch (err) {
res.status(400).send('invalid signature');
}
},
);Next.js App Router
// app/api/cashly-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cashly } from '@/lib/cashly';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
try {
const event = cashly.webhooks.verify({
rawBody,
signature: req.headers.get('cashly-signature') ?? undefined,
secret: process.env.CASHLY_WEBHOOK_SECRET!,
});
// ...handle event...
return NextResponse.json({ ok: true });
} catch {
return new NextResponse('invalid signature', { status: 400 });
}
}Subscribe to events at Dashboard → Developers → Webhooks. The available
event types are:
customer.created/updated/deletedsubscription.created/updated/canceled/trial_ending/trial_ended/past_due/reactivatedcharge.succeeded/failed/refundedinvoice.created/paid/payment_failed
Error handling
Every SDK error extends CashlyError. Check the subclass to react.
import {
CashlyApiError,
CashlyNetworkError,
CashlyValidationError,
CashlyWebhookVerificationError,
} from '@cashly-billing/sdk';
try {
await cashly.entitlements.check({ customerExternalId, feature: 'foo' });
} catch (err) {
if (err instanceof CashlyApiError) {
// err.status, err.code, err.requestId, err.body
if (err.status === 404) { /* feature or customer not found */ }
if (err.status === 429) { /* should never happen — SDK retries 429 */ }
} else if (err instanceof CashlyNetworkError) {
// Connectivity issue, all retries exhausted
} else if (err instanceof CashlyValidationError) {
// Invalid argument before the request left the SDK
}
throw err;
}When opening a support ticket, include err.requestId — it correlates with
our server logs.
Security
- The secret key never leaves your server. Don't ship
@cashly-billing/sdkto a browser bundle — it would expose the key to any client. - Webhook signatures protect against forged events. Always verify them
before trusting
event.data. - For card capture (tokenization), use the Customer Portal — never collect card details on your own forms. This keeps you out of PCI scope.
License
MIT — see LICENSE.
