npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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)
               →  customer

The 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/sdk

Quick start — zero to a recorded signal

Before running this, do the one-time setup in the ClockNext dashboard:

  1. Enable a model under Settings → Models.
  2. Create a credit (its agentKey is what signals address).
  3. 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, adjustedAt is when the original happened, remaining is 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 agentKey

Reserved 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 | undefined

When 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); // cleared

Look 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.createToken

Your 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 it

If 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 with archive(), bring it back with unarchive(). 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 back

Models 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).

  • behavior is lower case: "inclusive" (already inside the price) or "exclusive" (added on top).
  • countryCode is 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 with isActive: false and 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 changes

Webhooks

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 null

Verify, 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 mount express.raw({ type: "application/json" }) on the webhook route, not express.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 same id.
  • 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.
  • source is not the ledger's source. wallet.balance.update carries the stored source — TOPUP, USAGE or ADJUSTMENT — not the finer SUBSCRIPTION / RENEWAL / API / … that customers.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 build

The 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