@clocknext/sdk 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,88 @@
1
1
  # @clocknext/sdk
2
2
 
3
- Official ClockNext SDK for Node/TypeScript. Record **usage signals** and manage
4
- **plans, purchases, invoices, customers, catalogs, and billing** from your
5
- backend — full coverage of the ClockNext `/api/v1` surface.
3
+ Official TypeScript/JavaScript SDK for ClockNext. Use it from a backend to:
6
4
 
7
- > **Server-side only.** Your `cnk_…` API key has org-wide write access — never
8
- > ship it to a browser. For embedding the customer portal in a browser, mint a
9
- > short-lived token with `cnk.portal.createToken()`.
5
+ - record credit, wallet, outcome, and unit usage;
6
+ - read customer usage, balances, plans, invoices, and payments;
7
+ - manage customers, billing plans, purchases, catalogs, webhooks, and portal tokens.
8
+
9
+ The SDK targets the ClockNext `/api/v1` API and requires Node.js 18 or newer.
10
+ It has zero runtime dependencies and works with native `fetch` in Node, Bun,
11
+ Deno, and edge runtimes.
12
+
13
+ ---
14
+
15
+ ## Read this before you write any code
16
+
17
+ Two facts decide whether your integration works. Neither is obvious from the
18
+ method signatures, and getting either wrong produces **no error at all**.
19
+
20
+ ### 1. A successful signal call does not mean the usage was recorded
21
+
22
+ Every `POST /api/v1/signal/*` route answers `202 Accepted` as soon as it has
23
+ written your request to a durable inbox. It is validated and priced afterwards,
24
+ by a background worker. So this resolves successfully even when the signal is
25
+ about to be thrown away:
26
+
27
+ ```ts
28
+ const result = await cnk.signals.credit(
29
+ { customerId, model: model.modelId, agentKey: credit.agentKey, tokens: { input: 1, output: 1 } },
30
+ { wait: true },
31
+ );
32
+ // { status: "accepted", usageLog: null, rawId: "...", messageId: "..." }
33
+ ```
34
+
35
+ That is a deliberate trade: metering must never put a billing round-trip on your
36
+ request path. But it means **rejections cannot come back through the promise**.
37
+ A signal is silently discarded if any of these is true:
38
+
39
+ | Cause | How to avoid it |
40
+ | --- | --- |
41
+ | `customerId` does not exist | create the customer first |
42
+ | `model` is not enabled in your workspace | use a `modelId` from `workspace.models()` verbatim |
43
+ | `agentKey` names no credit / outcome step / unit | read it from `credits.list()`, `outcomes.get()`, `units.list()` |
44
+ | the customer has no active purchase of a plan carrying that entitlement | `plans.create()` then `purchases.create()` |
45
+ | `member` is set to an email that is not a member of that customer | `customers.addMember()` first, or omit `member` |
46
+ | a required field is missing | see the tables below |
47
+ | the allowance is exhausted | top up with `customers.adjustCredit()` etc. |
48
+
49
+ Two things follow:
50
+
51
+ - **Always pass `onError`.** It is the only channel a rejected signal has. The
52
+ SDK logs to `console.error` by default rather than staying silent, but a real
53
+ integration should handle it.
54
+ - **Prove it landed with `signals.list()`**, not with the return value. That is
55
+ the only authoritative read.
56
+
57
+ ```ts
58
+ const usage = await cnk.signals.list({ customerId });
59
+ console.log(usage.totals.count); // 0 means nothing was recorded
60
+ ```
61
+
62
+ ### 2. Nothing can be metered until five things exist
63
+
64
+ A signal is not self-contained; it references rows that must already be there.
65
+ The full chain, in order:
66
+
67
+ ```
68
+ enabled model → catalog entitlement (credit / outcome / unit, with an agentKey)
69
+ → plan containing that entitlement
70
+ → purchase linking the customer to the plan
71
+ → customer
72
+ ```
73
+
74
+ `customerId`, `model` and `agentKey` are **not strings you can invent**. The
75
+ quick start below discovers each one instead of hard-coding it.
76
+
77
+ ---
78
+
79
+ ## Security: backend only
80
+
81
+ `cnk_...` API keys have organization-wide access. Never expose one in browser
82
+ code, mobile apps, frontend bundles, or public Git repositories.
83
+
84
+ Use `cnk.portal.createToken()` on your backend when a browser needs to embed the
85
+ customer portal. The browser should receive only the short-lived portal token.
10
86
 
11
87
  ## Install
12
88
 
@@ -14,374 +90,552 @@ backend — full coverage of the ClockNext `/api/v1` surface.
14
90
  npm install @clocknext/sdk
15
91
  ```
16
92
 
17
- Requires Node ≥ 18 (native `fetch`). Zero runtime dependencies. Uses only
18
- Web-standard `fetch` and `crypto`, so it also runs on edge runtimes (Cloudflare
19
- Workers, Vercel Edge, Deno, Bun).
93
+ ## Quick start — zero to a recorded signal
20
94
 
21
- ## Quick start
95
+ This is a complete, runnable setup for an empty workspace. Every id is read back
96
+ from the API rather than guessed.
22
97
 
23
98
  ```ts
24
99
  import { ClockNext } from "@clocknext/sdk";
25
100
 
