@clocknext/sdk 0.11.0 → 0.12.1

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,108 @@
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
+ Three things follow.
50
+
51
+ **The per-signal verdict lives in the ClockNext dashboard, and only there.**
52
+ Every signal is logged with its outcome — accepted, processed, or failed with a
53
+ reason — on the workspace's signal log. That view is owner-only in the product
54
+ UI by design and is not exposed through this SDK or any API. When usage is not
55
+ being billed and the causes below do not explain it, that log is where you look.
56
+
57
+ **`onError` does not see worker-side rejections.** It fires when the HTTP
58
+ request itself fails — network, timeout, auth, a `4xx` returned *before* the
59
+ `202`. A signal that was accepted and then rejected during pricing never
60
+ reaches it, because from the SDK's point of view that request succeeded. Pass it
61
+ anyway: it is how you catch a bad API key, an unreachable host, and a dropped
62
+ buffer.
63
+
64
+ **`signals.list()` is the programmatic check, and it is partial.** It returns
65
+ priced usage logs, each with a `status`, so it can tell you a signal landed and
66
+ whether pricing succeeded. It cannot tell you about a signal that was rejected
67
+ *before* it was ever priced — an unknown `agentKey`, a customer with no plan —
68
+ because no usage log is written for one. Those appear only in the dashboard.
69
+
70
+ ```ts
71
+ const usage = await cnk.signals.list({ customerId });
72
+
73
+ // Nothing here at all → rejected before pricing. Check the dashboard's log.
74
+ console.log(usage.totals.count);
75
+
76
+ // Present but not billed → priced and refused; `status` says which.
77
+ for (const log of usage.logs) {
78
+ if (log.status !== "SUCCESS") console.warn(log.id, log.status);
79
+ }
80
+ ```
81
+
82
+ ### 2. Nothing can be metered until five things exist
83
+
84
+ A signal is not self-contained; it references rows that must already be there.
85
+ The full chain, in order:
86
+
87
+ ```
88
+ enabled model → catalog entitlement (credit / outcome / unit, with an agentKey)
89
+ → plan containing that entitlement
90
+ → purchase linking the customer to the plan
91
+ → customer
92
+ ```
93
+
94
+ `customerId`, `model` and `agentKey` are **not strings you can invent**. The
95
+ quick start below discovers each one instead of hard-coding it.
96
+
97
+ ---
98
+
99
+ ## Security: backend only
100
+
101
+ `cnk_...` API keys have organization-wide access. Never expose one in browser
102
+ code, mobile apps, frontend bundles, or public Git repositories.
103
+
104
+ Use `cnk.portal.createToken()` on your backend when a browser needs to embed the
105
+ customer portal. The browser should receive only the short-lived portal token.
10
106
 
11
107
  ## Install
12
108
 
@@ -14,374 +110,563 @@ backend — full coverage of the ClockNext `/api/v1` surface.
14
110
  npm install @clocknext/sdk
15
111
  ```
16
112
 
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).
113
+ ## Quick start — zero to a recorded signal
20
114
 
21
- ## Quick start
115
+ This is a complete, runnable setup for an empty workspace. Every id is read back
116
+ from the API rather than guessed.
22
117
 
23
118
  ```ts
24
119
  import { ClockNext } from "@clocknext/sdk";
25
120
 
