@clocknext/sdk
v0.21.0
Published
Official ClockNext SDK — record usage signals and manage plans, purchases, invoices, customers, and billing from your backend.
Downloads
1,548
Maintainers
Readme
@clocknext/sdk
Official TypeScript/JavaScript SDK for ClockNext. Use it from a backend to:
- record credit, wallet, outcome, unit and composite usage, and list and retry the signals that could not be counted;
- read customer usage, balances, plans, subscriptions, invoices, and payments;
- manage customers, their members and balances, purchases, subscriptions, invoice payments and portal tokens;
- list the billing plans a customer can be subscribed to.
The SDK targets two ClockNext /api/v1 APIs and requires Node.js 18 or newer:
| API | Host | What the SDK calls there |
| --- | --- | --- |
| Events API | https://events.clocknext.com | every signal, signals.list(), signals.retry(), signals.usageLog(), signals.lookupUsageLogs() |
| Payments API | https://payments.clocknext.com | everything else |
Both take the same API key. It has zero runtime dependencies and works with
native fetch in Node, Bun, Deno, and edge runtimes. The one exception is
webhook signature verification (Webhooks), which uses
node:crypto and so needs Node (or a runtime that provides it); it is not
supported on edge runtimes.
Read this before you write any code
Two facts decide whether your integration works. Neither is obvious from the method signatures, and getting either wrong produces no error at all.
1. A successful signal call does not mean the usage was recorded
Every POST /api/v1/signal/* route answers 202 Accepted with the signal's
id as soon as it has written your request down. It is checked and priced
afterwards. So this resolves successfully even when the signal cannot be
counted:
const result = await cnk.signals.credit(
{ customerId, model, agentKey: credit.agentKey, tokens: { input: 1, output: 1 } },
{ wait: true },
);
// { status: "accepted", queued: true, signalId: "0199e3c2-7a41-7c3e-…" }That is a deliberate trade: metering must never put a billing round-trip on your
request path. But it means a signal that cannot be counted does not come
back through the promise. It is kept as PENDING, with its error, if any of
these is true:
| Cause | How to avoid it |
| --- | --- |
| customerId (or externalId) names no active customer | create the customer first |
| model is not enabled in your workspace | use a model id enabled under Settings → Models in the dashboard, verbatim |
| agentKey names no credit / outcome / outcome step / unit | read it from customers.plan() or customers.balances() — every entitlement on the customer's plan carries its agentKey |
| the customer has no active plan carrying that entitlement | pick a plan with plans.list(), then purchases.create() — and pay its first invoice |
| member is not a valid email | send the member's email, or omit member |
Three things follow.
signals.list() shows every signal that was not counted. List the
PENDING ones, read each row's error, fix the cause, and send them again
with signals.retry():
const { signals } = await cnk.signals.list({ customerId, status: "PENDING" });
for (const s of signals) console.warn(s.id, s.error?.message, s.attempts);
const { queued, failed } = await cnk.signals.retry(signals.map((s) => s.id));A signal is processed 11 times at most (the first send and 10 retries); after
its last attempt it is REJECTED and never counted.
onError does not see these. It fires when the HTTP request itself fails
— network, timeout, auth, a 4xx returned before the 202. A signal that
was accepted and then could not be counted never reaches it, because from the
SDK's point of view that request succeeded. Pass it anyway: it is how you
catch a bad API key, an unreachable host, and a dropped buffer.
signals.usageLog() lists what was counted. It returns one customer's
counted signals (SUCCESS), newest first, from the last 30 days by default.
signals.lookupUsageLogs(ids) finds counted signals by signalId.
const page = await cnk.signals.usageLog({ customerId });
console.log(page.usageLogs.length, "signals counted in the last 30 days");2. Nothing can be metered until five things exist
A signal is not self-contained; it references rows that must already be there. The full chain, in order:
enabled model → entitlement (credit / outcome / unit, with an agentKey)
→ plan containing that entitlement
→ purchase linking the customer to the plan (its first invoice paid)
→ customerThe model, the entitlements and the plan are set up in the ClockNext dashboard. The SDK then links a customer to a plan and meters against it.
customerId, model and agentKey are not strings you can invent. The
quick start below reads each one back instead of hard-coding it.
Security: backend only
cnk_... API keys have organization-wide access. Never expose one in browser
code, mobile apps, frontend bundles, or public Git repositories.
Use cnk.portal.createToken() on your backend when a browser needs to embed the
customer portal. The browser should receive only the short-lived portal token.
Install
npm install @clocknext/sdkQuick start — zero to a recorded signal
Before running this, do the one-time setup in the ClockNext dashboard:
- Enable a model under Settings → Models.
- Create a credit (its
agentKeyis what signals address). - Create a plan that sells that credit.
Everything else is read back from the API rather than guessed.
import { ClockNext } from "@clocknext/sdk";
const apiKey = process.env.CLOCKNEXT_API_KEY;
if (!apiKey) throw new Error("CLOCKNEXT_API_KEY is required");
const cnk = new ClockNext({
apiKey,
// Catches request-level failures (bad key, network, dropped buffer).
// A signal that can't be counted later stays PENDING: see signals.list().
onError: (error, signal) =>
console.error("[clocknext] signal not sent", signal?.type, error),
});
// 1. The model you enabled under Settings → Models. Use its id VERBATIM.
const model = "gpt-4o";
// 2. A plan to subscribe the customer to.
const [plan] = await cnk.plans.list({ active: true });
if (!plan) throw new Error("Create a plan in the dashboard first.");
// 3. A customer, and a purchase linking them to the plan.
// Without an active purchase every signal below stays PENDING.
const customer = await cnk.customers.create({
name: "Acme",
email: "[email protected]",
});
const { invoice } = await cnk.purchases.create({ customerId: customer.id, planId: plan.id });
// The plan starts once its first invoice is paid. Send the customer
// `invoice.payUrl`, or record a payment you received another way:
if (invoice) await cnk.invoices.pay(invoice.id, { mode: "BANK_TRANSFER", reference: "test" });
// 4. The credit's agentKey, read off the customer's balances.
const { credits } = await cnk.customers.balances(customer.id);
const credit = credits[0];
if (!credit) throw new Error("The plan must sell at least one credit.");
// 5. Now a signal can land.
await cnk.signals.credit({
customerId: customer.id,
model,
agentKey: credit.agentKey,
tokens: { input: 1200, output: 340 },
custom: { feature: "summarize" },
});
// 6. Required in async mode before the process can exit.
await cnk.flush();
// 7. Prove it. Signals are counted within seconds, so allow a moment.
const page = await cnk.signals.usageLog({ customerId: customer.id });
console.log(page.usageLogs.length, "signals counted");
// Anything not counted is PENDING, with the reason:
const pending = await cnk.signals.list({ customerId: customer.id, status: "PENDING" });
for (const s of pending.signals) console.warn(s.id, s.error?.message);A runnable version is in examples/node-basic.ts,
shipped with the package.
Signals
Signals are billable usage events. Each kind posts to its own route on the events API.
| SDK method | Meter | Required fields |
| --- | --- | --- |
| signals.credit() | Credit allowance | customerId, model, agentKey, tokens |
| signals.wallet() | Customer wallet | customerId, model, tokens |
| signals.outcome() | Outcome workflow step | customerId, model, agentKey, runId, tokens |
| signals.completeOutcome() | Outcome run (one completed outcome) | customerId, runId, agentKey (the outcome's own) |
| signals.unit() | Quantity-based unit | customerId, agentKey |
| signals.completeComposite() | Composite occurrence | customerId, composite |
signals.track(signal) is the underlying call the four convenience methods wrap;
use it when the kind is dynamic.
tokens takes input and output (required), plus two optional prompt-cache
counts that each default to 0:
cache— tokens read from the prompt cache, priced at the model's cache-read rate.cacheWrite— tokens written into the prompt cache, priced at the model's separate cache-write rate.
await cnk.signals.credit({
customerId,
model: "claude-sonnet-4-5",
agentKey: "api_credit",
tokens: { input: 1200, output: 340, cache: 800, cacheWrite: 2000 },
});Every signal method can take externalId (your own id for the customer — see
Your own customer ids) instead of customerId.
Where each value comes from
| Field | Source | Notes |
| --- | --- | --- |
| model | Settings → Models in the dashboard | Use it verbatim. Matching is case-insensitive but the id must exist; "openai/gpt-4o" is not the same as "gpt-4o". |
| agentKey (credit) | customers.plan(id) → plan.entitlements.credit[].agentKey | Case-insensitive. |
| agentKey (outcome step) | customers.balances(id) → outcomes[].steps[].agentKey | signals.outcome() takes the step's key. |
| agentKey (outcome completion) | customers.plan(id) → plan.entitlements.outcome[].agentKey | signals.completeOutcome() takes the outcome's own key. |
| agentKey (unit) | customers.plan(id) → plan.entitlements.unit[].agentKey | Matched exactly — keys are stored lowercased. |
| member | the member's email | Optional. A member the customer does not have yet is added, as the dashboard does. A value that is not an email leaves the signal PENDING. |
| composite.ref | customers.plan(id) → plan.entitlements.composite[].refId | An unknown ref is ignored, not rejected — the signal still counts. |
Credit and wallet
await cnk.signals.credit({
customerId,
model,
agentKey: credit.agentKey,
tokens: { input: 500, output: 120 },
});
await cnk.signals.wallet({
customerId,
model,
tokens: { input: 500, output: 120 },
});Wallet signals take no agentKey — they debit money at the model's cost rather
than drawing on a named entitlement — and cannot be grouped into a composite.
Outcome runs
An outcome is billed once per run, not once per step. Reuse the same runId for
every step, then close the run with a separate, dedicated call —
signals.completeOutcome(). signals.outcome() accepts ONLY the step shape;
there is no complete flag to set on a step.
const runId = "ticket_9021";
await cnk.signals.outcome({
customerId,
model,
agentKey: "resolved_ticket.triage", // a STEP key
runId,
tokens: { input: 600, output: 150 },
});
await cnk.signals.outcome({
customerId,
model,
agentKey: "resolved_ticket.resolve",
runId,
tokens: { input: 500, output: 200 },
});
// Closing is ALWAYS its own explicit call — never a flag riding along on a
// step's real usage. It names the OUTCOME's own agentKey, not a step's.
await cnk.signals.completeOutcome({ customerId, runId, agentKey: "resolved_ticket" });A step may repeat within a run — a verify loop firing three times is three
signals and one outcome. Completing the same run twice counts twice. The
SDK keys every call (see Idempotency), so its own
retries are counted once; send your own stable idempotencyKey to dedupe
your retries too. When a composite covers the outcome, pass composite to
completeOutcome(): the composite then bills the completion instead.
Unit usage
Units carry no model and no tokens. quantity defaults to 1.
await cnk.signals.unit({ customerId, agentKey: "seats", quantity: 3 });signals.unit() takes no { wait: true } option — unit reports are never
buffered and are always sent immediately.
Composites
A composite groups credit, outcome, and unit signals into one billable occurrence. Tag each signal, then close the occurrence explicitly.
const composite = { ref: "voice_ai", value: "call_5f3a" };
await cnk.signals.credit({
customerId,
model,
agentKey: credit.agentKey,
tokens: { input: 100, output: 50 },
composite,
});
// Closing is ALWAYS explicit. Put it on the line before your own return.
await cnk.signals.completeComposite({ customerId, composite });signals.completeOutcome() does not close a composite occurrence. It
closes the run, and nothing else — whatever mix of credits, outcomes and units
the composite wraps. If you tag outcome signals with a composite, you must
still call completeComposite(), or that occurrence stays open and is never
billed.
Completing the same value twice counts twice, so keep a stable
idempotencyKey when you retry it yourself. Wallet signals do not
participate in composites.
Delivery status: buffered, accepted, dropped
Every metering call resolves with a status saying how far the signal actually
got. Branch on it rather than on the legacy queued boolean, which collapses
the first two states into one.
| status | Meaning | Can it still be lost? |
| --- | --- | --- |
| "buffered" | In this process's memory, not yet sent (async mode) | Yes — flush before the process exits |
| "accepted" | The events API took it (202) and gave it signalId; counted within seconds, or kept PENDING | No, but not billed yet either |
| "dropped" | The SDK discarded it — client closed, or local queue full | Already lost; onDrop fired |
The default mode is async: the signal is buffered locally and the call returns
immediately.
const signal = {
customerId,
model,
agentKey: credit.agentKey,
tokens: { input: 1200, output: 340 },
};
const cnk = new ClockNext({ apiKey }); // async (default)
await cnk.signals.credit(signal); // -> { status: "buffered" }
await cnk.signals.credit(signal, { wait: true }); // -> { status: "accepted", signalId }
const sync = new ClockNext({ apiKey, mode: "sync" }); // every signal awaits{ wait: true } and mode: "sync" wait for the server to accept the
signal, never for it to finish pricing. No mode of this SDK blocks a request
thread on a billing result.
cnk.pending reports how many signals are currently buffered.
Serverless and shutdown
The local queue is in memory, so it must be drained before the process can freeze or exit.
// Serverless handler — flush, but do NOT close: the container is reused.
try {
// application work and signal tracking
} finally {
await cnk.flush();
}
// Long-running process, on shutdown only:
await cnk.close();Construct the client once at module scope, not per request. After close()
the client is permanently shut: further signals resolve { status: "dropped" }
and fire onDrop with reason "closed". Never call close() in a serverless
handler.
If losing a buffered signal is unacceptable, use mode: "sync" or
{ wait: true }, and supply your own stable idempotencyKey.
Idempotency and retries
Metering calls get an idempotency key automatically and reuse it across every
retry, so a retried send is deduplicated server-side instead of double-billing:
the events API keeps a key for 3 days, and a repeat gets the original
signalId. A repeat with a different body is a ValidationError.
Supply your own stable key to also deduplicate across process restarts and
at-least-once redelivery:
await cnk.signals.credit({
customerId,
model,
agentKey: credit.agentKey,
tokens: { input: 10, output: 5 },
idempotencyKey: `llm-request:${requestId}`,
});Every signal accepts idempotencyKey — credit, wallet, outcome,
completeOutcome, unit and completeComposite:
await cnk.signals.unit({ customerId, agentKey: "seats", quantity: 3, idempotencyKey: `seat-sync:${syncId}` });
await cnk.signals.completeComposite({ customerId, composite, idempotencyKey: `call-done:${callId}` });When a signal fails permanently, the key it was sent with is available on both
the error (error.idempotencyKey) and the signal handed to onError/onDrop.
Replay with that key — generating a new one bills the event twice.
Balance adjustments
Adjustments (customers.adjustCredit() and friends) work like signals: every
call gets an idempotency key automatically (a UUID, generated once per call and
reused across its retries), so a network blip never adjusts twice. Pass your own
idempotencyKey — your order / top-up id — to also dedupe your own retries
across restarts or redelivery, which an auto key can't survive.
const res = await cnk.customers.adjustCredit(customerId, {
agentKey: "api_credit",
delta: 500,
idempotencyKey: `topup:${orderId}`,
});
if (res.replayed) {
// An earlier call with this key already added the 500 (at res.adjustedAt).
// Nothing was added this time; res.remaining is the balance now.
}- Same key, same request → nothing is adjusted again;
replayed: true,adjustedAtis when the original happened,remainingis the current balance. - Same key, different request (another customer, entitlement or amount) →
ConflictError, nothing adjusted. Use a new key for a new adjustment. - A failed adjustment (validation, not on the plan, …) does not use up its key — fix the request and send it again with the same key.
- Keys are unique per workspace and per adjust endpoint (credits, outcomes, units, composites, wallet). 1–255 characters.
Retries apply to network failures, timeouts, 408, 429, 5xx, and the one
transient 409. Validation, authentication, not-found, plan, allowance and
permanent-conflict errors are never retried. Reads and DELETEs are retried;
POST/PATCH are not, except metering calls and balance adjustments, which
opt in because they are always idempotency-keyed.
Client-side batching is timing and concurrency management, not a bulk API — each signal is still an individual HTTP request.
Handling errors
Branch on error classes, never on message text.
import {
ClockNextError,
AllowanceError,
AuthError,
ConflictError,
ForbiddenError,
NetworkError,
NotFoundError,
PlanError,
RateLimitError,
ServerError,
ValidationError,
} from "@clocknext/sdk";
try {
await cnk.customers.get(customerId);
} catch (error) {
if (error instanceof NotFoundError) {
// No such customer in this workspace.
} else if (error instanceof AuthError) {
// Rotate or correct the API key.
} else if (error instanceof ValidationError) {
// Fix the request shape or required fields.
} else if (error instanceof RateLimitError || error instanceof ServerError) {
// Already retried by the SDK; add an application-level fallback.
} else if (error instanceof ClockNextError) {
console.error(error.status, error.code, error.message);
}
}| Class | Status | Meaning |
| --- | --- | --- |
| ValidationError | 400 | Malformed request or failed schema validation |
| AuthError | 401 | Missing, invalid or expired API key |
| ForbiddenError | 403 | Key is valid but not permitted to do this (a feature your plan does not include, or a workspace locked because its ClockNext subscription was cancelled) |
| NotFoundError | 404 | Unknown resource (also returned for cross-org access) |
| ConflictError | 409 | Conflict — an archived customer, a customer with an active plan you try to archive, an invoice that is not open. Permanent, and not retried |
| PlanError | 422 | An adjustment of an entitlement the plan does not sell |
| AllowanceError | 422 | An adjustment that would take the Reserved Wallet below 0 |
| RateLimitError | 429 | Rate limited; carries retryAfterMs |
| ServerError | 5xx | Server-side failure |
| NetworkError | — | fetch threw or the request timed out |
All extend ClockNextError, which exposes .status (number | undefined),
.retryable, .code (the server's machine-readable code, when it sends one),
and .idempotencyKey on metering calls. Constructor failures — a missing
apiKey, no available fetch — throw ValidationError, so a single
instanceof ClockNextError catches setup problems too.
Signal failures do not arrive here
Because the events API answers 202 before it checks a signal, a try/catch
around signals.credit() will not catch a signal that cannot be counted,
with or without { wait: true } — and neither will onError. Such a signal
stays PENDING with its error: signals.list({ status: "PENDING" }) finds
it, and signals.retry() sends it again (see
the first section).
What the hooks below DO cover is everything that fails before the 202: an
invalid API key, an unreachable host, a timeout, and signals the SDK dropped
without sending.
import { ClockNext, ClockNextError } from "@clocknext/sdk";
const cnk = new ClockNext({
apiKey,
onError: (error, signal) => {
// `error` is `unknown`; narrow it.
if (error instanceof ClockNextError) {
logger.error({ status: error.status, key: error.idempotencyKey }, error.message);
}
deadLetter.push(signal); // replay later with signal.idempotencyKey
},
onDrop: (signal, reason) => logger.error({ signal, reason }, "signal dropped"),
onRetry: ({ attempt, delayMs, error }) => logger.warn({ attempt, delayMs, error }),
onFlush: (sentCount) => logger.info({ sentCount }, "signals flushed"),
});onFlush counts signals the server accepted, not signals it billed.
So the division of labour is: these hooks tell you your integration cannot
reach ClockNext; signals.usageLog() tells you what was counted;
signals.list() tells you what was not, and why.
Balances and usage
const balances = await cnk.customers.balances(customerId);
balances.wallet.balance; // USD; always present
balances.wallet.reservedBalance; // USD; undefined unless Reserved Wallet is on
balances.credits; // granted, used, remaining
balances.outcomes; // granted, remaining, steps
balances.units; // granted, remaining
balances.composites; // prepaid composite pools: refId, granted, used, remaining
// Convenience lookups — these fetch the full snapshot, so prefer `balances()`
// when you need more than one row from the same customer.
await cnk.customers.creditBalance(customerId, { creditName: "API credit" });
await cnk.customers.outcomeBalance(customerId, { outcomeName: "Resolved ticket" });
await cnk.customers.unitBalance(customerId, { unitName: "Seats" });
// Counted usage across every meter, newest first, cursor-paginated. Without
// `from` it reads the last 30 days; name the customer by `customerId` or
// `externalId`.
let cursor: string | undefined;
do {
const page = await cnk.signals.usageLog({ customerId, from: "2026-09-01", limit: 50, cursor });
// page.usageLogs — each row has a `type`: credit | outcome | outcome_completion
// | unit | wallet | composite, and `custom`: the `custom` object sent (or null)
cursor = page.nextCursor ?? undefined;
} while (cursor);
// Every signal, counted or not, 50 to a page — filter by customer, status,
// meter and days:
const { signals, nextCursor } = await cnk.signals.list({ status: "REJECTED", type: "credit" });Adjustments use stable agent keys, not display names. A credit, outcome, unit
or composite can only be adjusted when it is a prepaid (ADVANCE) part of the
customer's current plan — the same set customers.balances() shows.
Otherwise the call fails with a PlanError ("… is not part of this customer's
plan") and nothing is written. That includes an entitlement that is only
billed in arrears, one that is only covered through a composite, and a
customer with no plan (or a cancelled one). The wallet can always be
adjusted, whatever the plan.
Every adjust call returns { remaining, replayed, customerArchived } (plus
adjustedAt on a replay — see Balance adjustments). A
reduce is never rejected or floored for going below 0 — the resulting balance
may read negative, same as a real-usage overdraft already can.
customerArchived: true means the customer is archived; the same rules apply
(an archived customer has no plan, so usually only the wallet can still move).
await cnk.customers.adjustCredit(customerId, { agentKey: "api_credit", delta: 100, idempotencyKey: `grant:${grantId}` });
await cnk.customers.adjustOutcome(customerId, { agentKey: "resolved_ticket.triage", delta: 1 });
await cnk.customers.adjustUnit(customerId, { agentKey: "seats", delta: 5 });
// Composite: `agentKey` here is actually the composite's `refId` — the same
// value signals tag it with — named `agentKey` on the wire so all four share
// one request shape. Adjusts the CURRENT billing cycle's ADVANCE slot pool.
await cnk.customers.adjustComposite(customerId, { agentKey: "voice_ai", delta: 2 });
await cnk.customers.adjustWallet(customerId, { delta: 25 }); // USD, no agentKeyReserved Wallet
Some workspaces turn on a Reserved Wallet: a second balance that sits behind
the customer's ordinary wallet (Settings → Organisation → "Enable Reserved
balance in wallet"). When a wallet-funded Arrear charge comes in, it draws from
the Wallet first, then from the Reserved Wallet down to 0, and anything still
left pushes the Wallet negative. The Reserved Wallet itself never goes
negative, and a normal (main) wallet adjustment never touches it.
// Top up (or, with a negative delta, reduce) the Reserved Wallet.
const { remaining } = await cnk.customers.adjustWallet(customerId, { delta: 500, pool: "reserved" });
// `remaining` is the Reserved balance. Reducing below 0 is refused with an
// AllowanceError (down to exactly 0 is fine); any "reserved" call when the
// workspace hasn't enabled the feature is a PlanError.
const { wallet } = await cnk.customers.balances(customerId);
wallet.reservedBalance; // number | undefinedWhen the feature is off, wallet.reservedBalance is left out of balances().
Wallet ledger
customers.wallet() lists every movement of wallet money, newest first, with
both balances. Each row's source says what caused it:
| source | Meaning |
|---|---|
| SUBSCRIPTION | Wallet money from a new plan purchase |
| RENEWAL | Wallet money from a plan renewal, or unused money removed at renewal |
| MANUAL | Changed in the ClockNext dashboard |
| API | Changed with adjustWallet() |
| CUSTOMER_PORTAL | Top-up paid by the customer in the customer portal |
| USAGE_LOG | Charged by a counted signal; usageLogId is its signal id |
| INVOICE_SETTLEMENT | Usage cleared by a paid pay-as-you-go invoice |
| INVOICE_VOID | Settlement undone by a voided invoice |
On a USAGE_LOG row, usageLogId is the signal id, so each charge links to
the exact signal that caused it (signals.lookupUsageLogs()). A charge split
across both wallets is two rows; the Reserved Wallet one has :reserved added
to its id.
notes is only text a person wrote — your own note on an API row, the
admin's note on a MANUAL row — and null on every row the system writes.
const page = await cnk.customers.wallet(customerId, { from: "2026-10-01", to: "2026-10-07" });
page.balanceUsd; // ordinary wallet, USD
page.reservedBalanceUsd; // Reserved Wallet, or null when the feature is off
page.lowBalanceThresholdUsd; // the customer's own low-balance alert level, or null
for (const t of page.transactions) {
// t.type: "CREDIT" | "DEBIT"; t.amount is always positive
// t.entitlementType: "CREDIT" | "OUTCOME" | "UNIT" | "COMPOSITE" | null
}
// Every row, across all pages — pick out the signal charges:
for await (const t of cnk.customers.iterateWallet(customerId)) {
if (t.source === "USAGE_LOG") console.log(t.usageLogId, t.amountUsd);
}Low-balance alert
Set a customer's own low-balance level in USD. When their wallet falls to it
or below, ClockNext sends wallet.balance.drop to every webhook endpoint
subscribed to it. null clears it.
await cnk.customers.setWalletAlert(customerId, 50); // { lowBalanceThresholdUsd: 50 }
await cnk.customers.setWalletAlertByExternalId("acme-42", null); // clearedLook up usage rows by id
signals.lookupUsageLogs(ids) returns up to 10 counted signals in one call,
by signalId — for example the signal behind each wallet debit's
usageLogId. It is scoped to your org, not one customer: every row has its
own customerId. Rows come back in the order of ids; an id with no counted
signal in your org (a PENDING or REJECTED one included) is listed in
notFound.
Each row has entitlement: { type, id, name } — type is credit, outcome,
outcome_completion, unit, composite_completion or wallet (a wallet row
is named "Wallet") — and only the matching group (credit, outcome,
outcomeCompletion, unit, compositeCompletion, wallet) is set.
composite: { id, refId, runId } names the composite that pays for a row.
cost.customerCost is what the row charged, so it matches the wallet debit.
const ledger = await cnk.customers.wallet(customerId);
const ids = ledger.transactions
.filter((t) => t.source === "USAGE_LOG" && t.usageLogId)
.map((t) => t.usageLogId!);
const { usageLogs, notFound } = await cnk.signals.lookupUsageLogs(ids);
for (const row of usageLogs) {
row.entitlement.type; // what was charged
row.cost.customerCost; // what it charged
row.composite; // { id, refId, runId } | null
}To change how much a customer is granted every billing cycle (not just
their balance right now), use updateRecurringGrant. Any increase or decrease
applies to their balance immediately. quantity is USD for the wallet and a
whole number for everything else; the wallet needs no agentKey.
await cnk.customers.updateRecurringGrant(customerId, { kind: "wallet", quantity: 10000 });
await cnk.customers.updateRecurringGrant(customerId, { kind: "credit", agentKey: "api_credit", quantity: 5000 });Customers, plans, and billing
const customer = await cnk.customers.create({ name: "Acme", email: "[email protected]" });
await cnk.customers.update(customer.id, { currencyCode: "USD" });
await cnk.customers.list({ limit: 50, q: "acme" });
for await (const c of cnk.customers.iterate()) {
// follows the cursor for you
}
// `{ active: true }` returns only active plans, `{ active: false }` only
// archived ones, and omitting it returns both.
const [plan] = await cnk.plans.list({ active: true });
// A purchase starts PENDING with its first invoice. It starts once that
// invoice is paid and `subscriptionDate` (default: today) has come.
const { purchase, invoice } = await cnk.purchases.create({
customerId: customer.id,
planId: plan.id,
subscriptionDate: "2026-11-01",
});
invoice?.payUrl; // a hosted page where the customer pays it
// …or record a payment you received outside ClockNext:
if (invoice) await cnk.invoices.pay(invoice.id, { mode: "BANK_TRANSFER", reference: "UTR 4471" });
await cnk.purchases.setAutoPayment(purchase.id, false, {
pauseReason: "Customer requested manual payment",
});
// The plan the customer is on now, with everything it sells, by kind:
const { plan: current } = await cnk.customers.plan(customer.id);
current?.entitlements.credit; // [{ id, agentKey, billingMode, recurringQuantity, … }]
current?.cycle; // { index, start, end }A purchase's status is by payment: COMPLETED (first invoice paid),
PENDING or CANCELLED. Whether the plan has started is lifecycleStatus
on purchases.get() (SCHEDULED, ACTIVE or CANCELLED).
Subscriptions are the running terms of a purchase, addressed by the purchase id: one row per period, with its renewal and cancellation dates.
const rows = await cnk.subscriptions.list({ customerId: customer.id, status: "ACTIVE" });
const sub = await cnk.subscriptions.get(purchase.id); // with its past `periods`
// Cancel at the end of the current period (a reason is required)…
await cnk.subscriptions.cancel(purchase.id, { reason: "Moving to annual", atPeriodEnd: true });
// …or now, which voids any open invoice and bills the usage not billed yet:
await cnk.purchases.cancel(purchase.id, { reason: "Moving to annual" });To change plan, create a purchase of the new plan: the current plan stops
renewing, and keeps its entitlements until the new plan starts or its current
period ends. customers.delete() archives a customer, and refuses one with a
scheduled or active plan (ConflictError): cancel the subscription first.
Additional resource methods:
cnk.customers.get / update / delete / iterate
cnk.customers.getByExternalId / updateByExternalId / updateRecurringGrantByExternalId
cnk.customers.adjustCreditByExternalId / adjustOutcomeByExternalId / adjustUnitByExternalId
cnk.customers.adjustCompositeByExternalId / adjustWalletByExternalId
cnk.customers.deleteByExternalId / balancesByExternalId / planByExternalId / walletByExternalId
cnk.customers.listMembers / addMember / updateMember / removeMember
cnk.customers.balances / creditBalance / outcomeBalance / unitBalance / plan
cnk.customers.wallet / iterateWallet / setWalletAlert / setWalletAlertByExternalId
cnk.customers.adjustCredit / adjustOutcome / adjustUnit / adjustComposite / adjustWallet
cnk.customers.updateRecurringGrant
cnk.signals.credit / wallet / outcome / unit / track
cnk.signals.completeOutcome / completeComposite
cnk.signals.list / retry / usageLog / lookupUsageLogs
cnk.plans.list
cnk.purchases.list / get / create / cancel / setAutoPayment
cnk.subscriptions.list / get / cancel
cnk.invoices.list / get / payLink / pay
cnk.payments.list / get
cnk.portal.createTokenYour own customer ids
Give a customer an externalId — the id your product already uses for
them — and you never have to store ClockNext's customer id. It is 1–32
letters, numbers or -, case-sensitive, and unique among your active
customers (a clash is a ConflictError).
await cnk.customers.create({ name: "Acme", email: "[email protected]", externalId: "acme-42" });
// Signals and purchases.create() take `externalId` in place of `customerId`:
await cnk.signals.credit({ externalId: "acme-42", agentKey: "api_credit", model: "gpt-4o", tokens: { input: 1200, output: 300 } });
await cnk.purchases.create({ externalId: "acme-42", planId: plan.id });
// Read, update, or resize the recurring grant by your id:
const acme = await cnk.customers.getByExternalId("acme-42");
await cnk.customers.updateByExternalId("acme-42", { currencyCode: "USD" });
await cnk.customers.updateRecurringGrantByExternalId("acme-42", { kind: "wallet", quantity: 10000 });
// Manage balances by your id too:
await cnk.customers.adjustCreditByExternalId("acme-42", { agentKey: "api_credit", delta: 500 });
await cnk.customers.adjustWalletByExternalId("acme-42", { delta: 250 });
await cnk.customers.update(acme.id, { externalId: null }); // clear itIf you send both, customerId wins and externalId is ignored. An unknown
externalId is a NotFoundError; on a signal (checked after the 202) it
leaves the signal PENDING with that reason — create the customer, then
signals.retry() it. Customer objects carry externalId, and
signals.usageLog() and signals.list() take it too. Everything else — the
invoice, payment, purchase and subscription lists, portal.createToken() and
members — still takes ClockNext's id.
Catalogue: credits, outcomes, units, composites and plans
Each of cnk.credits, cnk.outcomes, cnk.units, cnk.composites and
cnk.plans has the same six calls: list(), get(id), create(input),
update(id, input), archive(id) and unarchive(id).
- Nothing is deleted. There is no
delete(): retire an item witharchive(), bring it back withunarchive(). Usage history never loses what it points at. update()is partial — only the fields you send change. A list you do send (modelBundle,steps,tiers,entitlements) is the complete new list.- Prices are never typed in. A credit or outcome step sends a
modelBundle— one line per model — and ClockNext works the price out from the models' live prices. - A plan's currency is your workspace's primary currency; it can't be set.
const credit = await cnk.credits.create({
name: "Deep Research",
agentKey: "deep_research",
marginPercent: 20,
modelBundle: [
{ modelId: "claude-sonnet-4-5", averageTokensPerLLMCall: 100000, input: 60, output: 30, cache: 5, cacheWrite: 5 },
],
});
const outcome = await cnk.outcomes.create({
name: "Contract review",
agentKey: "contract_review",
marginPercent: 100,
steps: [
{ name: "Extract clauses", agentKey: "contract_review.extract",
modelBundle: [{ modelId: "claude-sonnet-4-5", averageTokensPerLLMCall: 50000, input: 70, output: 30, cache: 0 }] },
],
});
const unit = await cnk.units.create({
name: "API calls",
agentKey: "api_calls",
pricingType: "SLAB",
tiers: [{ upTo: 1000, price: 0.01 }, { upTo: null, price: 0.005 }], // last tier: "and above"
});
const composite = await cnk.composites.create({
name: "Voice AI call",
refId: "voice_ai",
price: 2.5,
entitlements: { creditIds: [credit.id] },
});
const plan = await cnk.plans.create({
name: "Pro",
billingCycle: "MONTHLY",
entitlements: [
{ type: "CREDIT", billingMode: "ADVANCE", creditId: credit.id, quantity: 1000, rollover: true },
{ type: "OUTCOME", billingMode: "ARREAR", outcomeId: outcome.id },
{ type: "PRICING_METRIC", billingMode: "ADVANCE", compositeId: composite.id, quantity: 50 },
{ type: "WALLET", billingMode: "ADVANCE", amount: 25 },
],
});
await cnk.credits.update(credit.id, { marginPercent: 50 }); // only the margin changes
await cnk.units.archive(unit.id); // retire it
await cnk.units.unarchive(unit.id); // and bring it backModels are enabled in the ClockNext dashboard. A bundle line names its model by
modelId — the same id you send as usage.model on a signal.
Taxes
cnk.taxes manages the tax rates charged to your customers by billing country
— the same rates as Settings → Taxes in the dashboard. Every active tax for
a customer's country is applied to their invoices and portal purchases
automatically; a customer with no country is charged no tax.
It has four calls: list(), get(id), create(input) and update(id, input).
behavioris lower case:"inclusive"(already inside the price) or"exclusive"(added on top).countryCodeis a 2-letter ISO code ("IN","GB"); lower case is accepted, a country name is refused.- No duplicates: two active taxes can't share a name (ignoring case) in the
same country — that's a
ConflictError. update()is partial, and only affects invoices issued afterwards — issued invoices keep the tax they were issued with.- Archiving is dashboard-only. There is no
archive()/unarchive()/delete(); an archived tax reads back withisActive: falseand can still be updated.
const gst = await cnk.taxes.create({
name: "GST",
percentage: 18,
countryCode: "IN",
behavior: "exclusive",
description: "India GST",
});
const active = await cnk.taxes.list({ active: true }); // omit for archived ones too
const one = await cnk.taxes.get(gst.id);
await cnk.taxes.update(gst.id, { percentage: 12 }); // only the percentage changesWebhooks
ClockNext can POST a signed event to your server when a customer's wallet changes. Create the endpoint in the dashboard (Settings → Webhooks), choose its events, and copy the signing secret — it is shown once. (The SDK verifies and types these events; it does not create or list endpoints.)
| Event | Fires when |
| --- | --- |
| wallet.balance.update | A customer's main wallet balance moves, credit or debit — a top-up, an adjustment, a dashboard change. Not for changes caused by signals (usage debits and outcome, unit and composite charges: they can arrive thousands of times a minute), not for invoice settlements, and not for the Reserved Wallet. To read the current balance use customers.balances(). |
| wallet.balance.drop | A debit takes the balance from above a level to at or below it. Fires once per drop through the level, not on every debit while the balance stays under it. A signal that crosses the level does fire it. |
The level for wallet.balance.drop is the customer's own alert level if they
have one, otherwise the endpoint's threshold. Set or clear a customer's own
level with the SDK:
await cnk.customers.setWalletAlert(customerId, 50); // alert at or below $50
await cnk.customers.setWalletAlert(customerId, null); // clear it: fall back to each endpoint's threshold
await cnk.customers.setWalletAlertByExternalId("acme-42", 50);
(await cnk.customers.wallet(customerId)).lowBalanceThresholdUsd; // 50, or nullVerify, then handle
import { constructWebhookEvent, WebhookSignatureError } from "@clocknext/sdk";
// `rawBody` is the UNPARSED request body (string or Buffer).
const event = constructWebhookEvent(
rawBody,
req.headers["x-clocknext-signature"],
process.env.CLOCKNEXT_WEBHOOK_SECRET!,
);
// the same check is available as cnk.webhooks.constructEvent(…)
if (event.event_type === "wallet.balance.drop") {
event.data.customer.id;
event.data.threshold; // the level that was crossed, USD
event.data.previous_balance; // USD
event.data.current_balance; // USD
}constructWebhookEvent throws WebhookSignatureError (a 400, never retryable)
when the header is missing or malformed, the signature does not match, the
timestamp is more than 5 minutes from now ({ toleranceSeconds } changes
that), or the body is not a ClockNext event. Answer 400 to those.
- Use the raw body. The signature is an HMAC-SHA256 of
${t}.${rawBody}; parsing the JSON and re-serialising it changes the bytes and every signature fails. In Express mountexpress.raw({ type: "application/json" })on the webhook route, notexpress.json(). - Answer 2xx quickly. ClockNext waits 5 seconds and retries any other answer. Do slow work after you respond.
- De-duplicate on
event.id. Delivery is at-least-once, and a retry carries the sameid. - Ignore event types you do not know. More may be added; answer 2xx.
- Field names are snake_case, exactly as sent (
event_type,previous_balance, …), unlike the camelCase the rest of the SDK returns. sourceis not the ledger'ssource.wallet.balance.updatecarries the stored source —TOPUP,USAGEorADJUSTMENT— not the finerSUBSCRIPTION/RENEWAL/API/ … thatcustomers.wallet()reports. Do not compare the two.- To test your receiver, sign a fixture with
signWebhookPayload(secret, body, Math.floor(Date.now() / 1000))and send it as the header.
See examples/webhook-receiver.ts.
Customer portal
const { token, expiresAt } = await cnk.portal.createToken({
customerId,
ttlSeconds: 600,
});Pass the portal token to your frontend and embed it using your portal URL. Never pass the API key to the frontend.
Configuration
const cnk = new ClockNext({
apiKey,
baseUrl: "https://payments.clocknext.com", // optional: the Payments API
eventsBaseUrl: "https://events.clocknext.com", // optional: the events API
mode: "async", // "async" (default) | "sync"
timeoutMs: 10_000,
fetch: myFetch, // optional fetch implementation
logger: { warn: console.warn, debug: console.debug },
batch: {
maxSize: 20,
maxIntervalMs: 2_000,
maxConcurrency: 5,
maxQueueSize: 10_000,
},
retry: {
maxAttempts: 5,
baseDelayMs: 200,
maxDelayMs: 10_000,
},
onError: (error, signal) => {},
onDrop: (signal, reason) => {},
onRetry: ({ attempt, delayMs, error }) => {},
onFlush: (sentCount) => {},
});With no onError supplied the SDK reports failures on console.error rather
than swallowing them. Pass onError: () => {} to silence that deliberately.
eventsBaseUrl defaults to https://events.clocknext.com, or to baseUrl
when you set only baseUrl: a proxy or a test server in front of both keeps
working, because the Payments API forwards the events routes.
Testing and packaging
npm run typecheck
npm test
npm run buildThe package publishes ESM and CommonJS builds with TypeScript declarations, and
ships its annotated src/ so source maps resolve and the implementation is
readable from node_modules.
License
MIT