26
- const cnk = new ClockNext({ apiKey: process.env.CLOCKNEXT_API_KEY! });
101
+ const apiKey = process.env.CLOCKNEXT_API_KEY;
102
+ if (!apiKey) throw new Error("CLOCKNEXT_API_KEY is required");
103
+
104
+ const cnk = new ClockNext({
105
+ apiKey,
106
+ // The only channel a rejected signal has. Do not skip it.
107
+ onError: (error, signal) =>
108
+ console.error("[clocknext] signal rejected", signal?.type, error),
109
+ });
110
+
111
+ // 1. Which workspace is this key for? `live` bills real money.
112
+ const { organization } = await cnk.workspace.me();
113
+ console.log(organization.environment); // "sandbox" | "live"
114
+
115
+ // 2. A model you are allowed to meter against. Use `modelId` VERBATIM.
116
+ const [model] = await cnk.workspace.models({ active: true });
117
+ if (!model) throw new Error("Enable a model in Settings → Models first.");
118
+
119
+ // 3. A credit type. Its `agentKey` is what signals address.
120
+ let [credit] = await cnk.credits.list({ active: true });
121
+ credit ??= await cnk.credits.create({
122
+ name: "API credit",
123
+ agentKey: "api_credit", // stable, lowercase [a-z0-9._-], unique per org
124
+ basePrice: 10,
125
+ marginPercent: 20,
126
+ pricePerCredit: 12, // server recomputes this from basePrice × margin
127
+ tokensPerCredit: 1000,
128
+ });
129
+
130
+ // 4. A customer.
131
+ const customer = await cnk.customers.create({
132
+ name: "Acme",
133
+ email: "billing@acme.com",
134
+ });
135
+
136
+ // 5. A plan that SELLS that credit, and a purchase linking the two.
137
+ // Without this step every signal below is silently discarded.
138
+ const plan = await cnk.plans.create({
139
+ name: "Pro",
140
+ billingCycle: "MONTHLY",
141
+ currencyCode: "USD",
142
+ components: [
143
+ { type: "CREDIT", billingMode: "ADVANCE", creditId: credit.id, quantity: 10_000 },
144
+ ],
145
+ });
146
+ await cnk.purchases.create({ customerId: customer.id, planId: plan.id });
27
147
 
148
+ // 6. Now a signal can land.
28
149
  await cnk.signals.credit({
29
- customerId: "cus_123",
30
- model: "llama-3.3-70b-versatile", // catalog model id (OrgModel.modelId)
31
- agentKey: "api_credit", // the credit's stable agent key
32
- member: "alice@acme.com",
33
- tokens: { input: 1200, output: 340, cache: 0 },
150
+ customerId: customer.id,
151
+ model: model.modelId,
152
+ agentKey: credit.agentKey,
153
+ tokens: { input: 1200, output: 340 },
34
154
  custom: { feature: "summarize" },
35
155
  });
36
156
 
37
- // Before a serverless function returns (or on shutdown):
157
+ // 7. Required in async mode before the process can exit.
38
158
  await cnk.flush();