26
- const cnk = new ClockNext({ apiKey: process.env.CLOCKNEXT_API_KEY! });
121
+ const apiKey = process.env.CLOCKNEXT_API_KEY;
122
+ if (!apiKey) throw new Error("CLOCKNEXT_API_KEY is required");
123
+
124
+ const cnk = new ClockNext({
125
+ apiKey,
126
+ // Catches request-level failures (bad key, network, dropped buffer).
127
+ // Rejections found later, during pricing, are visible only in the dashboard.
128
+ onError: (error, signal) =>
129
+ console.error("[clocknext] signal rejected", signal?.type, error),
130
+ });
131
+
132
+ // 1. Which workspace is this key for? `live` bills real money.
133
+ const { organization } = await cnk.workspace.me();
134
+ console.log(organization.environment); // "sandbox" | "live"
135
+
136
+ // 2. A model you are allowed to meter against. Use `modelId` VERBATIM.
137
+ const [model] = await cnk.workspace.models({ active: true });
138
+ if (!model) throw new Error("Enable a model in Settings → Models first.");
139
+
140
+ // 3. A credit type. Its `agentKey` is what signals address.
141
+ let [credit] = await cnk.credits.list({ active: true });
142
+ credit ??= await cnk.credits.create({
143
+ name: "API credit",
144
+ agentKey: "api_credit", // stable, lowercase [a-z0-9._-], unique per org
145
+ basePrice: 10,
146
+ marginPercent: 20,
147
+ pricePerCredit: 12, // server recomputes this from basePrice × margin
148
+ tokensPerCredit: 1000,
149
+ });
150
+
151
+ // 4. A customer.
152
+ const customer = await cnk.customers.create({
153
+ name: "Acme",
154
+ email: "billing@acme.com",
155
+ });
156
+
157
+ // 5. A plan that SELLS that credit, and a purchase linking the two.
158
+ // Without this step every signal below is silently discarded.
159
+ const plan = await cnk.plans.create({
160
+ name: "Pro",
161
+ billingCycle: "MONTHLY",
162
+ currencyCode: "USD",
163
+ components: [
164
+ { type: "CREDIT", billingMode: "ADVANCE", creditId: credit.id, quantity: 10_000 },
165
+ ],
166
+ });
167
+ await cnk.purchases.create({ customerId: customer.id, planId: plan.id });
27
168
 
169
+ // 6. Now a signal can land.
28
170
  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 },
171
+ customerId: customer.id,
172
+ model: model.modelId,
173
+ agentKey: credit.agentKey,
174
+ tokens: { input: 1200, output: 340 },
34
175
  custom: { feature: "summarize" },
35
176
  });
36
177
 
37
- // Before a serverless function returns (or on shutdown):
178
+ // 7. Required in async mode before the process can exit.
38
179
  await cnk.flush();
180
+
181
+ // 8. Prove it. Ingestion is asynchronous, so allow a moment.
182
+ const usage = await cnk.signals.list({ customerId: customer.id });
183
+ console.log(usage.totals.count, "signals recorded");
39
184
  ```
40
185
 
41
- ## Recording signals
186
+ A runnable version is in [`examples/node-basic.ts`](./examples/node-basic.ts),
187
+ shipped with the package.
42
188
 
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.
189
+ ## Signals
48
190
 
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.
191
+ Signals are billable usage events. Each kind posts to its own route.
54
192
 
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.
193
+ | SDK method | Meter | Required fields |
194
+ | --- | --- | --- |
195
+ | `signals.credit()` | Credit allowance | `customerId`, `model`, `agentKey`, `tokens` |
196
+ | `signals.wallet()` | Customer wallet | `customerId`, `model`, `tokens` |
197
+ | `signals.outcome()` | Outcome workflow step | `customerId`, `model`, `agentKey`, `runId`, `tokens` |
198
+ | `signals.unit()` | Quantity-based unit | `customerId`, `agentKey` |
59
199
 
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 } });
200
+ `signals.track(signal)` is the underlying call the four convenience methods wrap;
201
+ use it when the kind is dynamic.
63
202
 
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 } });
203
+ ### Where each value comes from
66
204
 
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 } });
205
+ | Field | Source | Notes |
206
+ | --- | --- | --- |
207
+ | `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"`. |
208
+ | `agentKey` (credit) | `credits.list()` → `agentKey` | Case-insensitive. |
209
+ | `agentKey` (outcome) | `outcomes.get(id)` → `steps[].agentKey` | The **step's** key, not the outcome's own `agentKey`. `outcomes.list()` does not return steps. |
210
+ | `agentKey` (unit) | `units.list()` → `agentKey` | Matched **exactly** — keys are stored lowercased. |
211
+ | `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. |
212
+ | `composite.ref` | `composites.list()` → `refId` | An unknown `ref` is ignored, not rejected — the signal still records. |
213
+
214
+ ### Credit and wallet
215
+
216
+ ```ts
217
+ await cnk.signals.credit({
218
+ customerId,
219
+ model: model.modelId,
220
+ agentKey: credit.agentKey,
221
+ tokens: { input: 500, output: 120 },
222
+ });
223
+
224
+ await cnk.signals.wallet({
225
+ customerId,
226
+ model: model.modelId,
227
+ tokens: { input: 500, output: 120 },
228
+ });
69
229
  ```
70
230
 
231
+ Wallet signals take no `agentKey` — they debit money at the model's cost rather
232
+ than drawing on a named entitlement — and cannot be grouped into a composite.
233
+
71
234
  ### Outcome runs
72
235
 
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.
236
+ An outcome is billed once per run, not once per step. Reuse the same `runId` for
237
+ every step and set `complete: true` only on the final one.
78
238
 
79
239
  ```ts
