@clocknext/sdk
Official ClockNext SDK for Node/TypeScript. Record usage signals and manage
plans, purchases, invoices, customers, catalogs, and billing from your
backend — full coverage of the ClockNext /api/v1 surface.
Server-side only. Your cnk_… API key has org-wide write access — never
ship it to a browser. For embedding the customer portal in a browser, mint a
short-lived token with cnk.portal.createToken().
Install
npm install @clocknext/sdk
Requires Node ≥ 18 (native fetch). Zero runtime dependencies. Uses only
Web-standard fetch and crypto, so it also runs on edge runtimes (Cloudflare
Workers, Vercel Edge, Deno, Bun).
Quick start
import { ClockNext } from "@clocknext/sdk";
const cnk = new ClockNext({ apiKey: process.env.CLOCKNEXT_API_KEY! });
await cnk.signals.credit({
customerId: "cus_123",
model: "llama-3.3-70b-versatile",
agentKey: "api_credit",
member: "alice@acme.com",
tokens: { input: 1200, output: 340, cache: 0 },
custom: { feature: "summarize" },
});
await cnk.flush();
Recording signals
There are three signal types, matching ClockNext's billable primitives. Each has
a typed helper that enforces the right required fields at compile time.
model is always the catalog model id enabled in your org (matched
case-insensitively against OrgModel.modelId). Credits and outcomes are
referenced by their stable agentKey (assigned when you create the credit /
outcome step), not by display name.
await cnk.signals.credit({ customerId, model: "llama-3.3-70b-versatile", agentKey: "api_credit", tokens: { input: 1200, output: 340 } });
await cnk.signals.wallet({ customerId, model: "openai/gpt-oss-120b", tokens: { input: 800, output: 5000 } });
await cnk.signals.outcome({ customerId, model: "llama-3.3-70b-versatile", agentKey: "resolved_ticket.triage" });
Async vs sync
By default the client is in async mode: track() buffers the signal and
returns immediately ({ queued: true }), and a background flusher drains the
buffer on a size/interval trigger. This keeps billing telemetry off your LLM hot
path.
"Batching" here means timing, not a bulk request. Each signal is still its
own POST /api/v1/usage; the flusher just groups when sends happen and caps
how many run at once (batch.maxConcurrency). There is no single bulk-ingest
call, so size your request budget per signal.
To get the computed result back (and any real-time allowance rejection), either:
const res = await cnk.signals.credit({ … }, { wait: true });
console.log(res.usageLog);
const cnk = new ClockNext({ apiKey, mode: "sync" });
In async mode, server-side rejections (e.g. insufficient allowance, 422)
surface via the onError hook or flush(), not from the track() call.
Reliability
- Retries: transient failures (network, timeout,
408/409/429/5xx) are
retried with exponential backoff + equal jitter, honoring Retry-After.
Deterministic 4xx (400/401/404/422) are never retried.
- Replay safety: reads (
GET) and DELETE are retried automatically. Metering
signals (signals.*) opt into retries too — made safe by the idempotency key
below. Other non-idempotent writes — e.g. customers.create, plans.create,
purchases.create — are not auto-retried, so a transient blip can never
create a duplicate.
- Idempotency: every metering signal carries an idempotency key — auto-
generated per signal, or pass your own via
idempotencyKey on the signal —
reused across all retries, so a retried send is deduplicated by the server
instead of double-counted. Pass a stable key (e.g. your own event id) to also
dedup across process restarts or at-least-once redelivery.
- Flushing: call
await cnk.flush() before a serverless return, and
await cnk.close() on graceful shutdown to drain the buffer.
- Durability: the async buffer is in-memory. A signal already accepted
by the server is durable on the server side, but signals still buffered when a
process crashes (before send) are lost. For zero-loss-before-receipt, either
run in
sync mode (or { wait: true }) so the send completes before your
code continues, or call flush()/close() at the right boundaries. A
pluggable before-send persistence store is not yet available.
Customers
const c = await cnk.customers.create({ name: "Acme", email: "ops@acme.com" });
const one = await cnk.customers.get(c.id);
const page = await cnk.customers.list({ limit: 50, q: "ac" });
await cnk.customers.update(c.id, { notes: "VIP" });
await cnk.customers.delete(c.id);
for await (const cust of cnk.customers.iterate()) { }
await cnk.customers.usage(c.id, { limit: 50 });
await cnk.customers.balances(c.id);
await cnk.customers.plan(c.id);
await cnk.customers.revenue(c.id, { range: "30d" });
Members
await cnk.customers.addMember(c.id, { name: "Alice", role: "ADMIN", email: "alice@acme.com" });
const members = await cnk.customers.listMembers(c.id);
await cnk.customers.updateMember(c.id, members[0].id, { name: "Alice R." });
await cnk.customers.removeMember(c.id, members[0].id);
Wallet & billing config
await cnk.customers.wallet(c.id, { limit: 50 });
await cnk.customers.addWalletEntry(c.id, { type: "CREDIT", amount: 25 });
await cnk.customers.update(c.id, { currencyCode: "EUR" });
await cnk.customers.adjustCredit(c.id, { creditId: "cr_1", delta: 100 });
const { remaining } = await cnk.customers.adjustOutcome(c.id, { outcomeId: "out_1", delta: -1 });
await cnk.customers.adjustUnit(c.id, { unitId: "un_1", delta: 5 });
Plans & purchases
const plan = await cnk.plans.create({
name: "Pro",
billingCycle: "MONTHLY",
currencyCode: "USD",
components: [
{ type: "WALLET", billingMode: "ADVANCE", amount: 100 },
{ type: "CREDIT", billingMode: "ADVANCE", creditId: "cr_1", quantity: 10_000 },
],
});
await cnk.plans.list({ active: true });
await cnk.plans.setActive(plan.id, false);
const purchase = await cnk.purchases.create({ customerId: c.id, planId: plan.id });
await cnk.purchases.setAutoPayment(purchase.id, false, { pauseReason: "trial" });
await cnk.purchases.cancel(purchase.id);
Invoices & payments (read-only)
const invoices = await cnk.invoices.list({ customerId: c.id, status: "OPEN" });
const { invoice, plan } = await cnk.invoices.get(invoices[0].id);
const { url } = await cnk.invoices.payLink(invoices[0].id);
const payments = await cnk.payments.list({ customerId: c.id });
await cnk.payments.get(payments[0].id);
Catalogs: credits, outcomes, units
Define the billable types; record usage against them with signals.*.
await cnk.credits.create({ name: "API", agentKey: "api_credit", basePrice: 1, marginPercent: 20, pricePerCredit: 0.01 });
await cnk.credits.list({ active: true });
await cnk.outcomes.create({
name: "Resolved ticket",
basePrice: 1, marginPercent: 20, pricePerOutcome: 2,
steps: [{ name: "Triage", agentKey: "resolved_ticket.triage", basePrice: 0.5 }],
});
await cnk.units.create({ name: "Seats", agentKey: "seats", pricingType: "FLAT", flatPrice: 5 });
Unit metering
Record a unit event (no tokens) and read a customer's unit balances:
await cnk.signals.unit({ customerId: c.id, agentKey: "seats" });
await cnk.signals.unitUsage({ customerId: c.id });
Webhooks
Threshold alerts — at most one per kind (CREDIT / OUTCOME / WALLET):
await cnk.webhooks.upsert({ kind: "WALLET", threshold: 10, url: "https://acme.com/hooks/clocknext" });
await cnk.webhooks.list();
await cnk.webhooks.delete("WALLET");
Customer portal token
const { token, expiresAt } = await cnk.portal.createToken({ customerId: "cus_123", ttlSeconds: 600 });
Errors
All failures throw typed errors you can branch on:
import { AllowanceError, AuthError, RateLimitError } from "@clocknext/sdk";
try {
await cnk.signals.credit({ … }, { wait: true });
} catch (err) {
if (err instanceof AllowanceError) {}
else if (err instanceof AuthError) {}
else if (err instanceof RateLimitError) {}
}
ValidationError (400) · AuthError (401) · NotFoundError (404) ·
PlanError (422) · AllowanceError (422) · ConflictError (409) ·
RateLimitError (429) · ServerError (5xx) · NetworkError — all extend
ClockNextError (.status, .retryable).
Configuration
new ClockNext({
apiKey: "cnk_…",
baseUrl: "https://payments.clocknext.com",
mode: "async",
timeoutMs: 10_000,
batch: { maxSize: 20, maxIntervalMs: 2000, maxConcurrency: 5, maxQueueSize: 10_000 },
retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10_000 },
onError: (err, signal) => {},
onFlush: (count) => {},
onRetry: ({ attempt, delayMs }) => {},
onDrop: (signal, reason) => {},
});
Numeric options are validated: a negative or non-finite value (e.g. a bad
timeoutMs) falls back to its default rather than silently breaking the client.
License
MIT