159
+
160
+ // 8. Prove it. Ingestion is asynchronous, so allow a moment.
161
+ const usage = await cnk.signals.list({ customerId: customer.id });
162
+ console.log(usage.totals.count, "signals recorded");
39
163
  ```
40
164
 
41
- ## Recording signals
165
+ A runnable version is in [`examples/node-basic.ts`](./examples/node-basic.ts),
166
+ shipped with the package.
42
167
 
43
- There are three signal types, matching ClockNext's billable primitives. Each has
44
- a typed helper that enforces the right required fields at compile time, and each
45
- posts to **its own route** — `/api/v1/signal/credit`, `/signal/wallet`,
46
- `/signal/outcome`. The signal's `type` is what picks that route; from 0.10 it is
47
- no longer sent in the body.
168
+ ## Signals
48
169
 
49
- > **Migrating from 0.9 or earlier?** Your calling code does not change — same
50
- > methods, same arguments. What changed is the wire: the SDK now targets the
51
- > `/api/v1/signal/*` family instead of the deprecated `POST /api/v1/usage`, and
52
- > the `metric` field is now `composite` (see [Composites](#composites)). Bump the
53
- > package and you are migrated.
170
+ Signals are billable usage events. Each kind posts to its own route.
54
171
 
55
- `model` is always the **catalog model id** enabled in your org (matched
56
- case-insensitively against `OrgModel.modelId`). Credits and outcomes are
57
- referenced by their **stable `agentKey`** (assigned when you create the credit /
58
- outcome step), not by display name.
172
+ | SDK method | Meter | Required fields |
173
+ | --- | --- | --- |
174
+ | `signals.credit()` | Credit allowance | `customerId`, `model`, `agentKey`, `tokens` |
175
+ | `signals.wallet()` | Customer wallet | `customerId`, `model`, `tokens` |
176
+ | `signals.outcome()` | Outcome workflow step | `customerId`, `model`, `agentKey`, `runId`, `tokens` |
177
+ | `signals.unit()` | Quantity-based unit | `customerId`, `agentKey` |
59
178
 
60
- ```ts
61
- // Credit — metered against a credit (by agentKey); charged at the model's real cost + your margin
62
- await cnk.signals.credit({ customerId, model: "llama-3.3-70b-versatile", agentKey: "api_credit", tokens: { input: 1200, output: 340 } });
179
+ `signals.track(signal)` is the underlying call the four convenience methods wrap;
180
+ use it when the kind is dynamic.
63
181
 
64
- // Wallet — debits the customer's USD wallet at model cost
65
- await cnk.signals.wallet({ customerId, model: "openai/gpt-oss-120b", tokens: { input: 800, output: 5000 } });
182
+ ### Where each value comes from
66
183
 
67
- // Outcome — one step of a run (agentKey = the OutcomeStep's agent key)
68
- await cnk.signals.outcome({ customerId, model: "llama-3.3-70b-versatile", agentKey: "resolved_ticket.triage", runId: "ticket_9021", tokens: { input: 600, output: 150 } });
184
+ | Field | Source | Notes |
185
+ | --- | --- | --- |
186
+ | `model` | `workspace.models()` → `modelId` | Use it verbatim. Matching is case-insensitive but the id must exist; `"openai/gpt-4o"` is not the same as `"gpt-4o"`. |
187
+ | `agentKey` (credit) | `credits.list()` → `agentKey` | Case-insensitive. |
188
+ | `agentKey` (outcome) | `outcomes.get(id)` → `steps[].agentKey` | The **step's** key, not the outcome's own `agentKey`. `outcomes.list()` does not return steps. |
189
+ | `agentKey` (unit) | `units.list()` → `agentKey` | Matched **exactly** — keys are stored lowercased. |
190
+ | `member` | `customers.listMembers(id)` → `email` | Optional. **If set to an email that is not a member of this customer, the whole signal is discarded.** Add them with `customers.addMember()` first. |
191
+ | `composite.ref` | `composites.list()` → `refId` | An unknown `ref` is ignored, not rejected — the signal still records. |
192
+
193
+ ### Credit and wallet
194
+
195
+ ```ts
196
+ await cnk.signals.credit({
197
+ customerId,
198
+ model: model.modelId,
199
+ agentKey: credit.agentKey,
200
+ tokens: { input: 500, output: 120 },
201
+ });
202
+
203
+ await cnk.signals.wallet({
204
+ customerId,
205
+ model: model.modelId,
206
+ tokens: { input: 500, output: 120 },
207
+ });
69
208
  ```
70
209
 
210
+ Wallet signals take no `agentKey` — they debit money at the model's cost rather
211
+ than drawing on a named entitlement — and cannot be grouped into a composite.
212
+
71
213
  ### Outcome runs
72
214
 
73
- An outcome is billed per **run**, not per step. Every signal for one run shares a
74
- `runId` (opaque — any id you can correlate, e.g. your ticket/session id), so a
75
- step can fire as many times as it needs to. The run bills **once**, when you mark
76
- the final step `complete: true` — completion is never inferred from step
77
- coverage. Sending `complete` twice is a safe no-op.
215
+ An outcome is billed once per run, not once per step. Reuse the same `runId` for
216
+ every step and set `complete: true` only on the final one.
78
217
 
79
218
  ```ts
80
219
  const runId = "ticket_9021";
81
- // each step of the run carries the same runId…
82
- await cnk.signals.outcome({ customerId, model, agentKey: "resolved_ticket.triage", runId, tokens: { input: 600, output: 150 } });
83
- await cnk.signals.outcome({ customerId, model, agentKey: "resolved_ticket.verify", runId, tokens: { input: 200, output: 80 } }); // may repeat
84
- // …and the last one closes (and bills) the run:
85
- await cnk.signals.outcome({ customerId, model, agentKey: "resolved_ticket.resolve", runId, complete: true, tokens: { input: 500, output: 200 } });
220
+
221
+ await cnk.signals.outcome({
222
+ customerId,
223
+ model: model.modelId,
224
+ agentKey: "resolved_ticket.triage", // a STEP key
225
+ runId,
226
+ tokens: { input: 600, output: 150 },
227
+ });
228
+
229
+ await cnk.signals.outcome({
230
+ customerId,
231
+ model: model.modelId,
232
+ agentKey: "resolved_ticket.resolve",
233
+ runId,
234
+ complete: true,
235
+ tokens: { input: 500, output: 200 },
236
+ });
86
237
  ```
87
238
 
88
- ### Composites
239
+ A step may repeat within a run — a verify loop firing three times is three
240
+ signals and one outcome. `complete: true` twice is a safe no-op.
89
241
 
90
- A **composite** bundles several credits / outcomes / units and is priced and
91
- billed as one thing. Tag a signal with `composite` to group it into one
92
- *occurrence* — `ref` is the composite's ref ID and `value` is your own
93
- correlation id for that occurrence (a session, a call, a job):
242
+ ### Unit usage
243
+
244
+ Units carry no model and no tokens. `quantity` defaults to `1`.
94
245
 
95
246
  ```ts
96
- await cnk.signals.credit({
97
- customerId, model: "llama-3.3-70b-versatile", agentKey: "api_credit",
98
- tokens: { input: 1200, output: 340 },
99
- composite: { ref: "voice_ai", value: "call_5f3a" },
100
- });
247
+ await cnk.signals.unit({ customerId, agentKey: "seats", quantity: 3 });
101
248
  ```
102
249
 
103
- An occurrence opens on the first tagged signal and costs nothing. **Closing it
104
- is what bills it** — one charge for the bundle, however many signals it
105
- contained:
250
+ `signals.unit()` takes **no `{ wait: true }` option** — unit reports are never
251
+ buffered and are always sent immediately.
252
+
253
+ ### Composites
254
+
255
+ A composite groups credit, outcome, and unit signals into one billable
256
+ occurrence. Tag each signal, then close the occurrence explicitly.
106
257
 
107
258
  ```ts
108
- await cnk.signals.completeComposite({
259
+ const composite = { ref: "voice_ai", value: "call_5f3a" };
260
+
261
+ await cnk.signals.credit({
109
262
  customerId,
110
- composite: { ref: "voice_ai", value: "call_5f3a" },
263
+ model: model.modelId,
264
+ agentKey: credit.agentKey,
265
+ tokens: { input: 100, output: 50 },
266
+ composite,
111
267
  });
268
+
269
+ // Closing is ALWAYS explicit. Put it on the line before your own return.
270
+ await cnk.signals.completeComposite({ customerId, composite });
112
271
  ```
113
272
 
114
- Only your code knows when the work ended, so closing is a declaration, not
115
- something ClockNext infers from which signals have arrived: tagged signals may
116
- arrive in any order and the occurrence stays open and unbilled until you say
117
- otherwise. Put the call on the line before your own `return`.
273
+ **An outcome's `complete: true` does not close a composite occurrence.** It
274
+ closes the run, and nothing else — whatever mix of credits, outcomes and units
275
+ the composite wraps. If you tag outcome signals with a `composite`, you must
276
+ still call `completeComposite()`, or that occurrence stays open and is never
277
+ billed.
118
278
 
119
- An **outcome never needs it** — its own `complete: true` closes its run and the
120
- matching occurrence together. Credits and units have no "done" signal of their
121
- own, so this is theirs.
279
+ Closing twice is safe. Closing early is safe — the server parks the ping until
280
+ the first tagged signal arrives. Wallet signals do not participate in composites.
122
281
 
123
- Closing twice is safe (`ALREADY_COMPLETED`, and it cannot bill again), and so is
124
- closing *early* — before every entitlement in the bundle has reported, or while
125
- a prepaid slot pool is full: the server parks the request and applies it when
126
- the blocker clears, rather than failing it.
282
+ ## Delivery status: `buffered`, `accepted`, `recorded`, `dropped`
127
283
 
128
- A tag that names no live composite is ignored and never fails the signal — the
129
- signal carries money, the tag only decides what it is grouped under. Tags on
130
- wallet signals are ignored.
284
+ Every metering call resolves with a `status` saying how far the signal actually
285
+ got. Branch on it rather than on the legacy `queued` boolean, which collapses
286
+ the first two states into one.
131
287
 
132
- > **Renamed in 0.10.** This field was called `metric` and was documented as
133
- > "reporting only, never changes what's billed". That was wrong: where the plan
134
- > sells the composite, the occurrence is the billable unit. Rename `metric:` to
135
- > `composite:` — the shape (`{ ref, value }`) is unchanged.
288
+ | `status` | Meaning | Can it still be lost? |
289
+ | --- | --- | --- |
290
+ | `"buffered"` | In this process's memory, not yet sent (async mode) | **Yes** — flush before the process exits |
291
+ | `"accepted"` | Written to the server's durable inbox (`202`), not yet priced | No, but not billed yet either |
292
+ | `"recorded"` | Priced inline; `usageLog` is populated | No |
293
+ | `"dropped"` | The SDK discarded it — client closed, or local queue full | Already lost; `onDrop` fired |
136
294
 
137
- ### Async vs sync
295
+ The default mode is `async`: the signal is buffered locally and the call returns
296
+ immediately.
138
297
 
139
- By default the client is in **`async`** mode: `track()` buffers the signal and
140
- returns immediately (`{ queued: true }`), and a background flusher drains the
141
- buffer on a size/interval trigger. This keeps billing telemetry off your LLM hot
142
- path.
298
+ ```ts
299
+ const signal = {
300
+ customerId,
301
+ model: model.modelId,
302
+ agentKey: credit.agentKey,
303
+ tokens: { input: 1200, output: 340 },
304
+ };
143
305
 
144
- > **"Batching" here means timing, not a bulk request.** Each signal is still its
145
- > own POST to its own `/api/v1/signal/*` route; the flusher just groups *when*
146
- > sends happen and caps how many run at once (`batch.maxConcurrency`). There is
147
- > no single bulk-ingest call, so size your request budget per signal.
306
+ const cnk = new ClockNext({ apiKey }); // async (default)
307
+ await cnk.signals.credit(signal); // -> { status: "buffered" }
308
+ await cnk.signals.credit(signal, { wait: true }); // -> "accepted" | "recorded"
148
309
 
149
- To await the request itself (rather than buffering it), either:
310
+ const sync = new ClockNext({ apiKey, mode: "sync" }); // every signal awaits
311
+ ```
150
312
 
151
- ```ts
152
- // per call
153
- const res = await cnk.signals.credit({ … }, { wait: true });
313
+ `{ wait: true }` and `mode: "sync"` wait for the server to **accept** the
314
+ signal, never for it to finish pricing. No mode of this SDK blocks a request
315
+ thread on a billing result.
154
316
 
155
- // or globally
156
- const cnk = new ClockNext({ apiKey, mode: "sync" });
157
- ```
317
+ `cnk.pending` reports how many signals are currently buffered.
318
+
319
+ ### Serverless and shutdown
158
320
 
159
- > **Awaiting is not the same as billed.** Where the server has a durable queue
160
- > configured it answers `202` and prices the signal in a worker, so you get
161
- > `{ queued: true, messageId, usageLog: null }` — accepted, not yet recorded, and
162
- > validated only later (a bad `agentKey` is dropped there, silently). Only when
163
- > the server prices inline do you get `{ queued: false, usageLog }`. Branch on
164
- > `queued`; never assume `usageLog` is populated.
165
- >
166
- > To validate and price a signal synchronously, use `signals.verify()` (a dry
167
- > run — it always returns the projected log). To confirm a real signal landed,
168
- > read it back with `signals.list({ customerId })`.
169
-
170
- > In `async` mode, server-side rejections (e.g. insufficient allowance, `422`)
171
- > surface via the `onError` hook or `flush()`, not from the `track()` call.
172
-
173
- ### Reliability
174
-
175
- - **Retries:** transient failures (network, timeout, `408/409/429/5xx`) are
176
- retried with exponential backoff + equal jitter, honoring `Retry-After`.
177
- Deterministic `4xx` (`400/401/404/422`) are never retried.
178
- - **Replay safety:** reads (`GET`) and `DELETE` are retried automatically. Metering
179
- signals (`signals.*`) opt into retries too — made safe by the idempotency key
180
- below. Other non-idempotent writes — e.g. `customers.create`, `plans.create`,
181
- `purchases.create` — are *not* auto-retried, so a transient blip can never
182
- create a duplicate.
183
- - **Idempotency:** every metering signal carries an idempotency key — auto-
184
- generated per signal, or pass your own via `idempotencyKey` on the signal —
185
- reused across all retries, so a retried send is **deduplicated by the server**
186
- instead of double-counted. Pass a stable key (e.g. your own event id) to also
187
- dedup across process restarts or at-least-once redelivery.
188
- - **Flushing:** call `await cnk.flush()` before a serverless return, and
189
- `await cnk.close()` on graceful shutdown to drain the buffer.
190
- - **Durability:** the async buffer is **in-memory**. A signal already accepted
191
- by the server is durable on the server side, but signals still buffered when a
192
- process crashes (before send) are lost. For zero-loss-before-receipt, either
193
- run in `sync` mode (or `{ wait: true }`) so the send completes before your
194
- code continues, or call `flush()`/`close()` at the right boundaries. A
195
- pluggable before-send persistence store is not yet available.
196
-
197
- ## Customers
321
+ The local queue is in memory, so it must be drained before the process can
322
+ freeze or exit.
198
323
 
199
324
  ```ts
200
- const c = await cnk.customers.create({ name: "Acme", email: "ops@acme.com" });
201
- const one = await cnk.customers.get(c.id);
202
- const page = await cnk.customers.list({ limit: 50, q: "ac" });
203
- await cnk.customers.update(c.id, { legalName: "Acme Inc." });
204
- await cnk.customers.delete(c.id);
205
-
206
- // iterate every customer (auto-follows the cursor)
207
- for await (const cust of cnk.customers.iterate()) { /* … */ }
208
-
209
- // insights
210
- await cnk.customers.usage(c.id, { limit: 50 });
211
- await cnk.customers.balances(c.id);
212
- await cnk.customers.plan(c.id);
213
- await cnk.customers.revenue(c.id, { range: "30d" });
325
+ // Serverless handler — flush, but do NOT close: the container is reused.
326
+ try {
327
+ // application work and signal tracking
328
+ } finally {
329
+ await cnk.flush();
330
+ }
331
+
332
+ // Long-running process, on shutdown only:
333
+ await cnk.close();
214
334
  ```
215
335
 
216
- ### Members
336
+ Construct the client **once at module scope**, not per request. After `close()`
337
+ the client is permanently shut: further signals resolve `{ status: "dropped" }`
338
+ and fire `onDrop` with reason `"closed"`. Never call `close()` in a serverless
339
+ handler.
340
+
341
+ If losing a buffered signal is unacceptable, use `mode: "sync"` or
342
+ `{ wait: true }`, and supply your own stable `idempotencyKey`.
343
+
344
+ ## Idempotency and retries
345
+
346
+ Metering calls get an idempotency key automatically and reuse it across every
347
+ retry, so a retried send is deduplicated server-side instead of double-billing.
348
+ Supply your own stable key to also deduplicate across process restarts and
349
+ at-least-once redelivery:
217
350
 
218
351
  ```ts