80
240
  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 } });
241
+
242
+ await cnk.signals.outcome({
243
+ customerId,
244
+ model: model.modelId,
245
+ agentKey: "resolved_ticket.triage", // a STEP key
246
+ runId,
247
+ tokens: { input: 600, output: 150 },
248
+ });
249
+
250
+ await cnk.signals.outcome({
251
+ customerId,
252
+ model: model.modelId,
253
+ agentKey: "resolved_ticket.resolve",
254
+ runId,
255
+ complete: true,
256
+ tokens: { input: 500, output: 200 },
257
+ });
86
258
  ```
87
259
 
88
- ### Composites
260
+ A step may repeat within a run — a verify loop firing three times is three
261
+ signals and one outcome. `complete: true` twice is a safe no-op.
89
262
 
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):
263
+ ### Unit usage
264
+
265
+ Units carry no model and no tokens. `quantity` defaults to `1`.
94
266
 
95
267
  ```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
- });
268
+ await cnk.signals.unit({ customerId, agentKey: "seats", quantity: 3 });
101
269
  ```
102
270
 
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:
271
+ `signals.unit()` takes **no `{ wait: true }` option** — unit reports are never
272
+ buffered and are always sent immediately.
273
+
274
+ ### Composites
275
+
276
+ A composite groups credit, outcome, and unit signals into one billable
277
+ occurrence. Tag each signal, then close the occurrence explicitly.
106
278
 
107
279
  ```ts
