@planescrm/sdk 0.4.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,19 @@
1
- # @planes/sdk
1
+ # @planescrm/sdk
2
2
 
3
3
  TypeScript SDK for the [Planes CRM](https://planescrm.com) platform — a typed API
4
4
  client, an ergonomic webhook router, OAuth helpers, and an action-handler server
5
5
  for building agents.
6
6
 
7
- > **Not yet published.** `@planes/sdk` is currently a private workspace package —
8
- > it is **not** on npm. Consume it from within this monorepo as a workspace
9
- > dependency (`"@planes/sdk": "workspace:*"`), or request access from the Planes
10
- > team. The `npm install @planes/sdk` examples below describe the intended
11
- > published surface once publishing is enabled.
7
+ ```bash
8
+ npm install @planescrm/sdk
9
+ ```
10
+
11
+ **Self-contained types (0.52.1).** The built package imports only Node builtins
12
+ and its own dependencies — never the private `@blueprint/shared`. Types the API
13
+ shares are mirrored under `src/types/*-types.ts` and checked against shared at
14
+ compile time by `*.parity.ts` files, which are not reachable from `index.ts`.
15
+ `build` runs `scripts/check-shipped-imports.mjs` after `tsup` and fails if
16
+ `dist/` or a shipped source file imports anything else.
12
17
 
13
18
  ## Client
14
19
 
@@ -16,7 +21,7 @@ for building agents.
16
21
  exactly **one** authentication mode.
17
22
 
18
23
  ```ts
19
- import { PlanesClient } from '@planes/sdk';
24
+ import { PlanesClient } from '@planescrm/sdk';
20
25
 
21
26
  // 1. A pre-obtained OAuth access token (not auto-refreshed).
22
27
  const client = new PlanesClient({ accessToken: 'tok_...' });
@@ -36,7 +41,555 @@ const opp = await client.opportunities.get('opp_123');
36
41
  ```
37
42
 
38
43
  Resources: `opportunities`, `customers`, `properties`, `calendarEvents`,
39
- `contracts`, `jobs`, `estimates`, `invoices`, `inventory`.
44
+ `contracts`, `jobs`, `estimates`, `invoices`, `inventory`, `contacts`,
45
+ `activities`, `communications`, `gmail`, `documents`, `tags`, `tasks`,
46
+ `customFields`, `recordViews`, `modules`, `plan`, `me`, `roles`, `contactSequences`,
47
+ `enrollments`, `workflows`, `billingSchedules`, `webhookSubscriptions`, `users`,
48
+ `teams`.
49
+
50
+ ### Breaking changes (0.43.0)
51
+
52
+ Twenty methods called routes the API doesn't serve. Every old call failed with
53
+ a 404 or 405, so no working integration depended on them. Each one now follows
54
+ the real route, and a method the API doesn't have is gone, so calling it is a
55
+ compile error instead of a runtime 404.
56
+
57
+ | Resource | Before (never worked) | Now |
58
+ |---|---|---|
59
+ | `properties` | `list(params)`, `create(body)`, `update(id, body)` (PATCH), `delete(id)` on `/properties` | `list(customerId, params)`, `create(customerId, body)`, `update(customerId, id, body)` (PUT), `delete(customerId, id)` on `/customers/{customerId}/properties`. `get(id)` is unchanged (`GET /properties/{id}`). |
60
+ | `jobs` | `list`, `get`, `create`, `update` (PATCH), `delete` on `/jobs` | `list(opportunityId, params)`, `get(opportunityId, id)`, `create(opportunityId, body)`, `update(opportunityId, id, body)` (PUT), `delete(opportunityId, id)` on `/opportunities/{opportunityId}/jobs`. `listForOpportunity()` and `getTradeCoverage()` are unchanged. |
61
+ | `estimates` | `list`, `get`, `create`, `update` (PATCH), `delete` on `/estimates` | `list(jobId, params)`, `get(jobId, id)`, `create(jobId, body)`, `update(jobId, id, body)` (PUT), `delete(jobId, id)` on `/jobs/{jobId}/estimates`. `listForOpportunity()` is unchanged. |
62
+ | `inventory` | CRUD on `/inventory/items`, `update` sent PATCH | Same methods on `/admin/inventory/items`; `update` sends PUT. |
63
+ | `opportunities` | `update(id, body)` sent PATCH | `update(id, body)` sends PUT; the body is typed `UpdateOpportunityDto`. |
64
+ | `tasks` | `delete(id)` | Removed (there is no delete route). Use `cancel(id)`; `reopen(id)` brings a task back. |
65
+ | `tags` | `get(id)` | Removed (there is no single-tag read). Use `list()` or `getEntities(id)`. |
66
+
67
+ New request types, mirroring the API's validation schemas: `CreatePropertyDto`,
68
+ `UpdatePropertyDto`, `PropertyListParams`, `CreateJobDto`, `UpdateJobDto`,
69
+ `CreateEstimateDto`, `UpdateEstimateDto`, `CreateInventoryItemDto`,
70
+ `UpdateInventoryItemDto`, `InventoryItemListParams`, `UpdateOpportunityDto`.
71
+
72
+ ```ts
73
+ const { data: properties } = await client.properties.list('cust_123');
74
+ const job = await client.jobs.create('opp_123', { tradeId: 'trade_123' });
75
+ await client.estimates.update('job_123', 'est_123', { taxRate: 0.0825 });
76
+ await client.tasks.cancel('task_123');
77
+ ```
78
+
79
+ ### Invoices
80
+
81
+ `client.invoices.list({ status, customerId, page, limit })` returns typed
82
+ `InvoiceListItemDto` rows. Each row carries `customerName` — the company name
83
+ for a company customer, "First Last" for a person — so a list view needs no
84
+ follow-up `customers.get()` per row:
85
+
86
+ ```ts
87
+ const { data } = await client.invoices.list({ status: 'sent' });
88
+ data.forEach((inv) => console.log(inv.invoiceNumber, inv.customerName, inv.balance));
89
+ ```
90
+
91
+ ### Consent state (0.33.0)
92
+
93
+ Every contact channel the API returns (`contacts.get`, `contacts.list({ include: ['channels'] })`,
94
+ channel create/update) and `GET /customers/:id` / `PUT /customers/:id` carry a `consent`
95
+ object (`ConsentStateDto`): the record's own `marketing_opt_out` / `dnc` flags,
96
+ `effective_opted_out` (`marketing_email` / `sms` / `call`) — the by-address verdict every send
97
+ honours, which can be true while the record's own flag is false because another record with the
98
+ same address holds the opt-out — and `last_change` (`{ outcome, source, at, scope }` or `null`).
99
+
100
+ ```ts
101
+ const { data } = await client.contacts.list({ customer_id: customerId, include: ['channels'] });
102
+ for (const ch of data.flatMap((c) => c.channels ?? [])) {
103
+ if (ch.consent.effective_opted_out.marketing_email && !ch.consent.marketing_opt_out) {
104
+ console.log(ch.value, 'is opted out via another record', ch.consent.last_change?.source);
105
+ }
106
+ }
107
+ `client.invoices.downloadPdf(id)` (0.36.0) returns the invoice PDF as an
108
+ `ArrayBuffer`. A sent invoice returns the copy stored when it was sent, and a
109
+ draft renders on demand with a DRAFT watermark:
110
+
111
+ ```ts
112
+ import { writeFile } from 'node:fs/promises';
113
+ await writeFile('INV-0001.pdf', Buffer.from(await client.invoices.downloadPdf(invoiceId)));
114
+ ### Overdue invoices (0.35.0)
115
+
116
+ `client.invoices.listOverdue({ minDays, customerId, page, limit })` returns
117
+ sent / partially paid invoices past their due date, most overdue first, with
118
+ `daysOverdue` and `lastOffsetReached`; `client.invoices.overdueSummary()`
119
+ returns `{ count, total }`. A daily sweep emits the `invoice.overdue` webhook
120
+ event (typed `InvoiceOverdueEvent`) once per invoice at each of the company's
121
+ offsets — days after the due date, default `[3, 7, 14]`, read and set (admin)
122
+ with `client.invoiceSettings.getOverdueOffsets()` / `setOverdueOffsets([...])`:
123
+
124
+ ```ts
125
+ const { data } = await client.invoices.listOverdue({ minDays: 7 });
126
+ const { count, total } = await client.invoices.overdueSummary();
127
+ await client.invoiceSettings.setOverdueOffsets([5, 10, 30]); // 1–90 days, up to 5
128
+ ```
129
+
130
+ Invoice emails attach that stored copy (as `INV-0001.pdf`) unless an admin turns
131
+ it off: `client.invoiceSettings.setAttachPdf(false)` / `getAttachPdf()` (0.39.0;
132
+ the write needs Update Tenant).
133
+
134
+ Payment reminders (0.41.0, admin only): `client.invoiceReminders.list()` returns
135
+ the rules and the overdue offsets (a rule at an offset the sweep never emits is
136
+ `willNeverFire`); `update(rules)` replaces the whole set (unknown placeholders are
137
+ a 422 `REMINDER_TEMPLATE_INVALID`); `preview({ subject, body, invoiceId? })`
138
+ renders exactly what would send, without sending:
139
+
140
+ ```ts
141
+ await client.invoiceReminders.update([
142
+ { offsetDays: 7, enabled: true, subject: 'Invoice {{invoiceNumber}} is past due', body: 'Hi {{customerName}}, {{amountDue}} was due {{dueDate}}.' },
143
+ ]);
144
+ const { subject, body } = await client.invoiceReminders.preview({ subject: '…', body: '…', invoiceId });
145
+ ```
146
+
147
+ ### Billing schedules (0.32.0)
148
+ ### Billing schedules (0.32.0; line versions 0.34.0; adjustments 0.38.0; autopay summary 0.46.0)
149
+
150
+ `client.billingSchedules` manages recurring invoices (`/admin/billing-schedules`,
151
+ invoicing module). Fields are snake_case, money is a 2-decimal string, and
152
+ dates are `YYYY-MM-DD`:
153
+
154
+ ```ts
155
+ const schedule = await client.billingSchedules.create({
156
+ customer_id: 'cus_123',
157
+ name: 'Monthly maintenance',
158
+ cadence: 'monthly',
159
+ start_date: '2026-11-01', // a past date also needs backfill: 'skip' | 'draft'
160
+ lines: [{ description: 'Lawn care', quantity: '1', unit_price: '120.00' }],
161
+ }); // timezone defaults to the tenant's
162
+
163
+ await client.billingSchedules.preview(schedule.id, { count: 12 }); // next 12 periods
164
+ // A price change from a date: periods starting on or after it bill the new lines.
165
+ await client.billingSchedules.update(schedule.id, {
166
+ lines: [{ description: 'Lawn care', quantity: '1', unit_price: '135.00' }],
167
+ effective_from: '2027-01-01',
168
+ });
169
+ await client.billingSchedules.listVersions(schedule.id); // line version history
170
+ // A one-off credit (or charge) on the next invoice only — no automatic proration.
171
+ const credit = await client.billingSchedules.addAdjustment(schedule.id, {
172
+ description: 'Credit: 10 days not serviced',
173
+ amount: '-40.00',
174
+ });
175
+ await client.billingSchedules.listAdjustments(schedule.id); // pending + consumed
176
+ await client.billingSchedules.cancelAdjustment(schedule.id, credit.id); // only while pending
177
+ await client.billingSchedules.periods(schedule.id, { status: 'failed' }); // the period ledger
178
+ await client.billingSchedules.needsReview(); // tenant-wide queue
179
+ await client.billingSchedules.autopaySummary(); // { data: { autopaySchedules, customers, savedCards } } before a Stripe disconnect
180
+ await client.billingSchedules.generateNow(schedule.id); // bill the current due period now
181
+ await client.billingSchedules.skipPeriod(schedule.id, '2026-12-01', { reason: 'Away' });
182
+ await client.billingSchedules.regeneratePeriod(schedule.id, '2026-11-01'); // after a void
183
+ await client.billingSchedules.pause(schedule.id); // also resume(id), end(id)
184
+ ```
185
+
186
+ Also `list({ customer_id, status, page, limit })`, `get(id)` (with
187
+ `upcoming_periods`), `update(id, patch)` (`lines` replaces every line) and
188
+ `delete(id)` (only a schedule that never invoiced).
189
+
190
+ ### Completing calendar events (0.44.0)
191
+
192
+ `client.calendarEvents.complete(id, { completion_notes? })` marks an event
193
+ completed and runs its "on complete" transition, best-effort. The event is
194
+ always completed; `workflow_trigger` says whether the transition ran:
195
+
196
+ ```ts
197
+ const res = await client.calendarEvents.complete('evt_123');
198
+ if (res.workflow_trigger.status === 'failed') {
199
+ // e.g. reason 'not_valid_from_current_stage' or 'wrong_pipeline'
200
+ console.warn(res.workflow_trigger.message);
201
+ }
202
+ ```
203
+
204
+ `status` is `fired`, `skipped` or `failed` (with a `reason` and `message`; the
205
+ tenant's admins also get a notification). A skip's `reason` is
206
+ `no_completion_transition` (none set) or, since 0.59.0,
207
+ `handled_by_pipeline_rule`: the record's pipeline rule moves it after the
208
+ request, so the event's own step didn't run.
209
+ Since 0.55.0 a `fired` trigger carries `to_stage: { id, name }`, the stage the
210
+ transition moved the record to; `tasks.complete(id)` returns the same
211
+ `workflow_trigger`:
212
+
213
+ ```ts
214
+ const { workflow_trigger } = await client.tasks.complete('task_123');
215
+ if (workflow_trigger.status === 'fired') console.log(`Moved to ${workflow_trigger.to_stage?.name}`);
216
+ ```
217
+
218
+ Actions on that transition run afterwards, so a chained transition may move the
219
+ record again later; `to_stage` doesn't report that later move. Creating
220
+ or updating an event with a `completion_workflow_transition_id` from a pipeline
221
+ other than its linked record's is rejected with a 422
222
+ `COMPLETION_TRANSITION_WRONG_PIPELINE`.
223
+
224
+ ### Updating recurring calendar events (0.54.0)
225
+
226
+ `client.calendarEvents.update(id, patch, { editMode })` chooses which
227
+ occurrences of a recurring event change: `this` (only this occurrence, the
228
+ default), `this_and_future` (splits the series here) or `all` (the whole
229
+ series). It is sent as the `recurrence_edit_mode` query parameter and ignored
230
+ for one-time events. `this` on a series master is a 400 `INVALID_EDIT_MODE`.
231
+
232
+ ```ts
233
+ await client.calendarEvents.update('evt_123', { starts_at: '2026-11-02T15:00:00' }, { editMode: 'this_and_future' });
234
+ ```
235
+
236
+ A blank `recurrence_rule` in the patch means "doesn't repeat": it is ignored
237
+ rather than stored, so it never changes an existing series.
238
+
239
+ ### Recently viewed
240
+
241
+ `client.recordViews.touch({ entity_type, entity_id })` records that the calling
242
+ user opened a customer / contact / opportunity / job (idempotent; 204 even for
243
+ an id that no longer exists). `client.recordViews.recent({ limit })` returns
244
+ that user's newest views. The four list calls accept `sort_by: 'recent'` —
245
+ the caller's most recently opened records first, never-viewed last
246
+ (`sort_order` is ignored for it):
247
+
248
+ ```ts
249
+ await client.recordViews.touch({ entity_type: 'customer', entity_id: 'cus_123' });
250
+ const { data } = await client.customers.list({ sort_by: 'recent' });
251
+ const jobs = await client.jobs.listForOpportunity('opp_123', { sort_by: 'recent' });
252
+ ```
253
+
254
+ ### Module status
255
+
256
+ `client.modules.list()` returns `{ moduleKey, isActive }` for every registered
257
+ module in the caller's tenant (any role; read-only). A module that was never
258
+ activated reads `isActive: false`:
259
+
260
+ ```ts
261
+ const modules = await client.modules.list();
262
+ const contactsOn = modules.some((m) => m.moduleKey === 'contacts' && m.isActive);
263
+ ```
264
+
265
+ ### Plan
266
+
267
+ `client.plan.get()` returns the caller tenant's plan (any role; read-only):
268
+ `planKey`, `label`, `billingStatus`, `planSource`, `trialEndsAt`,
269
+ `billingInterval`, `priceLockedUntil`, and the plan's ceiling — the `modules`
270
+ and `features` it may enable, `includedFullSeats`, `crewSeatsAllowed`,
271
+ `monthlyAiCredits` and `storageGb`. A ceiling is not activation: check
272
+ `client.modules.list()` for what is switched on. Plans change in Settings →
273
+ Billing, not through the API.
274
+
275
+ ```ts
276
+ const plan = await client.plan.get();
277
+ const canInvoice = plan.modules.includes('invoicing');
278
+ ```
279
+
280
+ ### Caller abilities
281
+
282
+ `client.me.abilities()` returns the caller's effective ability — the exact
283
+ rules the server enforces — as `{ role, actingRole?, version, rules }`.
284
+ `rules` are CASL packed rules; for an API key, `actingRole` is the acting
285
+ agent's live role and the rules are the key role ∩ that role. `version`
286
+ matches the `X-Planes-Abilities-Version` header on every authenticated
287
+ response, so a changed header means refetch:
288
+
289
+ ```ts
290
+ import { createMongoAbility } from '@casl/ability';
291
+ import { unpackRules } from '@casl/ability/extra';
292
+
293
+ const { role, rules } = await client.me.abilities();
294
+ const ability = createMongoAbility(unpackRules(rules));
295
+ ability.can('delete', 'Customer');
296
+ ```
297
+
298
+ ### Roles (admin only)
299
+
300
+ `client.roles` manages Settings → Roles (0.19.0). Every call needs an admin
301
+ caller — any other role gets a 403 `FORBIDDEN_BY_ROLE`. `list()` returns
302
+ `{ data: [{ key, label, tunable, userCount, changeCount }] }`; `get(key)` one
303
+ role's matrix (`{ key, label, tunable, summary, version, rows: [{ rowId,
304
+ cells: [{ action, subject, value, default, tier, locked }] }] }`).
305
+ `updateGrants(key, body)` sends sparse cell changes (`effect: 'allow' | 'deny' |
306
+ 'default'`); granting a money action (approve, mark paid, correct, payments)
307
+ needs `confirm: true` and a `reason`, and passing the `version` you read as
308
+ `baseVersion` turns a concurrent edit into a 409. `reset(key)` returns a role
309
+ to the Planes defaults; `history({ role, cursor, limit })` pages the change log:
310
+
311
+ ```ts
312
+ const sales = await client.roles.get('sales');
313
+ await client.roles.updateGrants('sales', {
314
+ changes: [{ action: 'delete', subject: 'Customer', effect: 'allow' }],
315
+ baseVersion: sales.version,
316
+ });
317
+ const { data, cursor } = await client.roles.history({ role: 'sales', limit: 20 });
318
+ ```
319
+
320
+ ### Lists (0.64.0)
321
+
322
+ `client.lists` covers every `/lists` route: find, read, create, rename or
323
+ redefine, delete, add and remove members, read recipients, and export.
324
+ `list()` returns a bare array, newest first, each list with `member_count`.
325
+ A list with `managed_by` set (owned by an integration) refuses member and
326
+ definition changes; it can still be renamed.
327
+
328
+ ```ts
329
+ const all = await client.lists.list();
330
+ const promo = all.find((l) => l.name === 'Fall promo');
331
+
332
+ await client.lists.addMembers(promo.id, [{ entity_type: 'customer', entity_id: customerId }]);
333
+ await client.lists.removeMembers(promo.id, [{ entity_type: 'customer', entity_id: otherId }]);
334
+
335
+ const { data: people } = await client.lists.getRecipients(promo.id, { page: 1, limit: 50 });
336
+ await client.lists.update(promo.id, { name: 'Fall promo 2026' });
337
+ await client.lists.delete(promo.id); // soft delete; the records are untouched
338
+ ```
339
+
340
+ ### Custom field definitions, webhook subscriptions, users and teams (0.72.0)
341
+
342
+ **Custom field definitions** can now be configured, not just read. With the
343
+ admin grant on custom field definitions:
344
+
345
+ ```ts
346
+ const field = await client.customFields.create({
347
+ entityType: 'customer',
348
+ fieldKey: 'crm_id', // lower_snake_case; can't change later
349
+ label: 'CRM ID',
350
+ fieldType: 'text',
351
+ searchable: true,
352
+ });
353
+ await client.customFields.update(field.id, { label: 'External CRM ID' });
354
+ await client.customFields.deactivate(field.id); // hidden; stored values stay valid
355
+ ```
356
+
357
+ `listAll` and `get` read definitions through the same admin route. Definitions
358
+ can be created for contacts, customers and opportunities. Jobs, vendors and
359
+ agents get a 400 until those records can hold values.
360
+
361
+ **Webhook subscriptions** (`client.webhookSubscriptions`; the `open_api`
362
+ feature and admin grants): `create` (returns the `whsec_` signing secret once),
363
+ `list`, `get`, `update`, `delete`, `test` (sends one signed test event now),
364
+ `rotateSecret`, `listDeliveries` and `retryDelivery`. `client.webhooks` is still
365
+ the event router for verifying and handling deliveries.
366
+
367
+ **Users and teams, read-only:** `client.users.list`, `client.users.directory`
368
+ (ids, names and roles of active users, readable by every role),
369
+ `client.users.getWorkingHours`, `client.teams.list` and `client.teams.get`.
370
+ Inviting, editing and removing users, and changing teams, stay in the app.
371
+
372
+ ### Records: estimates, jobs, contracts, invoices, customers, calendar events (0.72.0)
373
+
374
+ - **Estimates:** line items (`createLineItem`, `updateLineItem`, `deleteLineItem`,
375
+ `reorderLineItems`), `updateStatus`, and pipeline moves (`getWorkflow`,
376
+ `listTransitions`, `transition`).
377
+ - **Jobs:** pipeline moves (`getWorkflow`, `listTransitions`, `transition`).
378
+ Material use is on `inventory`: `recordJobConsumption`, `listJobConsumption`,
379
+ `returnFromJob`, `getJobMaterialVariance`.
380
+ - **Contracts:** `createForSubject`, `createFlat`, `submit`, `approve`, `cancel`,
381
+ `reopen`, `send`, `createChangeOrder`, `listChangeOrders`, `downloadPdf`.
382
+ - **Invoices:** `listPayments`, `getPaymentRecordingSettings` (reads only).
383
+ - **Customers:** `convertToCompany`. **Calendar events:** `cancel`,
384
+ `addParticipant`, `updateParticipantStatus`.
385
+
386
+ `contracts.send()` emails the signer a signing link. A `transition()` runs the
387
+ actions the tenant attached to that move, which can include sending email or
388
+ an invoice. None of these take an idempotency key, so retrying after a timeout
389
+ can repeat the email or the actions.
390
+
391
+ ### Service schedules (0.71.0)
392
+
393
+ `client.serviceSchedules` now covers every `/service-schedules` route: `list`,
394
+ `get`, `create`, `update`, `previewUpdate`, `setStatus`, `delete`,
395
+ `previewConflicts`, and the dispatch settings (`getCrewRoles`, `setCrewRoles`,
396
+ `listCrew`, `setCrewHomeAddress`).
397
+
398
+ Changing a schedule's cadence, time, crew, duration or horizon regenerates the
399
+ visits already on the calendar, from today. A visit
400
+ someone changed (moved, re-crewed, confirmed or started, past its first stage,
401
+ invoiced or marked do-not-bill) is never touched; it comes back under
402
+ `protected` with its reasons.
403
+
404
+ ```ts
405
+ const edit = { dtstart: '2026-12-03T09:00:00-06:00' }; // move to Thursdays
406
+ const preview = await client.serviceSchedules.previewUpdate(scheduleId, edit);
407
+ // { removed: 8, added: 8, updated: 0, protected: [...] }
408
+ const { regeneration } = await client.serviceSchedules.update(scheduleId, edit);
409
+ ```
410
+
411
+ ### Tag merge (0.70.0)
412
+
413
+ `client.tags.merge(winnerId, loserId)` folds a duplicate tag into another: every record tagged with the loser moves to the winner, and the loser is deleted.
414
+
415
+ ### Opportunity pipeline moves (0.69.0)
416
+
417
+ ```ts
418
+ const { transitions } = await client.opportunities.listTransitions(oppId);
419
+ await client.opportunities.transition(oppId, { transition_id: transitions[0].id as string, notes: 'Quote sent' });
420
+ const workflow = await client.opportunities.getWorkflow(oppId); // stage + history
421
+ ```
422
+
423
+ A move runs the stage's automations, exactly as the stage button in Planes does
424
+ (they can send the customer a contract or an invoice).
425
+
426
+ ### Properties (0.68.0)
427
+
428
+ `client.properties` gains `getForCustomer(customerId, id)` (404 if the property
429
+ isn't that customer's) and `geocode(id)`, which queues a fresh geocode.
430
+
431
+ ### Lead sources (0.67.0)
432
+
433
+ `client.leadSources` lists, creates, updates and deletes lead sources. A
434
+ lead source's `key` is what a customer's `source` takes:
435
+
436
+ ```ts
437
+ const sources = await client.leadSources.list(); // active ones, in display order
438
+ await client.customers.update(customerId, { source: sources[0].key });
439
+ ```
440
+
441
+ ### Contact links, roles and pipeline (0.66.0)
442
+
443
+ `client.contacts.get(id)` returns a contact with its channels and active links.
444
+ The rest of the contact surface:
445
+
446
+ ```ts
447
+ await client.contacts.listLinks(contactId); // active and ended links (history)
448
+ await client.contacts.addLink(contactId, { targetType: 'customer', targetId, role: 'GM' });
449
+ await client.contacts.updateLink(contactId, linkId, { endedAt: new Date().toISOString() }); // end a link
450
+ await client.contacts.listRoles(); // roles in use, for a picker
451
+
452
+ await client.contacts.startWorkflow(contactId); // opt into the relationship pipeline
453
+ const wf = await client.contacts.getWorkflow(contactId); // stage + available transitions
454
+ await client.contacts.transition(contactId, transitionId);
455
+ await client.contacts.getWorkflowHistory(contactId);
456
+ ```
457
+
458
+ ### Contact sequences
459
+
460
+ `client.contactSequences.list()` returns the tenant's published contact
461
+ sequences as `{ data: [{ id, name, description }], meta: { total } }`
462
+ (name-sorted, max 100), readable by any role that can enroll a contact. Each
463
+ `id` is the sequence's lineage root — the `workflow_id` that
464
+ `POST /contacts/:id/enrollments` accepts:
465
+
466
+ ```ts
467
+ const { data: sequences } = await client.contactSequences.list();
468
+ ```
469
+
470
+ ### Sequence enrollments
471
+
472
+ `client.enrollments` enrolls a contact in a sequence (the `id` from
473
+ `contactSequences.list()`), lists a contact's enrollments, and exits one:
474
+
475
+ ```ts
476
+ const enrollment = await client.enrollments.enroll(contactId, sequences[0].id);
477
+ // 409 if already active in that sequence; 422 if it has no published version
478
+
479
+ const active = await client.enrollments.list(contactId, { status: 'active' });
480
+ // bare array, newest first — each row carries sequenceName + currentStageName
481
+
482
+ await client.enrollments.exit(enrollment.id); // reason 'manual'; idempotent
483
+ ```
484
+
485
+ ### Workflow version stages
486
+
487
+ `client.workflows.getStages(workflowId)` returns the ordered stages of one
488
+ workflow version — `{ id, name, entityType, version, status, stages: [{ id,
489
+ name, color, icon, sortOrder, isInitial, isTerminal }] }`. Pass a record's
490
+ instance `workflowId` to draw its progress against the version it is pinned
491
+ to, even an archived one after a newer version was published. Readable by any
492
+ role that can read the workflow's record type (403 otherwise; 404 for an
493
+ unknown or draft id):
494
+
495
+ ```ts
496
+ const { stages } = await client.workflows.getStages(instance.workflowId);
497
+ ```
498
+
499
+ A stage's `color` is a hex code or `null`. The API only accepts `#RGB` or
500
+ `#RRGGBB` when a stage is written, and stores it as uppercase `#RRGGBB`.
501
+ A stage saved before that rule may still hold some other string, so check
502
+ the value before using it as a CSS colour.
503
+
504
+ ### Workflow transition summaries
505
+
506
+ `client.workflows.listTransitions({ entityTypes, workflowId })` returns
507
+ `{ transitions: [{ id, name, from_stage_name, to_stage_name, workflow_name,
508
+ entity_type, workflow_id, workflow_version, lineage_workflow_id,
509
+ automatic_only }] }`. With no `workflowId` it lists every published
510
+ workflow's transitions; with one it lists that version's, published or
511
+ archived, so a record pinned to an older version gets its own steps (an
512
+ unknown, draft or other-tenant id returns an empty list). `lineage_workflow_id`
513
+ is the same for every version of one pipeline. Both filters together are an
514
+ AND. Only pipelines whose record type the caller can read are listed (the
515
+ same rule as `getStages`): a `workflowId` of a type the key's role can't read
516
+ returns an empty list, not 403, and an unreadable type in `entityTypes` is
517
+ dropped:
518
+
519
+ ```ts
520
+ const { transitions } = await client.workflows.listTransitions({ workflowId: instance.workflowId });
521
+ const manual = transitions.filter((t) => !t.automatic_only);
522
+ ```
523
+
524
+ ### Workflow event rules (0.51.0)
525
+
526
+ `client.workflows.eventRules(workflowId)` manages a workflow's "advance
527
+ automatically when…" rules on the admin workflow API (manage Workflow + the
528
+ `workflow_builder` feature): `list()`, `create(input)`, `update(ruleId, input)`
529
+ and `delete(ruleId)`. A rule fires `transitionRef` (stage NAMES, resolved
530
+ against the version each record is pinned to) when a related contract, invoice
531
+ or calendar event emits `eventType` and its `changes` match `when`. Any
532
+ version's id works; rules are keyed by the workflow's lineage root, so they
533
+ survive a publish. `list()` is a bare array, newest first; `delete()` is a soft
534
+ delete returning `{ id }`.
535
+
536
+ Since 0.53.0 a rule carries exactly `id`, `workflowId`, `eventType`, `when`,
537
+ `transitionRef`, `enabled`, `createdAt` and `updatedAt` (no `tenantId`,
538
+ `createdBy` or `deletedAt`), and `update`/`delete` only reach a rule through an
539
+ id of its own pipeline (any version); another pipeline's id is a 404
540
+ `EVENT_RULE_NOT_FOUND`. A path id that isn't a uuid is a 400.
541
+
542
+ ```ts
543
+ const rules = client.workflows.eventRules(workflowId);
544
+
545
+ // Advance when the contract is sent.
546
+ await rules.create({
547
+ eventType: 'contract.status_changed',
548
+ when: { status: { new: 'sent' } },
549
+ transitionRef: { from_stage_name: 'Estimate', to_stage_name: 'Contract Sent' },
550
+ });
551
+
552
+ // Advance when an event is booked. Creation types carry no `changes`, so the
553
+ // only matcher is `when: {}` (see WORKFLOW_EVENT_RULE_MATCH_ALL_EVENT_TYPES).
554
+ const booked = await rules.create({
555
+ eventType: 'calendar_event.created',
556
+ when: {},
557
+ transitionRef: { from_stage_name: 'New', to_stage_name: 'Booked' },
558
+ });
559
+
560
+ await rules.update(booked.id, { enabled: false }); // only the fields sent change
561
+ await rules.delete(booked.id);
562
+ ```
563
+
564
+ Event types: `WORKFLOW_EVENT_RULE_EVENT_TYPES` (`contract.status_changed`,
565
+ `invoice.status_changed`, `calendar_event.status_changed`,
566
+ `calendar_event.created`). A body the server rejects is a 400
567
+ `VALIDATION_FAILED`; an unknown workflow is 404 `WORKFLOW_NOT_FOUND`, an
568
+ unknown or deleted rule 404 `EVENT_RULE_NOT_FOUND`.
569
+
570
+ ### Job trade coverage
571
+
572
+ `client.jobs.getTradeCoverage()` returns every active trade with whether a job
573
+ can be created under it — `{ trades: [{ tradeId, name, hasWorkflow }] }`,
574
+ ordered by name. `hasWorkflow` is true when a published job workflow resolves
575
+ for the trade (its own or the generic one); creating a job under a trade with
576
+ `hasWorkflow: false` returns 422 `WORKFLOW_NOT_CONFIGURED`. Readable by any
577
+ role that can create jobs (403 otherwise):
578
+
579
+ ```ts
580
+ const { trades } = await client.jobs.getTradeCoverage();
581
+ const creatable = trades.filter((t) => t.hasWorkflow);
582
+ ```
583
+
584
+ ### Estimates across an opportunity
585
+
586
+ `client.estimates.listForOpportunity(opportunityId, { status, page, limit })`
587
+ returns every estimate across all of the opportunity's jobs in one request
588
+ (newest first, each row carrying `jobId`; `limit` max 100):
589
+
590
+ ```ts
591
+ const { data } = await client.estimates.listForOpportunity('opp_123', { limit: 100 });
592
+ ```
40
593
 
