@clocknext/sdk 0.11.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 +518 -264
- package/dist/index.cjs +257 -116
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +219 -43
- package/dist/index.d.ts +219 -43
- package/dist/index.js +257 -117
- package/dist/index.js.map +1 -1
- package/examples/node-basic.ts +93 -0
- package/examples/serverless-flush.ts +47 -0
- package/package.json +3 -1
- package/src/client.ts +107 -0
- package/src/config.ts +112 -0
- package/src/errors.ts +215 -0
- package/src/filters.ts +33 -0
- package/src/flusher.ts +144 -0
- package/src/http.ts +178 -0
- package/src/idempotency.ts +33 -0
- package/src/index.ts +165 -0
- package/src/queue.ts +48 -0
- package/src/resources/composites.ts +47 -0
- package/src/resources/credits.ts +76 -0
- package/src/resources/customers.ts +298 -0
- package/src/resources/invoices.ts +51 -0
- package/src/resources/outcomes.ts +82 -0
- package/src/resources/payments.ts +30 -0
- package/src/resources/plans.ts +76 -0
- package/src/resources/portal.ts +31 -0
- package/src/resources/purchases.ts +75 -0
- package/src/resources/signals.ts +386 -0
- package/src/resources/units.ts +76 -0
- package/src/resources/webhooks.ts +37 -0
- package/src/resources/workspace.ts +33 -0
- package/src/retry.ts +56 -0
- package/src/types.ts +1599 -0
package/README.md
CHANGED
|
@@ -1,12 +1,88 @@
|
|
|
1
1
|
# @clocknext/sdk
|
|
2
2
|
|
|
3
|
-
Official
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
30
|
-
model:
|
|
31
|
-
agentKey:
|
|
32
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
165
|
+
A runnable version is in [`examples/node-basic.ts`](./examples/node-basic.ts),
|
|
166
|
+
shipped with the package.
|
|
42
167
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
await cnk.signals.outcome({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
259
|
+
const composite = { ref: "voice_ai", value: "call_5f3a" };
|
|
260
|
+
|
|
261
|
+
await cnk.signals.credit({
|
|
109
262
|
customerId,
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
295
|
+
The default mode is `async`: the signal is buffered locally and the call returns
|
|
296
|
+
immediately.
|
|
138
297
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
310
|
+
const sync = new ClockNext({ apiKey, mode: "sync" }); // every signal awaits
|
|
311
|
+
```
|
|
150
312
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
317
|
+
`cnk.pending` reports how many signals are currently buffered.
|
|
318
|
+
|
|
319
|
+
### Serverless and shutdown
|
|
158
320
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await cnk.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
+
Branch on error classes, never on message text.
|
|
242
377
|
|
|
243
378
|
```ts
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
453
|
+
`onFlush` counts signals the server **accepted**, not signals it billed.
|
|
275
454
|
|
|
276
|
-
|
|
455
|
+
## Balances and usage
|
|
277
456
|
|
|
278
457
|
```ts
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
475
|
+
Adjustments use stable agent keys, not display names:
|
|
298
476
|
|
|
299
|
-
|
|
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.
|
|
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
|
-
|
|
305
|
-
|
|
490
|
+
for await (const c of cnk.customers.iterate()) {
|
|
491
|
+
// follows the cursor for you
|
|
492
|
+
}
|
|
306
493
|
|
|
307
|
-
|
|
308
|
-
await cnk.
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
+
## Catalogs and workspace discovery
|
|
520
|
+
|
|
521
|
+
This is where `agentKey` and `modelId` come from.
|
|
331
522
|
|
|
332
523
|
```ts
|
|
333
|
-
await cnk.
|
|
334
|
-
await cnk.
|
|
335
|
-
await cnk.
|
|
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
|
-
|
|
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
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
+
## Webhooks and customer portal
|
|
348
578
|
|
|
349
579
|
```ts
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
371
|
-
baseUrl: "https://payments.clocknext.com",
|
|
372
|
-
mode: "async",
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
|