108
- await cnk.signals.completeComposite({
280
+ const composite = { ref: "voice_ai", value: "call_5f3a" };
281
+
282
+ await cnk.signals.credit({
109
283
  customerId,
110
- composite: { ref: "voice_ai", value: "call_5f3a" },
284
+ model: model.modelId,
285
+ agentKey: credit.agentKey,
286
+ tokens: { input: 100, output: 50 },
287
+ composite,
111
288
  });
289
+
290
+ // Closing is ALWAYS explicit. Put it on the line before your own return.
291
+ await cnk.signals.completeComposite({ customerId, composite });
112
292
  ```
113
293
 
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`.
294
+ **An outcome's `complete: true` does not close a composite occurrence.** It
295
+ closes the run, and nothing else — whatever mix of credits, outcomes and units
296
+ the composite wraps. If you tag outcome signals with a `composite`, you must
297
+ still call `completeComposite()`, or that occurrence stays open and is never
298
+ billed.
118
299
 
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.
300
+ Closing twice is safe. Closing early is safe — the server parks the ping until
301
+ the first tagged signal arrives. Wallet signals do not participate in composites.
122
302
 
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.
303
+ ## Delivery status: `buffered`, `accepted`, `recorded`, `dropped`
127
304
 
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.
305
+ Every metering call resolves with a `status` saying how far the signal actually
306
+ got. Branch on it rather than on the legacy `queued` boolean, which collapses
307
+ the first two states into one.
131
308
 
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.
309
+ | `status` | Meaning | Can it still be lost? |
310
+ | --- | --- | --- |
311
+ | `"buffered"` | In this process's memory, not yet sent (async mode) | **Yes** — flush before the process exits |
312
+ | `"accepted"` | Written to the server's durable inbox (`202`), not yet priced | No, but not billed yet either |
313
+ | `"recorded"` | Priced inline; `usageLog` is populated | No |
314
+ | `"dropped"` | The SDK discarded it — client closed, or local queue full | Already lost; `onDrop` fired |
136
315
 
137
- ### Async vs sync
316
+ The default mode is `async`: the signal is buffered locally and the call returns
317
+ immediately.
138
318
 
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.
319
+ ```ts
320
+ const signal = {
321
+ customerId,
322
+ model: model.modelId,
323
+ agentKey: credit.agentKey,
324
+ tokens: { input: 1200, output: 340 },
325
+ };
143
326
 
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.
327
+ const cnk = new ClockNext({ apiKey }); // async (default)
328
+ await cnk.signals.credit(signal); // -> { status: "buffered" }
329
+ await cnk.signals.credit(signal, { wait: true }); // -> "accepted" | "recorded"
148
330
 
149
- To await the request itself (rather than buffering it), either:
331
+ const sync = new ClockNext({ apiKey, mode: "sync" }); // every signal awaits
332
+ ```
150
333
 
151
- ```ts
152
- // per call
153
- const res = await cnk.signals.credit({ … }, { wait: true });
334
+ `{ wait: true }` and `mode: "sync"` wait for the server to **accept** the
335
+ signal, never for it to finish pricing. No mode of this SDK blocks a request
336
+ thread on a billing result.
154
337
 
155
- // or globally
156
- const cnk = new ClockNext({ apiKey, mode: "sync" });
157
- ```
338
+ `cnk.pending` reports how many signals are currently buffered.
339
+
340
+ ### Serverless and shutdown
158
341
 
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 confirm a signal landed, read it back with `signals.list({ customerId })`.
167
- > There is no way to price a signal without recording it — send one against a
168
- > **sandbox** organisation instead.
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
342
+ The local queue is in memory, so it must be drained before the process can
343
+ freeze or exit.
198
344
 
199
345
  ```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" });
346
+ // Serverless handler — flush, but do NOT close: the container is reused.
347
+ try {
348
+ // application work and signal tracking
349
+ } finally {
350
+ await cnk.flush();
351
+ }
352
+
353
+ // Long-running process, on shutdown only:
354
+ await cnk.close();
214
355
  ```
215
356
 
216
- ### Members
357
+ Construct the client **once at module scope**, not per request. After `close()`
358
+ the client is permanently shut: further signals resolve `{ status: "dropped" }`
359
+ and fire `onDrop` with reason `"closed"`. Never call `close()` in a serverless
360
+ handler.
361
+
362
+ If losing a buffered signal is unacceptable, use `mode: "sync"` or
363
+ `{ wait: true }`, and supply your own stable `idempotencyKey`.
364
+
365
+ ## Idempotency and retries
366
+
367
+ Metering calls get an idempotency key automatically and reuse it across every
368
+ retry, so a retried send is deduplicated server-side instead of double-billing.
369
+ Supply your own stable key to also deduplicate across process restarts and
370
+ at-least-once redelivery:
217
371
 
218
372
  ```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);
373
+ await cnk.signals.credit({
374
+ customerId,
375
+ model: model.modelId,
376
+ agentKey: credit.agentKey,
377
+ tokens: { input: 10, output: 5 },
378
+ idempotencyKey: `llm-request:${requestId}`,
379
+ });
223
380
  ```
224
381
 
225
- ### Wallet & billing config
382
+ When a signal fails permanently, the key it was sent with is available on both
383
+ the error (`error.idempotencyKey`) and the signal handed to `onError`/`onDrop`.
384
+ **Replay with that key** — generating a new one bills the event twice.
226
385
 
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)
386
+ Retries apply to network failures, timeouts, `408`, `429`, `5xx`, and the one
387
+ transient `409`. Validation, authentication, not-found, plan, allowance and
388
+ permanent-conflict errors are never retried. Reads and `DELETE`s are retried;
389
+ `POST`/`PATCH` are not, except metering calls, which opt in because they are
390
+ idempotency-keyed.
231
391
 
232
- // billing config — currency is set through update()
233
- await cnk.customers.update(c.id, { currencyCode: "EUR" });
392
+ Client-side batching is timing and concurrency management, not a bulk API — each
393
+ signal is still an individual HTTP request.
234
394
 
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
- ```
395
+ ## Handling errors
240
396
 
241
- ## Plans & purchases
397
+ Branch on error classes, never on message text.
242
398
 
243
399
  ```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);
