@curviate/sdk 0.44.0 → 0.45.0-next.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/CHANGELOG.md CHANGED
@@ -7,6 +7,378 @@ Versioning: semantic. Minor for additive changes, patch for bug fixes; no stabil
7
7
 
8
8
  ---
9
9
 
10
+ ## Unreleased
11
+
12
+ ### Account orchestration (regenerated from staging 1635e59)
13
+
14
+ - **Motion `settings.priority`** (`normal` | `prioritized`, default `normal`): the Motion's weighted share (1 : 2) of each sending account's daily allowance on `motions.create` / `motions.update`.
15
+ - **Account `health`** `{ state, factor, reasons, until }` (`healthy` | `watch` | `slowed` | `stopped`) on `accounts.get` and `accounts.update`. Absent on an account you can see but not operate.
16
+ - **`target` and `effective_target`** on the strict rows (invitations, `inmail`, `profile_views`) of `quotas[]` and the safety policy; `null` elsewhere. Read-only. `green` on those rows is now the target.
17
+ - **`auto_withdraw`** (boolean, default `true`) on the safety policy, read and written: old Motion-sent invitations are withdrawn once more than 400 are pending.
18
+ - **`global.jitter_seconds`** in the Motion pacing settings.
19
+ - **`waiting.kind: "budget"`** on an Enrollment whose allowance is spent or whose share is not yet its turn; `until` is the next session.
20
+ - **Preview projection:** `accounts[].expected_actions_per_day`, the binding pool in `binding`, and `warnings[]` (`hours_below_share`, `no_sending_account`). `daily_cap` is gone.
21
+ - **Same shape, new meaning:** `quotas[]` row `connection_requests_no_note` counts **every** invitation (noted or not), so its `used` is the invitation total; a noted one also counts on `connection_requests_with_note` where that row has a ceiling (Free, 3 a month; paid profiles none). A row's `green` is its `target`.
22
+ - **Same shape, new meaning:** `account_id` on an Enrollment is `null` while it waits unassigned in its Motion's pool; the `account_id` list filter leaves such Enrollments out.
23
+ - **Retired, accepted and ignored until 2027-01-28:** `settings.new_enrollments_per_account_per_day` and `settings.assign: "even"` (`SETTING_REMOVED` in `notices[]`, `Deprecation: true`). Never returned; the response `assign` enum is `auto` | `already_connected`. The request types still carry both until the cut.
24
+ - **Changed text:** the InMail credit hint no longer says credits can be bought; it tells you to wait for LinkedIn to regrant them (a reply to an InMail refunds its credit).
25
+ - **Blind spot, documented:** invitations and messages sent by hand on LinkedIn, or by another automation tool on the same account, are invisible to Curviate and to every limit. Do not run another automation tool on a connected account.
26
+
27
+ ### Save Connections as Contacts
28
+
29
+ - The account (`accounts.get`, `accounts.update`'s result) gains `save_connections_as_contacts` and `save_connections_paused_reason` (`"contact_limit_reached"` | `null`); both are absent on an account without Automate.
30
+ - `accounts.update` takes `save_connections_as_contacts`. Without Automate it throws `TIER_REQUIRED`.
31
+ - The generated types carry both fields (regenerated from the deployed document, staging 21b2531).
32
+
33
+ ### Staging regen: Contact fields, roles, filters
34
+
35
+ - **Contact `company_linkedin_url`** (`string | null`, read-only): the current company's LinkedIn page, filled with `company` from a profile read. Served on every Contact read and export.
36
+ - **Contact `email`** reads as what you supplied, else the first email a LinkedIn profile read returned (yours is never overwritten). Description only; webhook payloads now deliver fields they stripped before (no schema change).
37
+ - **`contacts.list` / `contacts.export` `account_id`** takes up to 50 account ids (an array or a comma list; one id is unchanged). The bulk `filter` body takes a string or an array.
38
+ - **`person_name`** (`string | null`) on `analytics.events.list` items and the `accounts.overview` activity items: the person of a direct action, name only.
39
+ - **Same shape, new meaning:** `teams.listMembers` `name` falls back to the email (`null` only when neither is known). Managers now rename the team, set or remove its logo, manage Member invites, remove Members and change the Team safety policy (JSDoc and the `INSUFFICIENT_ROLE` text).
40
+ - **Same shape, new text:** `photo_url` is a stored copy refreshed daily, so it does not expire. The seeded active hours fall back to Monday to Friday 07:00 to 22:00.
41
+
42
+ ### Served schema fixes (regenerated types)
43
+
44
+ - `GET /v1/billing/seats` now has a typed 200 response: each seat carries `lent_to` / `lent_from` (`{team_id, team_name}` or null).
45
+ - `Error.errors[]` items gain `line` and `col` on `WEBHOOK_TEMPLATE_INVALID`; their `severity` is `error` only on a refusal (warnings come back in `warnings[]` on success).
46
+ - The safety-policy write fields `timezone` and `activity_window_timezone` are typed `null`: any other value is a `400`.
47
+ - `DO_NOT_CONTACT` (409) and `SEAT_LENT` (409) are declared on the routes that answer them.
48
+
49
+ ### Webhooks v2, incoming events, Enrollment values, the surface header
50
+
51
+ #### Changed (same shape, new meaning)
52
+
53
+ - **A delivery's `id` is the delivery id**, the same on every attempt and every redelivery (it changed per attempt before). It is now the key to de-duplicate retries on; the old `event` + `account_id` + `occurred_at` composite still works. `delivered_at` is the time the body was rendered, the same on every attempt (it was the attempt's time); the signature's `t=` stays the attempt's time.
54
+ - **`request_url` reads back masked** (`https://host/•••`): the path and query are never returned. Sending the masked value back on `webhooks.update` keeps the stored URL. New `request_url_fingerprint` changes when the URL does.
55
+ - **A `headers` entry with a key and no value keeps that header's stored value** on `webhooks.update`; a new key needs a value.
56
+ - **`emit` steps are first-class again** ("Send event"): `step.emit` is no longer described as legacy. `event` on a `do` or `tag` step stays.
57
+ - **`constructEvent` accepts any matching `v1`.** For 24 hours after a secret rotation `Curviate-Signature` carries two (`v1=<new>,v1=<old>`); either secret verifies, any other is still `invalid_signature`. Older SDKs check only the last `v1`: upgrade before you rotate.
58
+ - **Every request sends `Curviate-Client: sdk/<version>`**, once. Informational only (it shows which tools use a key). The new `client` option replaces the value, for a tool built on the SDK.
59
+
60
+ #### Added
61
+
62
+ - **Webhook deliveries:** `webhooks.deliveries.list(id, { failed, cursor, limit })` (last 30 days, newest first; `for await` pages) and `webhooks.deliveries.get(id, deliveryId)`, metadata only. `webhooks.redeliver(id, deliveryId)` sends the original bytes again, freshly signed, within `redeliverable_until` (receipt + 24 h).
63
+ - **`webhooks.rotateSecret(id)`:** a new secret, returned once, with `previous_valid_until`. Creator only.
64
+ - **`webhooks.test(id, { event })`:** a synthetic delivery for one subscribed event (default: the first), answering `delivery_id` and `test_id`.
65
+ - **Webhook fields:** `disabled_reason`, `failing_since`, `last_delivery_status_code`, `created_by`, `request_url_fingerprint`, `body_template`, `custom_event_names`, and `warnings` on an edit that sets a template.
66
+ - **`webhooks.variables({ source })`:** the variables a body template of that source may use; on `motion`, also the Enrollment values your Sequences save.
67
+ - **`validateWebhookTemplate(template, { source })` and `renderWebhookTemplate(template, context)`:** the API's own template lint and renderer, offline. `errors` equal what `WEBHOOK_TEMPLATE_INVALID` lists, each with `line` and `col`.
68
+ - **`incomingEvents`:** `create`, `list`, `get`, `update`, `delete`, `rotate`, `capture`, `arrivals`. A private URL your tools post JSON to, moving Enrollments waiting on `until event.<name>`.
69
+ - **`tenant.beta.get()` and `tenant.beta.set({ beta_enabled })`:** the Team's persisted beta consent (`set`: Owners only).
70
+ - **Sequences:** `save` (and `save_to_contact`) on `until event.<name>` waits, typed on `step.until` and the builder, number gates (`gte`, `lte`) on `enrollment.<name>` predicates, rules R25 to R28 and warnings W14 and W16 in `validateSequence`; every entry carries `severity`, and `candidates` for a did-you-mean. R29 is the API's only. The validator now runs the API's own rule code.
71
+ - **Enrollment `values`** on `enrollments.get`: what the Sequence's `save` maps took, empty once the Enrollment finishes.
72
+ - **Error codes:** `NOTHING_TO_REDELIVER` (409), `REDELIVERY_EXPIRED` (410), `WEBHOOK_DISABLED` (409), `DELIVERY_IN_FLIGHT` (409, retryable), `WEBHOOK_TEMPLATE_INVALID` (422), `INCOMING_URL_RETIRED` (410). Earlier SDKs decode them as `INTERNAL`. `errors[]` entries type `severity`, `candidates`, `line` and `col`.
73
+
74
+ ### Unibox v2
75
+
76
+ - **Same shape, new behaviour, `listMessages`:** for an API key or MCP grant, `quoted` is omitted when the quoted message is from before a Motion chat's visible start.
77
+
78
+ #### Added
79
+
80
+ - **Message `origin` and `is_deleted`:** a message the connected account sent carries `origin` (`motion`, `unibox`, `api` or `linkedin`). It is omitted when unknown, absent on a received message and never on a chat's `last_message`. `is_deleted` is `true` for a deleted message.
81
+ - **`marked_unread`** on a Unibox chat: `{origin: "unibox" | "api" | "linkedin", at}` when the chat was made unread without a newer message from the other person, else `null`.
82
+ - **`unibox.list` options:** `person` (one Contact id or LinkedIn id), `motion_ids` also takes `"none"` (chats no Motion links), `include_total: true` adds `total` (the chats matching the filters, not the page).
83
+ - **Disconnected Accounts** appear in `unibox.accounts.list()` and `unibox.list()` (`account_status: "disconnected"`), read-only. Their messages come from the stored copy: `coverage.complete` and attachment `unavailable`. A write on one throws `REAUTH_REQUIRED` (409).
84
+ - **Webhook payload types:** `chat.updated` is `ChatUpdatedPayload` (always `chat_id`; `unread` and `origin` on a change of read state). `message.read`, `message.reaction`, `message.edited` and `message.deleted` are `OwnMessagePayload` (optional `origin`); `message.received` never carries `origin`. New exports `ChatUpdatedPayload`, `OwnMessagePayload`, `MessageOrigin`.
85
+ - **`analytics.events.list`:** `contact_id` keeps one person's events (`RESOURCE_NOT_FOUND` for an unknown, other-team or merged-away id); `origin` takes a comma list or an array.
86
+ - **Analytics `origin`** is `motion`, `unibox` or `api`; `direct` stays valid as an alias for `unibox` and `api`.
87
+
88
+ #### Changed
89
+
90
+ - **`unibox.refresh` cooldown is 2 minutes** (was 5). It also covers accounts with Motion chats or Motion outreach but no full-inbox sync (`not_synced` only for an account with none of those), and `sync.next_run_at` for a `complete` account is the next poll tick. Same shape, new meaning.
91
+ - **A send marks the chat read.** `startChat`, `sendMessage` and `sendInMail` through the API turn the chat's `unread` to `false` and clear `marked_unread`, and `chat.updated` reports it. A Motion send leaves the read state as it is.
92
+ - The `unibox.refresh` JSDoc no longer says "5 minutes".
93
+
94
+ ### Daily counts and the raw reach
95
+
96
+ - **Analytics windows reach 3650 days** on `analytics.motions.list` / `get` and `accounts.overview` (a longer one is `400 INVALID_REQUEST` naming `from`; the old 365-day cap is gone). Beyond 12 months you get daily counts only. New `window.counts_only_through` and `window.paths_through` (`YYYY-MM-DD | null`): past `paths_through`, `paths` does not add up to the totals. `accounts.metrics` reads up to 3650 days.
97
+ - **`enrollments.get`:** new `history_truncated` (boolean). `history` reaches back 12 months, plus what `retry` reads.
98
+ - **Activity exports:** a window past the 12-month raw reach is `400 INVALID_REQUEST` naming `from`.
99
+
100
+ ### Settings v2
101
+
102
+ #### Breaking
103
+
104
+ - **Limits are always enforced.** The warn posture is gone: `posture`,
105
+ `posture_source` and `tenant_default_posture` leave every read (the
106
+ safety policy and the `quotas[]` rows; on the policy write they remain
107
+ only as removed keys), and no success body carries a
108
+ `safety_warning` any more (`PostPublishedPayload.safety_warning` removed).
109
+ At a ceiling the action is refused with `BUDGET_EXHAUSTED`; to send more,
110
+ raise the limit. A policy write that still sends `posture` or
111
+ `tenant_default_posture` is accepted with a `POSTURE_REMOVED` notice until
112
+ 2027-01-25 when it says `"enforce"` or `null`; `"warn"`, or any value after
113
+ that day, is `400 INVALID_REQUEST` naming the key in `details.removed_key`.
114
+ - **`hint.parameter` on an `activity_window` refusal** is now `active_hours`,
115
+ `rows[<row>].activity_window_start` / `_end`, or on the Team policy
116
+ `profiles[<profile>].rows[<row>].activity_window_start` / `_end`. It was
117
+ the bare `activity_window_start` / `activity_window_end`.
118
+
119
+ #### Added
120
+
121
+ - **`err.details`** on `CurviateError` (type `CurviateErrorDetails`): the
122
+ error body's per-code details, `removed_key` on the posture refusal among
123
+ them.
124
+ - **`active_hours`** at the top level of the account and Team safety policy:
125
+ `{days, start, end, source}` or `null`, where `source` (`account`, `team`
126
+ or `default`) is read-only. Write `{days, start, end}` or `null`.
127
+ - **`activity_window_source`** on every policy row, both scopes, read-only:
128
+ `account_row`, `account`, `team_row`, `team` or `default`.
129
+
130
+ #### Changed
131
+
132
+ - **`activity_window_start` and `activity_window_end`** on a policy row now
133
+ report the effective edges after the chain (row, account, Team, default).
134
+ Same shape, wider meaning: a row with no edges of its own reads the edges
135
+ it inherits.
136
+ - **`blocked`** on `CurviateError` is always `true` on a `BUDGET_EXHAUSTED`;
137
+ there is no `blocked: false` success payload.
138
+
139
+ ### Account safety
140
+
141
+ #### Breaking
142
+
143
+ - **`activity_window`** on `accounts.get` (and the safety policy read)
144
+ loses `timezone` and `timezone_unset`. `effective_timezone` is
145
+ `string | null` (null while the zone is unknown); `source` is `country`,
146
+ `region`, `detected` or `none`; new `zone_state` (`known`, `pinned`,
147
+ `unknown`) and `fallback_timezones` (the candidate zones while unknown;
148
+ empty means the window is closed). `network.syncStatus()`'s `read_window`
149
+ carries the same four fields in place of `timezone` and `source`. The zone
150
+ follows where the account connects from: nobody sets it.
151
+ - **A time zone on the safety policy is refused.** The policy write's
152
+ `timezone` and a row's `activity_window_timezone` are `400 INVALID_REQUEST`
153
+ unless `null` (accepted and ignored, so an older document round-trips).
154
+ Change the account's `country` or own `proxy` instead.
155
+ - **`warm_up`**: `triggers` is now `connect`, `dormant` or
156
+ `post_restriction` (`account_age` and `connections` removed);
157
+ `unevaluated` removed. `state: "ramping"` keeps its shape and now follows
158
+ those triggers.
159
+
160
+ #### Added
161
+
162
+ - **`warm_up.enabled`** (default `true`, set with
163
+ `PATCH /v1/{account_id}/safety-policy` and `warm_up: { enabled }`; a
164
+ Team-scope `warm_up` is a 400) and **`warm_up.dormant`** on the account and
165
+ the policy read.
166
+ - **`accounts.getSafetyPolicy(accountId)` and
167
+ `accounts.updateSafetyPolicy(accountId, body)`**
168
+ (`GET` / `PATCH /v1/{account_id}/safety-policy`), typed from the generated
169
+ policy types. `updateSafetyPolicy(id, { warm_up: { enabled } })` is the
170
+ warm-up switch.
171
+ - **`warm_up: false`** on `auth.intent` and `connectRequests.create` connects
172
+ a new account with warm-up off. With `account_id` it is
173
+ `400 INVALID_REQUEST`.
174
+ - **`connection_location`** gains `checked_at`, `detected_country` and
175
+ `flags` (`country_mismatch`, `zone_moved`). No IP field anywhere.
176
+
177
+ #### Changed
178
+
179
+ - **`auth.intent` `timezone`** is `@deprecated` and ignored.
180
+ - **`err.resetAt`** is also `null` on an `activity_window` refusal while the
181
+ account's zone is unknown with no candidate zone (three null cases now).
182
+ `hint.parameter` never names `timezone` or `activity_window_timezone`.
183
+ - **Docs:** a live write outside the activity window is refused with
184
+ `BUDGET_EXHAUSTED` (`safetyReason: "activity_window"`); reads never are.
185
+
186
+ ### Connect v2 (checkpoints, connect refusals)
187
+
188
+ #### Breaking
189
+
190
+ - **New `challenge_type` values** `checking` (LinkedIn is checking the
191
+ sign-in: poll it, nothing to answer) and `phone_register` (solve with
192
+ `(+CC)number`; it was `422 CHECKPOINT_UNSUPPORTED`). Breaking for callers
193
+ that branch on that 422 or switch exhaustively over `challenge_type`: add
194
+ both arms.
195
+ - **`auth.pollCheckpoint` never answers `status: "expired"`** and carries no
196
+ `recovery_hint`. Past `expires_at` (also after the expiry sweep, or when it
197
+ fires mid-poll) solve, poll, resend and request throw
198
+ `409 CHECKPOINT_EXPIRED`. Breaking for callers branching on either.
199
+ - **`CHECKPOINT_EXPIRED` is 409 everywhere** (it was 422 on some solve
200
+ paths), and a pending checkpoint that times out answers it too. Breaking
201
+ for callers branching on a 422 `CHECKPOINT_EXPIRED`.
202
+ - **Connect refusals:** a cookie connect rejected for any reason is
203
+ `401 LINKEDIN_COOKIE_INVALID`; the account cap is `503 PLATFORM_ERROR`
204
+ with `retryLikelyToSucceed: false` (`SUBSTRATE_CAP_REACHED` is no longer
205
+ sent; it stays in `ERROR_CODES` for older deployments); an intent that
206
+ times out before any checkpoint is `503 LINKEDIN_SERVICE_UNAVAILABLE`; a
207
+ poll's terminal refusal throws its own code (a transient error still
208
+ answers `pending`).
209
+
210
+ #### Added
211
+
212
+ - **`LINKEDIN_SIGN_IN_REQUIRED`** (403, user_fixable): LinkedIn wants the
213
+ person to sign in on linkedin.com first.
214
+ - **`channel`** (`email`, `sms`, `whatsapp` or `null`) on `otp` and
215
+ `otp_or_mobile_app_approval` checkpoints: where LinkedIn sent the code.
216
+ - **`user_languages`** on `auth.intent` (cookie connects only, at most 5
217
+ BCP 47 tags, each at most 35 characters).
218
+ - **`contract_selected`** on the connect 201, solve, poll `active` and the
219
+ reconnect 200 when `recruiter_contract_id` was sent.
220
+
221
+ #### Changed
222
+
223
+ - **`TRY_ANOTHER_WAY`** on `auth.solveCheckpoint`: same shape, new meaning.
224
+ It asks LinkedIn for another method and counts no attempt; it is
225
+ `400 INVALID_REQUEST` on `contract_selection`, `challenge_selection`,
226
+ `phone_register` and `checking`. Solve `code` accepts `( ) +`.
227
+ - **`expires_at`** is when Curviate stops accepting an answer; a chained
228
+ checkpoint keeps the sign-in's original `expires_at`.
229
+ - **`recruiter_contract_id`** is selected once the account is connected;
230
+ `contract_selected: false` means connected, not selected.
231
+
232
+ ### Motions setup and engine v2
233
+
234
+ #### Breaking
235
+
236
+ - **`schedule.timezone` and `settings.stop_on_engagement` are retired.**
237
+ Days and hours are read in each sending account's own time zone. Both keys
238
+ are accepted with warning `W13` until 2027-01-25, never stored, and gone
239
+ from every Motion read; `stop_on_engagement: true` with an acting reply
240
+ route is a 400. `motions.start` no longer reports a missing time zone.
241
+ - **`settings.acceptance_floor` defaults to `null`** (off).
242
+ - **`finish_reason` gains `engaged_elsewhere`**: the Contact replied to, or
243
+ was messaged from, another account of the Motion. Add the arm to an
244
+ exhaustive switch.
245
+
246
+ #### Added
247
+
248
+ - **Several accounts per list read:** `account_id` on `motions.list`,
249
+ `enrollments.list` and the `enrollments.bulk` filter takes several
250
+ accounts (comma list, up to 50; an all-empty list is a 400).
251
+ - **`motions.list({ sort: "activity" })`** (latest step record first;
252
+ `updated` stays the default) and `last_activity_at` on every item. A
253
+ cursor belongs to its sort.
254
+ - **`enrollments.list({ step_id })`** takes up to 10 step ids (an array, or a
255
+ comma list); the bulk `filter.step_id` takes a string or an array.
256
+ - **Several mode** (`settings.accounts_per_contact` above 1): settings
257
+ `on_reply_elsewhere` (`stop` or `continue`), `withdraw_on_reply_elsewhere`
258
+ and `account_gap` (`1d` to `30d`), served only in that mode.
259
+ `accounts_per_contact` is locked once the Motion has started.
260
+ - **`motions.preview`**: `projection.audience` `{ total, skipped, to_enroll }`.
261
+
262
+ #### Changed
263
+
264
+ - **`motions.preview` `projection.contacts`** equals `audience.to_enroll`;
265
+ in Several mode `accounts[].contacts` counts Enrollments (same shape, new
266
+ meaning), and `projection.fits_before_end_at` is counted per account zone.
267
+
268
+ ### Analytics v2, exports, Account fields
269
+
270
+ Types come from the served document (regenerated); the hand-written parts:
271
+
272
+ - **`analytics.export(params)`** streams the Analytics tables now
273
+ (`table`, `format`, the overview's filters) and resolves with the file's
274
+ bytes. **`exports.create` / `get` / `download`** run the same as a
275
+ background job: poll `status` to `ready`, then `download` within 24 hours;
276
+ `download` resolves with the file's exact bytes. One export per Team at a time. Types `Export`,
277
+ `ExportCreateBody`, `ExportGetResult`, `AnalyticsExportParams`.
278
+ - **`accounts.metrics(accountId, { from, to, timezone })`** (the Account's daily
279
+ snapshots, oldest first, at most 366 days; no LinkedIn call) and
280
+ **`accounts.overview(accountId, params)`** (one Account at a glance, the
281
+ overview grammar scoped to it).
282
+ - **Analytics options:** `account_id`, `motion_id`, `origin`, `include`,
283
+ `fields` and `sort` take an array as well as a comma list. Windows take
284
+ `range` (`7d`, `30d`, `90d`, `12m`, `all`) and `bucket`; `compare:
285
+ "previous_period"` adds `previous` and `delta` to every rate.
286
+ - **Same shape, new meaning, analytics:** every rate is the outcome SO FAR over
287
+ what was sent in the window, no longer over cohorts at least 7 days old; read
288
+ it with `maturing` and `low_sample`. Messages and InMails are counted apart
289
+ (`reply`, `inmail_reply`). Enrollment counts are by state right now and the
290
+ window does not change them; the state `pending` is now `queued` (list
291
+ filters accept `pending` until 2027-01-21).
292
+ - **Same shape, new meaning, `accounts.list`:** an item carries the profile,
293
+ `status_reason`, `warm_up`, `inmail_balance`, `motions_count`,
294
+ `motion_refs` and three `quotas` rows; reading the list never calls
295
+ LinkedIn.
296
+ - **`err.details` keys:** `account_id` on `TIER_REQUIRED` (one Account lacks
297
+ the feature while the Team has it; an Account on a seat lent from another
298
+ Team carries that seat's Tier: leave it out, or ask the Owner who lent the
299
+ seat) and `export_id` on `EXPORT_IN_PROGRESS`.
300
+ - **JSDoc:** `CHAT_NOT_VISIBLE` (409) on `messaging.startChat`,
301
+ `messaging.sendInMail`, `recruiter.startChat` and
302
+ `salesNavigator.startChat`: nothing is sent when the Account already has a
303
+ private conversation with the person that you may not read. The `EXPORT_*`
304
+ codes are documented on `exports` and in `ERROR_CODES`.
305
+
306
+ ### Teams v2, seat lending
307
+
308
+ - **`teams.create({ name })`** returns the new Team and `next_step`: an
309
+ All-Teams key gets `{ team_header }` (the header line `X-Curviate-Team:
310
+ <team_id>`, usable at once; with this SDK pass the id to `withTeam`), any other
311
+ credential gets `{ command, url }`.
312
+ - **`teams.list({ include: "counts" })`** adds `tier`, `trial`, `accounts`,
313
+ `active_motions` and, on Owner rows, `seats` (`used`, `total`, `lent_out`,
314
+ `lent_in`). A dashboard session and an All-Teams key get counts on every
315
+ row; a Team key, or a token for one Team, only on its own row.
316
+ - **`teams.setLogo(png)`** (a PNG up to 1 MB; Owners only; `UNSUPPORTED_MEDIA_TYPE`
317
+ or `PAYLOAD_TOO_LARGE` otherwise), **`teams.removeLogo()`**, and `logo_url`
318
+ on every Team.
319
+ - **`billing.seats.lend({ team_id, seat_id? })`** and
320
+ **`billing.seats.return(seatId)`**. While a seat is lent, a connect naming
321
+ it or a cancel of it throws `SEAT_LENT`; a seat an Account still uses
322
+ throws `SEAT_NOT_EMPTY` on return.
323
+
324
+ ### Do-not-contact list
325
+
326
+ - **`doNotContact.list` / `get` / `add` / `update` / `remove`** over the
327
+ Team-wide list. `add` takes `contact_id` or `linkedin_url` and an optional
328
+ `reason` (`asked_to_stop`, `existing_customer`, `competitor`, `partner`,
329
+ `employee`, `other`); a person already listed resolves with their entry.
330
+ `remove` is for Owners and Managers.
331
+ - **`DO_NOT_CONTACT` (409)** is documented on the writes it refuses:
332
+ `invites.send`, `messaging.startChat`, `sendMessage`, `sendInMail`,
333
+ `users.follow`, `endorseSkill`, `companies.followInvite`, `posts.react` and
334
+ the comment methods. `do_not_contact` joins the skip and finish reasons.
335
+
336
+ ### Contacts and Network
337
+
338
+ - **`contacts.list` and `contacts.export`:** `list_id` takes up to 20 Contact
339
+ lists (an array or a comma list; any of them, or all with
340
+ `list_match: "all"`), plus `not_list_id` and `has_email`; `contacts.list`
341
+ adds opt-in `include_total` (`total` on the page).
342
+ - **`contacts.updateMany` and `contacts.deleteMany`** take exactly one of
343
+ `contact_ids` or `filter` (up to 10,000 matches; more throws
344
+ `INVALID_REQUEST` naming the count and changes nothing).
345
+ - **`contactLists.create({ name, from })`** starts a static list from a
346
+ filter.
347
+ - **`network.connections.list({ group: "person", include_total })`:** one item
348
+ per person (`object: "connection_person"`, with `accounts` and `contact`).
349
+ `Connection` is now a union of the two item shapes: branch on `object`.
350
+ - **`network.resync()`** needs no Account: with none it is Resync all and
351
+ resolves with `object: "network_resync_list"`. The result type is a union of
352
+ the two answers: branch on `object`.
353
+ - **Same shape, more often filled:** `location` on a Connection, and
354
+ `first_name` / `last_name` on `users.listRelations`, are non-null more often.
355
+
356
+ ### Invoices (the billing part)
357
+
358
+ - **`billing.invoices.list(params)`** pages the Team's issued invoices (house
359
+ auto-pager over `items` / `cursor`; `await` for a page, `for await` for all;
360
+ Owners only) and **`billing.invoices.pdf(number)`** resolves with the PDF
361
+ bytes.
362
+
363
+ ### All-Teams key
364
+
365
+ #### Added
366
+
367
+ - **`curviate.withTeam(team)`**: the same client acting in that Team. Every
368
+ call made through it sends `X-Curviate-Team: <team>`, overriding the
369
+ `team` client option, so one client can act per call in any of your
370
+ Teams. Works for every method, root and account-scoped.
371
+ - **`TEAM_REQUIRED`** (400): a key that works in all of your Teams called a
372
+ Team-level operation without naming the Team. Name one with `team` or
373
+ `withTeam`; a call on one of your accounts takes that account's Team.
374
+
375
+ #### Changed
376
+
377
+ - **`TEAM_ACCESS_DENIED`** and the `team` option copy: a key may work in one
378
+ Team or in all of a person's Teams. A Team key accepts only its own Team.
379
+
380
+ ---
381
+
10
382
  ## [0.44.0] - 2026-10-22