219
- await cnk.customers.addMember(c.id, { name: "Alice", email: "alice@acme.com" });
220
- const members = await cnk.customers.listMembers(c.id);
221
- await cnk.customers.updateMember(c.id, members[0].id, { name: "Alice R." });
222
- await cnk.customers.removeMember(c.id, members[0].id);
352
+ await cnk.signals.credit({
353
+ customerId,
354
+ model: model.modelId,
355
+ agentKey: credit.agentKey,
356
+ tokens: { input: 10, output: 5 },
357
+ idempotencyKey: `llm-request:${requestId}`,
358
+ });
223
359
  ```
224
360
 
225
- ### Wallet & billing config
361
+ When a signal fails permanently, the key it was sent with is available on both
362
+ the error (`error.idempotencyKey`) and the signal handed to `onError`/`onDrop`.
363
+ **Replay with that key** — generating a new one bills the event twice.
226
364
 
227
- ```ts
228
- // wallet
229
- await cnk.customers.wallet(c.id, { limit: 50 }); // { balanceUsd, transactions }
230
- await cnk.customers.addWalletEntry(c.id, { type: "CREDIT", amount: 25 }); // top up (DEBIT to spend)
365
+ Retries apply to network failures, timeouts, `408`, `429`, `5xx`, and the one
366
+ transient `409`. Validation, authentication, not-found, plan, allowance and
367
+ permanent-conflict errors are never retried. Reads and `DELETE`s are retried;
368
+ `POST`/`PATCH` are not, except metering calls, which opt in because they are
369
+ idempotency-keyed.
231
370
 