400
+ import {
401
+ ClockNextError,
402
+ AllowanceError,
403
+ AuthError,
404
+ ConflictError,
405
+ ForbiddenError,
406
+ NetworkError,
407
+ NotFoundError,
408
+ PlanError,
409
+ RateLimitError,
410
+ ServerError,
411
+ ValidationError,
412
+ } from "@clocknext/sdk";
256
413
 
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);
414
+ try {
415
+ await cnk.customers.get(customerId);
416
+ } catch (error) {
417
+ if (error instanceof NotFoundError) {
418
+ // No such customer in this workspace.
419
+ } else if (error instanceof AuthError) {
420
+ // Rotate or correct the API key.
421
+ } else if (error instanceof ValidationError) {
422
+ // Fix the request shape or required fields.
423
+ } else if (error instanceof RateLimitError || error instanceof ServerError) {
424
+ // Already retried by the SDK; add an application-level fallback.
425
+ } else if (error instanceof ClockNextError) {
426
+ console.error(error.status, error.code, error.message);
427
+ }
428
+ }
261
429
  ```
262
430
 
263
- ## Invoices & payments (read-only)
431
+ | Class | Status | Meaning |
432
+ | --- | --- | --- |
433
+ | `ValidationError` | 400 | Malformed request or failed schema validation |
434
+ | `AuthError` | 401 | Missing, invalid or expired API key |
435
+ | `ForbiddenError` | 403 | Key is valid but not permitted to do this |
436
+ | `NotFoundError` | 404 | Unknown resource (also returned for cross-org access) |
437
+ | `ConflictError` | 409 | Conflict — usually an archived customer. Permanent, and not retried |
438
+ | `PlanError` | 422 | No active plan, meter not in plan, model not enabled, member missing |
439
+ | `AllowanceError` | 422 | The entitlement ran out — top up or upsell |
440
+ | `RateLimitError` | 429 | Rate limited; carries `retryAfterMs` |
441
+ | `ServerError` | 5xx | Server-side failure |
442
+ | `NetworkError` | — | fetch threw or the request timed out |
443
+
444
+ All extend `ClockNextError`, which exposes `.status` (`number | undefined`),
445
+ `.retryable`, `.code` (the server's machine-readable code, when it sends one),
446
+ and `.idempotencyKey` on metering calls. Constructor failures — a missing
447
+ `apiKey`, no available `fetch` — throw `ValidationError`, so a single
448
+ `instanceof ClockNextError` catches setup problems too.
449
+
450
+ ### Signal failures do not arrive here
451
+
452
+ Because ingest answers `202` before validating, **a try/catch around
453
+ `signals.credit()` will not catch a rejected signal**, with or without
454
+ `{ wait: true }` — and neither will `onError`. A rejection discovered during
455
+ pricing has no programmatic channel at all; it is recorded on the workspace's
456
+ signal log in the ClockNext dashboard, which is owner-only by design.
457
+
458
+ What the hooks below DO cover is everything that fails before the `202`: an
459
+ invalid API key, an unreachable host, a timeout, and signals the SDK dropped
460
+ without sending.
264
461
 
265
462
  ```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
463
+ import { ClockNext, ClockNextError } from "@clocknext/sdk";
464
+
465
+ const cnk = new ClockNext({
466
+ apiKey,
467
+ onError: (error, signal) => {
468
+ // `error` is `unknown`; narrow it.
469
+ if (error instanceof ClockNextError) {
470
+ logger.error({ status: error.status, key: error.idempotencyKey }, error.message);
471
+ }
472
+ deadLetter.push(signal); // replay later with signal.idempotencyKey
473
+ },
474
+ onDrop: (signal, reason) => logger.error({ signal, reason }, "signal dropped"),
475
+ onRetry: ({ attempt, delayMs, error }) => logger.warn({ attempt, delayMs, error }),
476
+ onFlush: (sentCount) => logger.info({ sentCount }, "signals flushed"),
477
+ });
272
478
  ```
273
479
 
274
- ## Catalogs: credits, outcomes, units
480
+ `onFlush` counts signals the server **accepted**, not signals it billed.
275
481
 
276
- Define the billable *types*; record usage against them with `signals.*`.
482
+ So the division of labour is: these hooks tell you your integration cannot
483
+ reach ClockNext; `signals.list()` tells you what got priced; the dashboard's
484
+ signal log tells you why an individual signal did not.
277
485
 
278
- ```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
- });
486
+ ## Balances and usage
292
487
 