41
594
  ### Errors
42
595
 
@@ -44,7 +597,7 @@ Every failure throws a subclass of `PlanesError`, each carrying `status` and
44
597
  `code`:
45
598
 
46
599
  ```ts
47
- import { PlanesError, RateLimitError, ValidationError } from '@planes/sdk';
600
+ import { PlanesError, RateLimitError, ValidationError } from '@planescrm/sdk';
48
601
 
49
602
  try {
50
603
  await client.opportunities.get('missing');
@@ -93,7 +646,7 @@ Use the OAuth helpers to drive the login redirect and exchange the returned code
93
646
  for tokens.
94
647
 
95
648
  ```ts
96
- import { buildAuthorizeUrl, exchangeCode } from '@planes/sdk';
649
+ import { buildAuthorizeUrl, exchangeCode } from '@planescrm/sdk';
97
650
 
98
651
  const baseUrl = 'https://api.planescrm.com/api/v1';
99
652
 
@@ -115,7 +668,7 @@ const tokens = await exchangeCode({
115
668
  });
116
669
 
117
670
  // 3. Seed an OAuth-managed client so refreshes happen automatically.
118
- import { PlanesClient, MemoryTokenStorage } from '@planes/sdk';
671
+ import { PlanesClient, MemoryTokenStorage } from '@planescrm/sdk';
119
672
 
120
673
  const client = new PlanesClient({
121
674
  clientId: process.env.PLANES_CLIENT_ID!,
@@ -135,7 +688,7 @@ gives a chainable builder; call `registerAction(type, handler)` per action and
135
688
  `listen()` to start.
136
689
 
137
690
  ```ts
138
- import { createAgentApp } from '@planes/sdk';
691
+ import { createAgentApp } from '@planescrm/sdk';
139
692
 
140
693
  createAgentApp()
141
694
  .registerAction('enrich-opportunity', async (ctx) => {