232
- // billing config — currency is set through update()
233
- await cnk.customers.update(c.id, { currencyCode: "EUR" });
371
+ Client-side batching is timing and concurrency management, not a bulk API — each
372
+ signal is still an individual HTTP request.
234
373
 
235
- // grant / claw back balances (delta is signed)
236
- await cnk.customers.adjustCredit(c.id, { creditId: "cr_1", delta: 100 });
237
- const { remaining } = await cnk.customers.adjustOutcome(c.id, { outcomeId: "out_1", delta: -1 });
238
- await cnk.customers.adjustUnit(c.id, { unitId: "un_1", delta: 5 });
239
- ```
374
+ ## Handling errors
240
375
 
241
- ## Plans & purchases
376
+ Branch on error classes, never on message text.
242
377
 
243
378
  ```ts
244
- // define a plan
245
- const plan = await cnk.plans.create({
246
- name: "Pro",
247
- billingCycle: "MONTHLY",
248
- currencyCode: "USD",
249
- components: [
250
- { type: "WALLET", billingMode: "ADVANCE", amount: 100 },
251
- { type: "CREDIT", billingMode: "ADVANCE", creditId: "cr_1", quantity: 10_000 },
252
- ],
253
- });
254
- await cnk.plans.list({ active: true });
255
- await cnk.plans.setActive(plan.id, false);
379
+ import {
380
+ ClockNextError,
381
+ AllowanceError,
382
+ AuthError,
383
+ ConflictError,
384
+ ForbiddenError,
385
+ NetworkError,
386
+ NotFoundError,
387
+ PlanError,
388
+ RateLimitError,
389
+ ServerError,
390
+ ValidationError,
391
+ } from "@clocknext/sdk";
256
392
 