293
- // unit type — a quantity-priced meter (FLAT / SLAB / VOLUME)
294
- await cnk.units.create({ name: "Seats", agentKey: "seats", pricingType: "FLAT", flatPrice: 5 });
488
+ ```ts
489
+ const balances = await cnk.customers.balances(customerId);
490
+ balances.wallet?.balanceUsd;
491
+ balances.credits; // granted, used, remaining
492
+ balances.outcomes; // granted, remaining, steps
493
+ balances.units; // granted, remaining
494
+
495
+ // Convenience lookups — these fetch the full snapshot, so prefer `balances()`
496
+ // when you need more than one row from the same customer.
497
+ await cnk.customers.creditBalance(customerId, { creditName: "API credit" });
498
+ await cnk.customers.outcomeBalance(customerId, { outcomeName: "Resolved ticket" });
499
+ await cnk.customers.unitBalance(customerId, { unitName: "Seats" });
500
+
501
+ // Usage history, and unit balances + recent unit events.
502
+ await cnk.signals.list({ customerId, limit: 50 });
503
+ await cnk.signals.unitUsage({ customerId, limit: 50 });
295
504
  ```
296
505
 
297
- ### Unit metering
506
+ Adjustments use stable agent keys, not display names:
298
507
 
299
- Record a unit event (no tokens, no model) and read a customer's unit balances:
508
+ ```ts
509
+ await cnk.customers.adjustCredit(customerId, { agentKey: "api_credit", delta: 100 });
510
+ await cnk.customers.adjustOutcome(customerId, { agentKey: "resolved_ticket.triage", delta: 1 });
511
+ await cnk.customers.adjustUnit(customerId, { agentKey: "seats", delta: 5 });
512
+ ```
513
+
514
+ ## Customers, plans, and billing
300
515
 
301
516
  ```ts
302
- await cnk.signals.unit({ customerId: c.id, agentKey: "seats" });
517
+ const customer = await cnk.customers.create({ name: "Acme", email: "billing@acme.com" });
518
+ await cnk.customers.update(customer.id, { currencyCode: "USD" });
519
+ await cnk.customers.list({ limit: 50, q: "acme" });
303
520
 
304
- // several at once, instead of N separate calls
305
- await cnk.signals.unit({ customerId: c.id, agentKey: "seats", quantity: 3 });
521
+ for await (const c of cnk.customers.iterate()) {
522
+ // follows the cursor for you
523
+ }
306
524
 
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}`,
525
+ const purchase = await cnk.purchases.create({ customerId: customer.id, planId: plan.id });
526
+ await cnk.purchases.setAutoPayment(purchase.id, false, {
527
+ pauseReason: "Customer requested manual payment",
312
528
  });
313
-
314
- await cnk.signals.unitUsage({ customerId: c.id });
315
529
  ```
316
530
 
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.
531
+ Additional resource methods:
532
+
533
+ ```text
534
+ cnk.customers.get / update / delete / iterate
535
+ cnk.customers.listMembers / addMember / updateMember / removeMember
536
+ cnk.customers.wallet / addWalletEntry / usage / plan / balances
537
+
538
+ cnk.plans.list / get / create / update / setActive / delete
539
+ cnk.purchases.list / get / create / cancel / setAutoPayment
540
+ cnk.invoices.list / get / payLink
541
+ cnk.payments.list / get
542
+ cnk.webhooks.list / upsert / delete
543
+ cnk.portal.createToken
544
+ ```
320
545
 
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`.
546
+ Note: `plans.update()` is a full rewrite and takes the *input* shape, which is
547
+ not the shape `plans.get()` returns. Rebuild the components rather than passing
548
+ the read result straight back.
327
549
 
328
- ## Webhooks
550
+ ## Catalogs and workspace discovery
329
551
 
330
- Threshold alerts — at most one per kind (`CREDIT` / `OUTCOME` / `WALLET`):
552
+ This is where `agentKey` and `modelId` come from.
331
553
 
332
554
  ```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");
555
+ const models = await cnk.workspace.models({ active: true });
556
+ const credits = await cnk.credits.list({ active: true });
557
+ const outcomes = await cnk.outcomes.list({ active: true });
558
+ const units = await cnk.units.list({ active: true });
559
+ const composites = await cnk.composites.list({ active: true });
336
560
  ```
337
561
 
338
- ## Customer portal token
562
+ `{ active: true }` returns only active rows, `{ active: false }` only archived
563
+ ones, and omitting it returns both.
564
+
565
+ Creating catalog entries:
339
566
 
340
567
  ```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}`} />