11
383
 
12
384
  Dashboard v2 API. Fixture and types regenerated from deployed staging (server
package/README.md CHANGED
@@ -30,10 +30,11 @@ const curviate = new Curviate({
30
30
  // baseUrl: "https://api.curviate.com", // default
31
31
  // timeout: 30_000, // per-attempt timeout, ms
32
32
  // maxRetries: 3, // GET/HEAD retries with backoff
33
+ // client: "my-agent/1.2.0", // Curviate-Client header; default sdk/<version>
33
34
  });
34
35
  ```
35
36
 
36
- An API key belongs to one person in one team and acts with that person's role there. `curviate.teams.getCurrent()` says which team and role. A dashboard session token may act in any team you belong to: pick one with `team: "tnt_..."` (sent as `X-Curviate-Team`). Leave `team` unset with an API key; naming another team is refused with `TEAM_ACCESS_DENIED`.
37
+ An API key belongs to one person in one team and acts with that person's role there. `curviate.teams.getCurrent()` says which team and role. A key may work in one Team or in all of a person's Teams. A Team key acts only in its own Team: leave `team` unset with one; naming another team is refused with `TEAM_ACCESS_DENIED`. A key that works in all of your Teams, or a dashboard session token, picks one with `team: "tnt_..."` (sent as `X-Curviate-Team`), or per call with `curviate.withTeam("tnt_...")`, which overrides the option. Without one, an All-Teams key's Team-level operation is refused with `TEAM_REQUIRED` (a call on one of your accounts takes that account's Team), and a session token acts in your default Team.
37
38
 
38
39
  ---
39
40
 
@@ -96,7 +97,21 @@ if (unread_count > 0) {
96
97
 
97
98
  ## Teams
98
99
 
99
- `curviate.teams` manages the team the credential acts in: `getCurrent`, `updateCurrent`, `listMembers`, `updateMember` (role), `removeMember` (`"me"` leaves), `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`, plus `list` for every team you belong to. Member and invite writes are for Owners (`INSUFFICIENT_ROLE` otherwise). `curviate.accounts.setTeamAccess(accountId, false)` keeps an account you connected to yourself.
100
+ `curviate.teams` manages the team the credential acts in: `getCurrent`, `updateCurrent`, `listMembers`, `updateMember` (role), `removeMember` (`"me"` leaves), `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`, plus `list` for every team you belong to. Member and invite writes are for Owners; Managers also rename the team, set its logo and manage Member invites (`INSUFFICIENT_ROLE` otherwise). `curviate.accounts.setTeamAccess(accountId, false)` keeps an account you connected to yourself.
101
+
102
+ ```ts
103
+ const team = await curviate.teams.create({ name: "Client A" }); // you become its Owner; no seat yet
104
+ // An All-Teams key: next_step.team_header is "X-Curviate-Team: <team_id>", usable at once (pass team_id as the `team` option).
105
+ // Any other credential: next_step.command and next_step.url (CLI setup, dashboard Teams page).
106
+ const { items } = await curviate.teams.list({ include: "counts" }); // tier, trial, accounts, active_motions, seats (Owner rows)
107
+ await curviate.teams.setLogo(await readFile("logo.png")); // a PNG up to 1 MB; Owners and Managers; removeLogo() clears it
108
+
109
+ // Lend an unused seat to another of your Teams, and move it back (once no Account uses it).
110
+ const { seat_id } = await curviate.billing.seats.lend({ team_id: "tnt_456" });
111
+ await curviate.billing.seats.return(seat_id);
112
+ ```
113
+
114
+ `teams.list({ include: "counts" })` adds counts on every row for a dashboard session and an All-Teams key, and only on its own row for a Team key. While a seat is lent, a connect naming it or a cancel of it throws `SEAT_LENT`. `curviate.billing.invoices.list()` pages the Team's invoices (Owners only) and `billing.invoices.pdf(number)` resolves with the PDF bytes. `TIER_REQUIRED` carries `err.details.account_id` when one Account (on a seat lent from another Team) lacks the feature while the Team has it.
100
115
 
101
116
  ## Tiers
102
117
 
@@ -137,9 +152,15 @@ const { items: skipped } = await curviate.contacts.imports.rows(imp.id, { outcom
137
152
 
138
153
  await curviate.contacts.updateMany({ contact_ids: [id], add_tags: ["vip"] });
139
154
  const all = await curviate.contacts.export({ format: "json", tag: "vip" });
155
+
156
+ // One filter everywhere: several lists, an opt-in total, then act on exactly what it matches.
157
+ const filter = { list_id: ["cl_1", "cl_2"], list_match: "all" as const, not_list_id: ["cl_3"], has_email: true };
158
+ const { total } = await curviate.contacts.list({ ...filter, include_total: true, limit: 1 });
159
+ await curviate.contactLists.create({ name: "Both, with email", from: filter }); // a static list of what matches now
160
+ await curviate.contacts.updateMany({ filter, add_tags: ["vip"] }); // or deleteMany({ filter }); over 10,000 matches is refused
140
161
  ```
141
162
 
142
- `contacts.enrich` fills profile fields from what Curviate already stores, else queues paced live profile views from an account you name: a live view is a real profile view the person may see. `curviate.contactLists` and `curviate.tags` rename and delete; a Tag has no create call, the first write naming it creates it. Refusals: `NAME_TAKEN`, `RESOURCE_IN_USE` (a live Motion or a running import uses it), `CONTACT_LIMIT_REACHED`, `IMPORT_QUEUE_FULL`. Deletes are for Owners and Managers.
163
+ `list_id` and `not_list_id` take up to 20 Contact list ids (an array or a comma list). `curviate.doNotContact` is the Team-wide do-not-contact list (`list`, `get`, `add`, `update`, `remove`): no Motion contacts a person on it and a write toward them throws `DO_NOT_CONTACT`. `contacts.enrich` fills profile fields from what Curviate already stores, else queues paced live profile views from an account you name: a live view is a real profile view the person may see. `curviate.contactLists` and `curviate.tags` rename and delete; a Tag has no create call, the first write naming it creates it. Refusals: `NAME_TAKEN`, `RESOURCE_IN_USE` (a live Motion or a running import uses it), `CONTACT_LIMIT_REACHED`, `IMPORT_QUEUE_FULL`. Deletes are for Owners and Managers.
143
164
 
144
165
  ## Sequences
145
166
 
@@ -168,7 +189,7 @@ const document = seq.toDocument(); // the Sequence document, keys in canonical o
168
189
 
169
190
  Field names are the document's keys (`on_reply`, `note_fallback`, `withdraw_after`). Variables: `{{first_name}}` (any standard Contact field), `{{my_first_name}}`, `{{my_last_name}}`, `{{my_full_name}}` (the sending account), and any other `{{<key>}}` is the custom field `contact.extra.<key>`; the long forms `{{contact.first_name}}`, `{{contact.extra.opener}}` and `{{account.first_name}}` stay valid. A messaging step takes one text in `with` or exactly 2 `variants` (A/B). An optional `event` on a `do` or `tag` step sends a custom event each time the step runs; `until event.<name>` waits for one your agent sends (`enrollments.events`). With literal arguments, the type checker refuses what the API would: a typo'd action, event, reaction or predicate, a `{{...}}` that is not a variable (`{{firstName}}`, `{{my_title}}`, a `| default:` filter), a text with a variable and no fallback, one variant, a duration like `"1w"`, the reserved id `"end"`, and a jump to a step you never added (at `toDocument()`). Text the type checker cannot see (a `string` variable) is checked when you call `validate()` or `toDocument()`.
170
191
 
171
- Every builder operation (`add`, `insertBefore`, `replace`, `remove`, `onReply`) returns a new builder. `toDocument()` throws `SEQUENCE_INVALID` with every error in `err.errors`, the same list the API returns: `path`, `rule`, and the compiler-style `code` (`R1` to `R24`), `pointer` (a JSON Pointer into the document), `hint` (the fix) and `docs_url`. Warnings (`W1` to `W13`) never block, read them with `validate()`. `JSON.stringify(seq)` equals `JSON.stringify(seq.toDocument())`. `newStepId(document, kind)` names a new step as the dashboard editor does (`message`, `message_2`, `w_<step>` for a wait before a step).
192
+ Every builder operation (`add`, `insertBefore`, `replace`, `remove`, `onReply`) returns a new builder. `toDocument()` throws `SEQUENCE_INVALID` with every error in `err.errors`, the same list the API returns: `path`, `rule`, and the compiler-style `code` (`R1` to `R28`), `pointer` (a JSON Pointer into the document), `hint` (the fix), `docs_url`, `severity` and, for a did-you-mean, `candidates`. Warnings (`W1` to `W16`) never block, read them with `validate()`. `JSON.stringify(seq)` equals `JSON.stringify(seq.toDocument())`. `newStepId(document, kind)` names a new step as the dashboard editor does (`message`, `message_2`, `w_<step>` for a wait before a step).
172
193
 
173
194
  Raw JSON stays first class. Validate a document you wrote by hand, or load one (a Motion's `sequence`) to edit it:
174
195
 
@@ -183,7 +204,7 @@ const edited = Sequence.fromDocument(motion.sequence) // throws SEQUENCE_INVALID
183
204
  .toDocument();
184
205
  ```
185
206
 
186
- Deprecated forms are accepted with warning `W13` until 2027-01-19, then refused: 3 to 5 variants (use 2), `until signal.<name>` (use `until event.<name>`) and `{{motion.<key>}}` (write the text in place or use a custom field; offline it also gives `W9`). Warnings that need the Motion's sending accounts or settings come from the API only. `SEQUENCE_SCHEMA_V1` is the published JSON Schema (`SEQUENCE_SCHEMA_URL`) for editors and tooling. The validator knows the vocabulary of the schema it ships with: a document using an action or event added to the API later is refused offline while the API accepts it; upgrade the SDK.
207
+ Deprecated forms are accepted with warning `W13` until 2027-01-19, then refused: 3 to 5 variants (use 2), `until signal.<name>` (use `until event.<name>`) and `{{motion.<key>}}` (write the text in place or use a custom field; offline it also gives `W9`). Warnings that need the Motion's sending accounts or settings come from the API only. `SEQUENCE_SCHEMA_V1` is the published JSON Schema (`SEQUENCE_SCHEMA_URL`) for editors and tooling. R29 (an `until event.<name>` gate on a field the incoming event's sample does not carry) needs your Team's incoming events, so it comes from the API only. The validator is the API's own rule code, shipped with this SDK: a document using an action or event added to the API later is refused offline while the API accepts it; upgrade the SDK.
187
208
 
188
209
  ## Motions and Enrollments
189
210
 
@@ -196,7 +217,7 @@ const motion = await curviate.motions.create({
196
217
  name: "Q4 webinar follow-up",
197
218
  contact_list_ids: ["cl_123"],
198
219
  account_ids: ["acc_123"],
199
- schedule: { timezone: "Europe/Berlin" },
220
+ schedule: { days: ["mon", "tue", "wed", "thu", "fri"] }, // read in each sending account's own time zone
200
221
  sequence: seq, // a builder: converted with toDocument(); an invalid one throws SEQUENCE_INVALID, nothing is sent
201
222
  });
202
223
 
@@ -230,9 +251,12 @@ const { items, coverage } = await curviate.network.connections.list({ account_id
230
251
  for await (const c of curviate.network.connections.list({ q: "founder" })) console.log(c.public_profile_url);
231
252
  const { items: sync } = await curviate.network.syncStatus();
232
253
  await curviate.network.resync({ account_id: "acc_1" }); // once per account per 24 h
254
+ await curviate.network.resync(); // Resync all: network_resync_list, one item per account you can operate
255
+ for await (const p of curviate.network.connections.list({ group: "person" })) console.log(p.object); // connection_person: one per person
233
256
 
234
257
  // One inbox over every account you can operate.
235
258
  for await (const chat of curviate.unibox.list({ unread: true })) console.log(chat.chat_id);
259
+ const { items: theirs, total } = await curviate.unibox.list({ person: "con_1", motion_ids: ["none"], include_total: true }); // total: every match, not the page
236
260
  const { unread_chats } = await curviate.unibox.unreadCount();
237
261
  await curviate.unibox.accounts.update("acc_1", { sync_full_inbox: true }); // by the person who connected it
238
262
  await curviate.unibox.refresh();
@@ -241,11 +265,26 @@ await curviate.unibox.enroll("acc_1", "chat_1", { motion_id: "mot_1" });
241
265
 
242
266
  // Motion results, activity in budget units, the events feed.
243
267
  const overview = await curviate.analytics.motions.list({ from: "2026-10-01", to: "2026-10-31" });
268
+ const summary = await curviate.analytics.motions.list({ range: "30d", compare: "previous_period", origin: ["motion", "unibox"] });
244
269
  const detail = await curviate.analytics.motions.get("mot_1");
245
270
  const { types } = await curviate.analytics.activity(); // per BudgetRow; never add rows together
246
271
  for await (const event of curviate.analytics.events.list({ kind: ["message.replied"] })) console.log(event.motion_name);
272
+ const { items: forOne } = await curviate.analytics.events.list({ contact_id: "con_1", origin: ["unibox", "api"] }); // 404 for an unknown, other-team or merged-away id
273
+
274
+ // Files: stream the Analytics tables now, or run a background export and download it within 24 hours.
275
+ const csv = await curviate.analytics.export({ table: ["daily"], range: "90d" }); // ArrayBuffer
276
+ const job = await curviate.exports.create({ entity: "analytics", format: "csv", tables: ["daily", "motions"], filters: { range: "12m" } });
277
+ const file = await curviate.exports.download(job.id); // EXPORT_NOT_READY until exports.get(job.id).status is "ready"
278
+
279
+ // One account at a glance, and its own daily numbers.
280
+ const { performance, flags } = await curviate.accounts.overview("acc_1", { range: "30d" });
281
+ const { items: metrics } = await curviate.accounts.metrics("acc_1", { from: "2026-08-01" });
247
282
  ```
248
283
 
284
+ Every rate is the outcome so far over what was sent in the window, not over old enough cohorts, so it keeps moving as replies arrive: read it with `maturing` (sent under 7 days ago) and `low_sample` (under 30). Messages and InMails are counted apart (`reply` and `inmail_reply`). Enrollment counts are by state right now (`queued`, `active`, `paused`, `finished`, `failed`, `skipped`); the window does not change them. One export runs per Team at a time (`EXPORT_IN_PROGRESS`, with `err.details.export_id`).
285
+
286
+ Unibox traps. A send through the API (`messaging.sendMessage`, `startChat`, `sendInMail`) marks the chat read, so `unread` turns `false` and `marked_unread` clears; a Motion send leaves it. `marked_unread` is `{origin: "unibox" | "api" | "linkedin", at}` when a chat was made unread without a new message from them. A disconnected account stays in `unibox.accounts.list()` and `unibox.list()` (`account_status: "disconnected"`), read-only: its messages come from the stored copy (`coverage.complete`, attachments `unavailable`), and a write on it throws `REAUTH_REQUIRED` (409). `unibox.refresh` runs once per account per 2 minutes (`cooldown` with `next_allowed_at` after that) and covers accounts with Motion chats or Motion outreach even without full-inbox sync; `not_synced` means none of those apply. A sent message carries `origin` (`motion`, `unibox`, `api`, `linkedin`; omitted when unknown, never on a received message or `last_message`) and `is_deleted`. Analytics `origin` is `motion`, `unibox` or `api`; `direct` is an alias for `unibox` and `api`.
287
+
249
288
  Needs Automate: `network.resync`, `unibox.refresh`, `unibox.tagContact`, `unibox.enroll` and turning full-inbox sync on (off passes on any Tier). Messages and actions on a chat stay on `curviate.account(id).messaging`.
250
289
 
251
290
  ## Drafts and scheduled posts
@@ -334,9 +373,12 @@ try {
334
373
  // nothing reached LinkedIn and nothing was spent, so backing off is the
335
374
  // wrong move. Wait until err.resetAt, or raise the setting the hint
336
375
  // names on PATCH /v1/{account_id}/safety-policy. The ceiling comes from the
337
- // account's safety policy or the tenant default (GET/PATCH /v1/safety-policy).
338
- // resetAt is null in the two cases no clock frees: the pending_invites
339
- // backlog, and an InMail credit pool LinkedIn regrants on its own schedule.
376
+ // account's safety policy or the Team default (GET/PATCH /v1/safety-policy).
377
+ // Limits are always enforced. err.safetyReason "activity_window": a live
378
+ // write outside the account's active hours (the hint names active_hours).
379
+ // resetAt is null in the three cases no clock frees: the pending_invites
380
+ // backlog, an InMail credit pool LinkedIn regrants on its own schedule,
381
+ // and an account whose time zone is not known yet.
340
382
  console.warn(`${err.budgetRow} is spent until ${err.resetAt ?? "no fixed time"}`);
341
383
  console.warn(`change ${err.safetyHint?.parameter} to lift it`);
342
384
  break;
@@ -577,21 +619,43 @@ Each event also carries its delivery metadata: `event.id`, `event.webhook_id`, a
577
619
 
578
620
  ### De-duplicating retries
579
621
 
580
- Delivery is at-least-once, so your handler can see the same logical event more than
581
- once. The key that survives a retry is the payload composite `event.event` plus
582
- `event.data.account_id` plus `event.data.occurred_at`, which the platform re-sends
583
- byte-identical on every attempt:
622
+ Delivery is at-least-once, so your handler can see the same event more than once. Key on
623
+ `event.id`: it is the delivery id (`wdl_`), the same on every attempt and every redelivery
624
+ (before this release it changed per attempt). `event.delivered_at` is when the body was
625
+ rendered, also the same on every attempt.
584
626
 
585
627
  ```ts
586
- const key = `${event.event}:${event.data.account_id}:${event.data.occurred_at}`;
587
- if (await alreadyProcessed(key)) return res.sendStatus(200);
628
+ if (await alreadyProcessed(event.id)) return res.sendStatus(200);
629
+ ```
630
+
631
+ `curviate.webhooks.deliveries.get(event.webhook_id, event.id)` reads that delivery's status and
632
+ attempts; `deliveries.list(id, { failed: true })` lists what did not get through, and
633
+ `webhooks.redeliver(id, deliveryId)` sends one again, byte for byte, within 24 hours.
634
+
635
+ ### Rotating the secret
636
+
637
+ `webhooks.rotateSecret(id)` returns a new secret once. For 24 hours every delivery is signed
638
+ with both (`Curviate-Signature: t=...,v1=<new>,v1=<old>`) and `constructEvent` accepts the
639
+ delivery when any `v1` matches the secret you pass, so the old and the new secret both verify
640
+ while you deploy. Older SDK versions check only the last `v1`: upgrade before you rotate.
641
+
642
+ ### Body templates
643
+
644
+ A webhook's `body_template` replaces the standard payload with your own JSON (`{{variables}}`
645
+ inside its strings; `webhooks.variables({ source })` lists them). Check it offline with the
646
+ API's own lint, and preview it:
647
+
648
+ ```ts
649
+ import { renderWebhookTemplate, validateWebhookTemplate } from "@curviate/sdk";
650
+
651
+ const { errors } = validateWebhookTemplate(template, { source: "motion" });
652
+ for (const e of errors) console.error(`${e.line}:${e.col} ${e.code} ${e.rule}`);
653
+ const body = renderWebhookTemplate(template, { event: "enrollment.finished", contact: { email: "alex@example.com" } });
588
654
  ```
589
655
 
590
- **Do not key on `event.id`.** That is a `wdl_` id minted per delivery *attempt*, so a
591
- retry arrives with a different one and storing it would record the same event twice,
592
- missing exactly the duplicates you were guarding against. It is still worth logging:
593
- `event.id` is how you match one attempt to one line in your delivery logs, keyed
594
- alongside the stable `occurred_at` composite above.
656
+ A webhook's `request_url` reads back masked (`https://host/•••`); sending that value back on
657
+ `update` keeps the stored URL. A `headers` entry with a key and no value keeps that header's
658
+ stored value.
595
659
 
596
660
  `WebhookSignatureError` is NOT a `CurviateError`. Narrow with `instanceof WebhookSignatureError`.
597
661
  Its `reason` tells you where to look: `malformed_header` means the signature header could not be