257
- // subscribe a customer (auto-cancels their existing active/scheduled purchase)
258
- const purchase = await cnk.purchases.create({ customerId: c.id, planId: plan.id });
259
- await cnk.purchases.setAutoPayment(purchase.id, false, { pauseReason: "trial" });
260
- await cnk.purchases.cancel(purchase.id);
393
+ try {
394
+ await cnk.customers.get(customerId);
395
+ } catch (error) {
396
+ if (error instanceof NotFoundError) {
397
+ // No such customer in this workspace.
398
+ } else if (error instanceof AuthError) {
399
+ // Rotate or correct the API key.
400
+ } else if (error instanceof ValidationError) {
401
+ // Fix the request shape or required fields.
402
+ } else if (error instanceof RateLimitError || error instanceof ServerError) {
403
+ // Already retried by the SDK; add an application-level fallback.
404
+ } else if (error instanceof ClockNextError) {
405
+ console.error(error.status, error.code, error.message);
406
+ }
407
+ }
261
408
  ```
262
409
 
263
- ## Invoices & payments (read-only)
410
+ | Class | Status | Meaning |
411
+ | --- | --- | --- |
412
+ | `ValidationError` | 400 | Malformed request or failed schema validation |
413
+ | `AuthError` | 401 | Missing, invalid or expired API key |
414
+ | `ForbiddenError` | 403 | Key is valid but not permitted to do this |
415
+ | `NotFoundError` | 404 | Unknown resource (also returned for cross-org access) |
416
+ | `ConflictError` | 409 | Conflict — usually an archived customer. Permanent, and not retried |
417
+ | `PlanError` | 422 | No active plan, meter not in plan, model not enabled, member missing |
418
+ | `AllowanceError` | 422 | The entitlement ran out — top up or upsell |
419
+ | `RateLimitError` | 429 | Rate limited; carries `retryAfterMs` |
420
+ | `ServerError` | 5xx | Server-side failure |
421
+ | `NetworkError` | — | fetch threw or the request timed out |
422
+
423
+ All extend `ClockNextError`, which exposes `.status` (`number | undefined`),
424
+ `.retryable`, `.code` (the server's machine-readable code, when it sends one),
425
+ and `.idempotencyKey` on metering calls. Constructor failures — a missing
426
+ `apiKey`, no available `fetch` — throw `ValidationError`, so a single
427
+ `instanceof ClockNextError` catches setup problems too.
428
+
429
+ ### Signal failures do not arrive here
430
+
431
+ Because ingest answers `202` before validating, **a try/catch around
432
+ `signals.credit()` will not catch a rejected signal**, with or without
433
+ `{ wait: true }`. Those surface on the hooks:
264
434
 
265
435
  ```ts
266
- const invoices = await cnk.invoices.list({ customerId: c.id, status: "OPEN" });
267
- const { invoice, plan } = await cnk.invoices.get(invoices[0].id);
268
- const { url } = await cnk.invoices.payLink(invoices[0].id); // hosted pay link (OPEN only)
269
-
270
- const payments = await cnk.payments.list({ customerId: c.id });
271
- await cnk.payments.get(payments[0].id); // id = the paid invoice id
436
+ import { ClockNext, ClockNextError } from "@clocknext/sdk";
437
+
438
+ const cnk = new ClockNext({
439
+ apiKey,
440
+ onError: (error, signal) => {
441
+ // `error` is `unknown`; narrow it.
442
+ if (error instanceof ClockNextError) {
443
+ logger.error({ status: error.status, key: error.idempotencyKey }, error.message);
444
+ }
445
+ deadLetter.push(signal); // replay later with signal.idempotencyKey
446
+ },
447
+ onDrop: (signal, reason) => logger.error({ signal, reason }, "signal dropped"),
448
+ onRetry: ({ attempt, delayMs, error }) => logger.warn({ attempt, delayMs, error }),
449
+ onFlush: (sentCount) => logger.info({ sentCount }, "signals flushed"),
450
+ });
272
451
  ```
273
452
 
274
- ## Catalogs: credits, outcomes, units
453
+ `onFlush` counts signals the server **accepted**, not signals it billed.
275
454
 
276
- Define the billable *types*; record usage against them with `signals.*`.
455
+ ## Balances and usage
277
456
 
278
457
  ```ts