568
+ const credit = await cnk.credits.create({
569
+ name: "API credit",
570
+ agentKey: "api_credit", // stable identity; a rename never changes it
571
+ basePrice: 10,
572
+ marginPercent: 20,
573
+ pricePerCredit: 12, // recomputed server-side from basePrice × margin
574
+ tokensPerCredit: 1000,
575
+ });
576
+
577
+ const outcome = await cnk.outcomes.create({
578
+ name: "Resolved ticket",
579
+ agentKey: "resolved_ticket", // the OUTCOME's key
580
+ basePrice: 1,
581
+ marginPercent: 20,
582
+ pricePerOutcome: 1.2,
583
+ steps: [
584
+ { name: "Triage", agentKey: "resolved_ticket.triage", basePrice: 0.5 },
585
+ { name: "Resolve", agentKey: "resolved_ticket.resolve", basePrice: 0.5 },
586
+ ],
587
+ });
588
+
589
+ const unit = await cnk.units.create({
590
+ name: "Seats",
591
+ agentKey: "seats",
592
+ pricingType: "FLAT",
593
+ flatPrice: 5, // price per event; tiered units take `tiers` instead
594
+ });
343
595
  ```
344
596
 
345
- ## Errors
597
+ Signals report against a **step's** `agentKey`, never the outcome's own.
598
+
599
+ Other catalog methods:
600
+
601
+ ```text
602
+ cnk.credits.create / get / update / setActive / delete
603
+ cnk.outcomes.create / get / update / setActive / delete
604
+ cnk.units.create / get / update / setActive / delete
605
+ cnk.composites.list / create
606
+ ```
346
607
 
347
- All failures throw typed errors you can branch on:
608
+ ## Webhooks and customer portal
348
609
 
349
610
  ```ts
350
- import { AllowanceError, AuthError, RateLimitError } from "@clocknext/sdk";
611
+ await cnk.webhooks.upsert({
612
+ kind: "WALLET", // "WALLET" | "CREDIT" | "OUTCOME"
613
+ threshold: 10,
614
+ url: "https://example.com/clocknext/webhook",
615
+ });
616
+ await cnk.webhooks.list();
617
+ await cnk.webhooks.delete("WALLET");
351
618
 
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
- }
619
+ const { token, expiresAt } = await cnk.portal.createToken({
620
+ customerId,
621
+ ttlSeconds: 600,
622
+ });
359
623
  ```
360
624
 
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`).
625
+ Pass the portal token to your frontend and embed it using your portal URL. Never
626
+ pass the API key to the frontend.
365
627
 
366
628
  ## Configuration
367
629
 
368
630
  ```ts
369
- new ClockNext({
370
- apiKey: "cnk_…", // required
371
- baseUrl: "https://payments.clocknext.com",
372
- mode: "async", // "async" | "sync"
631
+ const cnk = new ClockNext({
632
+ apiKey,
633
+ baseUrl: "https://payments.clocknext.com", // optional
634
+ mode: "async", // "async" (default) | "sync"
373
635
  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 }) => {},
636
+ fetch: myFetch, // optional fetch implementation
637
+ logger: { warn: console.warn, debug: console.debug },
638
+ batch: {
639
+ maxSize: 20,
640
+ maxIntervalMs: 2_000,
641
+ maxConcurrency: 5,
642
+ maxQueueSize: 10_000,
643
+ },
644
+ retry: {
645
+ maxAttempts: 5,
646
+ baseDelayMs: 200,
647
+ maxDelayMs: 10_000,
648
+ },
649
+ onError: (error, signal) => {},
379
650
  onDrop: (signal, reason) => {},
651
+ onRetry: ({ attempt, delayMs, error }) => {},
652
+ onFlush: (sentCount) => {},
380
653
  });
381
654
  ```
382
655
 
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.
656
+ With no `onError` supplied the SDK reports failures on `console.error` rather
657
+ than swallowing them. Pass `onError: () => {}` to silence that deliberately.
658
+
659
+ ## Testing and packaging
660
+
661
+ ```bash
662
+ npm run typecheck
663
+ npm test
664
+ npm run build
665
+ ```
666
+
667
+ The package publishes ESM and CommonJS builds with TypeScript declarations, and
668
+ ships its annotated `src/` so source maps resolve and the implementation is
669
+ readable from `node_modules`.
385
670
 
386
671
  ## License
387
672