279
- // credit type — signals.credit({ agentKey }) meters against it
280
- await cnk.credits.create({ name: "API", agentKey: "api_credit", basePrice: 1, marginPercent: 20, pricePerCredit: 0.01 });
281
- await cnk.credits.list({ active: true });
282
-
283
- // outcome type — a multi-step workflow. TWO kinds of agentKey, both required and
284
- // both unique per org: the outcome's own (its identity) and each step's (what
285
- // signals.outcome() reports against). They are separate namespaces.
286
- await cnk.outcomes.create({
287
- name: "Resolved ticket",
288
- agentKey: "resolved_ticket",
289
- basePrice: 1, marginPercent: 20, pricePerOutcome: 2,
290
- steps: [{ name: "Triage", agentKey: "resolved_ticket.triage", basePrice: 0.5 }],
291
- });
292
-
293
- // unit type — a quantity-priced meter (FLAT / SLAB / VOLUME)
294
- await cnk.units.create({ name: "Seats", agentKey: "seats", pricingType: "FLAT", flatPrice: 5 });
458
+ const balances = await cnk.customers.balances(customerId);
459
+ balances.wallet?.balanceUsd;
460
+ balances.credits; // granted, used, remaining
461
+ balances.outcomes; // granted, remaining, steps
462
+ balances.units; // granted, remaining
463
+
464
+ // Convenience lookups — these fetch the full snapshot, so prefer `balances()`
465
+ // when you need more than one row from the same customer.
466
+ await cnk.customers.creditBalance(customerId, { creditName: "API credit" });
467
+ await cnk.customers.outcomeBalance(customerId, { outcomeName: "Resolved ticket" });
468
+ await cnk.customers.unitBalance(customerId, { unitName: "Seats" });
469
+
470
+ // Usage history, and unit balances + recent unit events.
471
+ await cnk.signals.list({ customerId, limit: 50 });
472
+ await cnk.signals.unitUsage({ customerId, limit: 50 });
295
473
  ```
296
474
 
297
- ### Unit metering
475
+ Adjustments use stable agent keys, not display names:
298
476
 
299
- Record a unit event (no tokens, no model) and read a customer's unit balances:
477
+ ```ts
478
+ await cnk.customers.adjustCredit(customerId, { agentKey: "api_credit", delta: 100 });
479
+ await cnk.customers.adjustOutcome(customerId, { agentKey: "resolved_ticket.triage", delta: 1 });
480
+ await cnk.customers.adjustUnit(customerId, { agentKey: "seats", delta: 5 });
481
+ ```
482
+
483
+ ## Customers, plans, and billing
300
484
 
301
485
  ```ts
302
- await cnk.signals.unit({ customerId: c.id, agentKey: "seats" });
486
+ const customer = await cnk.customers.create({ name: "Acme", email: "billing@acme.com" });
487
+ await cnk.customers.update(customer.id, { currencyCode: "USD" });
488
+ await cnk.customers.list({ limit: 50, q: "acme" });
303
489
 
304
- // several at once, instead of N separate calls
305
- await cnk.signals.unit({ customerId: c.id, agentKey: "seats", quantity: 3 });
490
+ for await (const c of cnk.customers.iterate()) {
491
+ // follows the cursor for you
492
+ }
306
493
 
307
- // part of a composite, and safe to retry
308
- await cnk.signals.unit({
309
- customerId: c.id, agentKey: "seats",
310
- composite: { ref: "voice_ai", value: "call_5f3a" },
311
- idempotencyKey: `seats:${requestId}`,
494
+ const purchase = await cnk.purchases.create({ customerId: customer.id, planId: plan.id });
495
+ await cnk.purchases.setAutoPayment(purchase.id, false, {
496
+ pauseReason: "Customer requested manual payment",
312
497
  });
313
-
314
- await cnk.signals.unitUsage({ customerId: c.id });
315
498
  ```
316
499
 
317
- `quantity` defaults to 1. On a prepaid (ADVANCE) plan the draw is
318
- all-or-nothing: asking for more than remains is refused in full rather than
319
- partially filled.
500
+ Additional resource methods:
320
501
 
321
- > **Changed in 0.10.** `signals.unit()` moved to `POST /api/v1/signal/unit`,
322
- > which is **durable** (the report is written down before it is priced, so a
323
- > failure parks for retry instead of losing a billable event) and **idempotent**
324
- > (on the old route a retried report drew the balance down twice). Because it can
325
- > now answer `202`, this resolves `{ queued, unitUsage, rawId }` rather than a
326
- > bare `{ unitUsage }` — branch on `queued`, don't assume `unitUsage`.
502
+ ```text
503
+ cnk.customers.get / update / delete / iterate
504
+ cnk.customers.listMembers / addMember / updateMember / removeMember
505
+ cnk.customers.wallet / addWalletEntry / usage / plan / balances
327
506
 
328
- ## Webhooks
507
+ cnk.plans.list / get / create / update / setActive / delete
508
+ cnk.purchases.list / get / create / cancel / setAutoPayment
509
+ cnk.invoices.list / get / payLink
510
+ cnk.payments.list / get
511
+ cnk.webhooks.list / upsert / delete
512
+ cnk.portal.createToken
513
+ ```
514
+
515
+ Note: `plans.update()` is a full rewrite and takes the *input* shape, which is
516
+ not the shape `plans.get()` returns. Rebuild the components rather than passing
517
+ the read result straight back.
329
518
 
330
- Threshold alerts — at most one per kind (`CREDIT` / `OUTCOME` / `WALLET`):
519
+ ## Catalogs and workspace discovery
520
+
521
+ This is where `agentKey` and `modelId` come from.
331
522
 
332
523
  ```ts
333
- await cnk.webhooks.upsert({ kind: "WALLET", threshold: 10, url: "https://acme.com/hooks/clocknext" });
334
- await cnk.webhooks.list();
335
- await cnk.webhooks.delete("WALLET");
524
+ const models = await cnk.workspace.models({ active: true });
525
+ const credits = await cnk.credits.list({ active: true });
526
+ const outcomes = await cnk.outcomes.list({ active: true });
527
+ const units = await cnk.units.list({ active: true });
528
+ const composites = await cnk.composites.list({ active: true });
336
529
  ```
337
530
 
338
- ## Customer portal token
531
+ `{ active: true }` returns only active rows, `{ active: false }` only archived
532
+ ones, and omitting it returns both.
533
+
534
+ Creating catalog entries:
339
535
 
340
536
  ```ts
341
- const { token, expiresAt } = await cnk.portal.createToken({ customerId: "cus_123", ttlSeconds: 600 });
342
- // render <iframe src={`https://payments.clocknext.com/portal/embed?token=${token}`} />
537
+ const credit = await cnk.credits.create({
538
+ name: "API credit",
539
+ agentKey: "api_credit", // stable identity; a rename never changes it
540
+ basePrice: 10,
541
+ marginPercent: 20,
542
+ pricePerCredit: 12, // recomputed server-side from basePrice × margin
543
+ tokensPerCredit: 1000,
544
+ });
545
+
546
+ const outcome = await cnk.outcomes.create({
547
+ name: "Resolved ticket",
548
+ agentKey: "resolved_ticket", // the OUTCOME's key
549
+ basePrice: 1,
550
+ marginPercent: 20,
551
+ pricePerOutcome: 1.2,
552
+ steps: [
553
+ { name: "Triage", agentKey: "resolved_ticket.triage", basePrice: 0.5 },
554
+ { name: "Resolve", agentKey: "resolved_ticket.resolve", basePrice: 0.5 },
555
+ ],
556
+ });
557
+
558
+ const unit = await cnk.units.create({
559
+ name: "Seats",
560
+ agentKey: "seats",
561
+ pricingType: "FLAT",
562
+ flatPrice: 5, // price per event; tiered units take `tiers` instead
563
+ });
343
564
  ```
344
565
 
345
- ## Errors
566
+ Signals report against a **step's** `agentKey`, never the outcome's own.
567
+
568
+ Other catalog methods:
569
+
570
+ ```text
571
+ cnk.credits.create / get / update / setActive / delete
572
+ cnk.outcomes.create / get / update / setActive / delete
573
+ cnk.units.create / get / update / setActive / delete
574
+ cnk.composites.list / create
575
+ ```
346
576
 
347
- All failures throw typed errors you can branch on:
577
+ ## Webhooks and customer portal
348
578
 
349
579
  ```ts
350
- import { AllowanceError, AuthError, RateLimitError } from "@clocknext/sdk";
580
+ await cnk.webhooks.upsert({
581
+ kind: "WALLET", // "WALLET" | "CREDIT" | "OUTCOME"
582
+ threshold: 10,
583
+ url: "https://example.com/clocknext/webhook",
584
+ });
585
+ await cnk.webhooks.list();
586
+ await cnk.webhooks.delete("WALLET");
351
587
 
352
- try {
353
- await cnk.signals.credit({ … }, { wait: true });
354
- } catch (err) {
355
- if (err instanceof AllowanceError) {/* upsell */}
356
- else if (err instanceof AuthError) {/* bad key */}
357
- else if (err instanceof RateLimitError) {/* back off */}
358
- }
588
+ const { token, expiresAt } = await cnk.portal.createToken({
589
+ customerId,
590
+ ttlSeconds: 600,
591
+ });
359
592
  ```
360
593
 
361
- `ValidationError (400)` · `AuthError (401)` · `NotFoundError (404)` ·
362
- `PlanError (422)` · `AllowanceError (422)` · `ConflictError (409)` ·
363
- `RateLimitError (429)` · `ServerError (5xx)` · `NetworkError` — all extend
364
- `ClockNextError` (`.status`, `.retryable`).
594
+ Pass the portal token to your frontend and embed it using your portal URL. Never
595
+ pass the API key to the frontend.
365
596
 
366
597
  ## Configuration
367
598
 
368
599
  ```ts
369
- new ClockNext({
370
- apiKey: "cnk_…", // required
371
- baseUrl: "https://payments.clocknext.com",
372
- mode: "async", // "async" | "sync"
600
+ const cnk = new ClockNext({
601
+ apiKey,
602
+ baseUrl: "https://payments.clocknext.com", // optional
603
+ mode: "async", // "async" (default) | "sync"
373
604
  timeoutMs: 10_000,
374
- batch: { maxSize: 20, maxIntervalMs: 2000, maxConcurrency: 5, maxQueueSize: 10_000 },
375
- retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10_000 },
376
- onError: (err, signal) => {},
377
- onFlush: (count) => {},
378
- onRetry: ({ attempt, delayMs }) => {},
605
+ fetch: myFetch, // optional fetch implementation
606
+ logger: { warn: console.warn, debug: console.debug },
607
+ batch: {
608
+ maxSize: 20,
609
+ maxIntervalMs: 2_000,
610
+ maxConcurrency: 5,
611
+ maxQueueSize: 10_000,
612
+ },
613
+ retry: {
614
+ maxAttempts: 5,
615
+ baseDelayMs: 200,
616
+ maxDelayMs: 10_000,
617
+ },
618
+ onError: (error, signal) => {},
379
619
  onDrop: (signal, reason) => {},
620
+ onRetry: ({ attempt, delayMs, error }) => {},
621
+ onFlush: (sentCount) => {},
380
622
  });
381
623
  ```
382
624
 
383
- Numeric options are validated: a negative or non-finite value (e.g. a bad
384
- `timeoutMs`) falls back to its default rather than silently breaking the client.
625
+ With no `onError` supplied the SDK reports failures on `console.error` rather
626
+ than swallowing them. Pass `onError: () => {}` to silence that deliberately.
627
+
628
+ ## Testing and packaging
629
+
630
+ ```bash
631
+ npm run typecheck
632
+ npm test
633
+ npm run build
634
+ ```
635
+
636
+ The package publishes ESM and CommonJS builds with TypeScript declarations, and
637
+ ships its annotated `src/` so source maps resolve and the implementation is
638
+ readable from `node_modules`.
385
639
 
386
640
  ## License
387
641