ab-ecosystem-cpass 1.0.7 → 1.0.8

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.
Files changed (38) hide show
  1. package/README.md +5 -1
  2. package/dist/client.d.ts +24 -0
  3. package/dist/client.js +28 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +7 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/resources/audience/canned-replies.d.ts +95 -0
  9. package/dist/resources/audience/canned-replies.js +112 -0
  10. package/dist/resources/audience/canned-replies.js.map +1 -0
  11. package/dist/resources/audience/notifications.d.ts +14 -0
  12. package/dist/resources/audience/notifications.js +14 -0
  13. package/dist/resources/audience/notifications.js.map +1 -1
  14. package/dist/resources/campaign-messages.d.ts +25 -22
  15. package/dist/resources/campaign-messages.js +34 -27
  16. package/dist/resources/campaign-messages.js.map +1 -1
  17. package/dist/resources/data-stores.d.ts +469 -0
  18. package/dist/resources/data-stores.js +530 -0
  19. package/dist/resources/data-stores.js.map +1 -0
  20. package/dist/resources/paths.d.ts +6 -0
  21. package/dist/resources/paths.js +7 -1
  22. package/dist/resources/paths.js.map +1 -1
  23. package/dist/types/campaign.d.ts +11 -5
  24. package/dist/types/canned-reply.d.ts +124 -0
  25. package/dist/types/canned-reply.js +6 -0
  26. package/dist/types/canned-reply.js.map +1 -0
  27. package/dist/types/datastore.d.ts +260 -0
  28. package/dist/types/datastore.js +21 -0
  29. package/dist/types/datastore.js.map +1 -0
  30. package/dist/types/index.d.ts +2 -0
  31. package/dist/types/index.js +3 -0
  32. package/dist/types/index.js.map +1 -1
  33. package/docs/audience/canned-replies.md +302 -0
  34. package/docs/audience/notifications.md +99 -0
  35. package/docs/campaign/campaign-messages.md +31 -22
  36. package/docs/campaign/campaigns.md +1 -1
  37. package/docs/data-store.md +495 -0
  38. package/package.json +1 -1
@@ -0,0 +1,302 @@
1
+ # Canned replies
2
+
3
+ `crm.cannedReplies` — `/v1/audience/canned-replies`
4
+
5
+ Message payloads an agent sends into a chat without retyping them. The panel
6
+ offers them when an agent types `/` followed by the reply's name.
7
+
8
+ Two kinds, and the difference is who gets the last word:
9
+
10
+ - **`CANNED`** — every message the reply holds is sent exactly as stored, in
11
+ order, the moment it is picked. A three-message reply puts three messages on
12
+ the contact's phone from one keystroke.
13
+ - **`QUICK`** — the reply's text lands in the agent's composer to edit before
14
+ sending. Exactly one message, of type `text`: there is one composer, and
15
+ nowhere in it to put a second message or an image.
16
+
17
+ | Action | Route | Scope |
18
+ | --- | --- | --- |
19
+ | [`list`](#list) | `GET /v1/audience/canned-replies` | `canned_replies:read` |
20
+ | [`get`](#get) | `GET /v1/audience/canned-replies/:reply` | `canned_replies:read` |
21
+ | [`create`](#create) | `POST /v1/audience/canned-replies` | `canned_replies:write` |
22
+ | [`update`](#update) | `PATCH /v1/audience/canned-replies/:reply` | `canned_replies:write` |
23
+ | [`delete`](#delete) | `DELETE /v1/audience/canned-replies/:reply` | `canned_replies:write` |
24
+
25
+ ## Addressing a reply: `:reply` is an id **or** a name
26
+
27
+ A 24-hex-character path segment is read as an **id**; anything else as a
28
+ **name**. Names are unique per account, which is what makes addressing by the
29
+ name an agent typed safe — and it is the only identifier the panel has at the
30
+ moment of picking one.
31
+
32
+ There is no fallback between the two. A 24-hex id that no longer exists is a
33
+ `404`, never a name lookup, so a request for a deleted reply cannot quietly match
34
+ a different one that happened to be named after its id. The one real ambiguity is
35
+ a reply *named* 24 hex characters: it would be read as an id and 404. Rename it.
36
+
37
+ ## The reply object
38
+
39
+ ```ts
40
+ interface CannedReply {
41
+ id: string;
42
+ name: string; // unique per account; what the agent types after `/`
43
+ type: 'CANNED' | 'QUICK';
44
+ channel: 'whatsapp' | 'sms' | 'email' | 'rcs' | 'webchat';
45
+ messages: CannedReplyMessage[];
46
+ createdAt: string; // ISO
47
+ updatedAt: string; // ISO
48
+ }
49
+ ```
50
+
51
+ `channel` is a channel **type**, never a channel id, so one reply works across
52
+ every number on the account. `whatsapp` is the only value the panel offers today,
53
+ and the reason is `whatsapp_api` below: a stored message is a literal WhatsApp
54
+ Cloud API message object, so a reply is only sendable on a channel that speaks
55
+ that shape.
56
+
57
+ `messages` comes back in full from **every** endpoint, `list` included. It is the
58
+ entire point of the record, and a picker that had to fetch each reply before it
59
+ could show what the reply says would make one request per keystroke.
60
+
61
+ ## The message object
62
+
63
+ ```ts
64
+ interface CannedReplyMessageV2 {
65
+ version: '2';
66
+ whatsapp_api: MessageContent; // the payload as the provider takes it
67
+ internal?: Record<string, unknown>; // the editor's working copy; never read by the server
68
+ }
69
+ ```
70
+
71
+ `whatsapp_api` is the same object `crm.inbox.messages.send()` accepts as
72
+ `content`. That is the point: picking a reply *is* sending its payloads, with no
73
+ translation step in between.
74
+
75
+ ```json
76
+ { "version": "2",
77
+ "whatsapp_api": { "type": "text", "text": { "body": "Hi!", "preview_url": false } } }
78
+ ```
79
+
80
+ ```json
81
+ { "version": "2",
82
+ "whatsapp_api": { "type": "image", "image": { "link": "https://...", "caption": "Our menu" } } }
83
+ ```
84
+
85
+ ```json
86
+ { "version": "2",
87
+ "whatsapp_api": { "type": "interactive", "interactive": {
88
+ "type": "button",
89
+ "body": { "text": "Pick one" },
90
+ "action": { "buttons": [ { "type": "reply", "reply": { "id": "btn_0", "title": "Yes" } } ] } } } }
91
+ ```
92
+
93
+ `internal` is the form state a composer needs to **reopen** the message for
94
+ editing — which media type was chosen, whether the upload was still local, the
95
+ button rows before they were flattened into an action. Stored and returned
96
+ verbatim, never read by the server. It is optional: a reply created through the
97
+ API without one is valid, and an editor just opens it in whatever state it can
98
+ infer from `whatsapp_api`.
99
+
100
+ ### Variables are not substituted here
101
+
102
+ `{{first_name}}` and friends are stored **literally**. Substitution happens at
103
+ the moment of sending, against the contact in front of the agent — there is no
104
+ contact when a reply is written, so there is nothing here to substitute against.
105
+
106
+ ### Reading older rows
107
+
108
+ Rows written by the previous editor are still in the collection and carry a
109
+ different shape entirely: a flat `type` plus a `components` array in template
110
+ form. They come back exactly as stored, because synthesising a `whatsapp_api` for
111
+ them would mean inventing a payload nobody approved.
112
+
113
+ Narrow before you read:
114
+
115
+ ```ts
116
+ for (const message of reply.messages) {
117
+ if (message.version === '2') {
118
+ send(message.whatsapp_api);
119
+ } else {
120
+ // A v1 row. Render it read-only, or ask the user to re-save the reply.
121
+ }
122
+ }
123
+ ```
124
+
125
+ New writes are v2 only — there is no way to create a v1 row through this API.
126
+
127
+ ---
128
+
129
+ ## list
130
+
131
+ ```ts
132
+ crm.cannedReplies.list(query?: Partial<ListCannedRepliesQuery>): Promise<Paginated<CannedReply>>
133
+ ```
134
+
135
+ `GET /v1/audience/canned-replies` · `canned_replies:read` · **200**
136
+
137
+ Offset paginated, newest first.
138
+
139
+ ### Query
140
+
141
+ | Field | Type | Default | Notes |
142
+ | --- | --- | --- | --- |
143
+ | `page` | number | `1` | 1-based. |
144
+ | `rows` | number | `25` | 1–100. |
145
+ | `search` | string | — | 1–120 chars. Matches the **name**, case-insensitively, anywhere in it. The term is escaped, so `(` and `.` are searched for rather than compiled. |
146
+ | `type` | `'CANNED' \| 'QUICK'` | — | — |
147
+ | `channel` | channel **type** | — | e.g. `whatsapp`. Not a channel id. |
148
+
149
+ ```ts
150
+ // What the chat panel asks for: the replies usable on the open chat's channel.
151
+ const { data } = await crm.cannedReplies.list({ channel: 'whatsapp', rows: 100 });
152
+ ```
153
+
154
+ To count without fetching, ask for one row and read the total:
155
+
156
+ ```ts
157
+ const { pagination } = await crm.cannedReplies.list({ rows: 1 });
158
+ pagination.total; // how many replies the account has
159
+ ```
160
+
161
+ > There is no `isListing` flag. Legacy had one, and all it dropped from the
162
+ > response was `createdAt` and `updatedAt` — it kept `messages`, the only field
163
+ > in the record with any weight.
164
+
165
+ ---
166
+
167
+ ## get
168
+
169
+ ```ts
170
+ crm.cannedReplies.get(ref: string): Promise<CannedReply>
171
+ ```
172
+
173
+ `GET /v1/audience/canned-replies/:reply` · `canned_replies:read` · **200**
174
+
175
+ `ref` is an id or a name — see [addressing](#addressing-a-reply-reply-is-an-id-or-a-name).
176
+
177
+ `404 not_found` if the reply is not the caller's.
178
+
179
+ ```ts
180
+ const reply = await crm.cannedReplies.get('price list');
181
+ ```
182
+
183
+ ---
184
+
185
+ ## create
186
+
187
+ ```ts
188
+ crm.cannedReplies.create(input: CreateCannedReplyInput): Promise<CannedReply>
189
+ ```
190
+
191
+ `POST /v1/audience/canned-replies` · `canned_replies:write` · **201**
192
+
193
+ ### Body
194
+
195
+ | Field | Type | Required | Default | Notes |
196
+ | --- | --- | --- | --- | --- |
197
+ | `name` | string | yes | — | Trimmed, 1–120 chars. Unique per account. |
198
+ | `type` | `'CANNED' \| 'QUICK'` | yes | — | — |
199
+ | `channel` | channel type | no | `'whatsapp'` | — |
200
+ | `messages` | `CannedReplyMessageInput[]` | yes | — | 1–10 entries. See [the message object](#the-message-object). |
201
+
202
+ `messages[].version` defaults to `'2'` and is the only accepted value;
203
+ `messages[].whatsapp_api` is required; `messages[].internal` is optional.
204
+
205
+ ```ts
206
+ const reply = await crm.cannedReplies.create({
207
+ name: 'price list',
208
+ type: 'CANNED',
209
+ channel: 'whatsapp',
210
+ messages: [
211
+ { version: '2', whatsapp_api: { type: 'text', text: { body: 'Here you go' } } },
212
+ { version: '2', whatsapp_api: {
213
+ type: 'document',
214
+ document: { link: 'https://.../prices.pdf', filename: 'prices.pdf' } } },
215
+ ],
216
+ });
217
+ ```
218
+
219
+ ### Errors
220
+
221
+ | Status | Code | When |
222
+ | --- | --- | --- |
223
+ | 409 | `conflict` | The name is already used on this account. |
224
+ | 422 | `invalid_canned_reply` | A `QUICK` reply holds more than one message, or one that is not `text`. |
225
+ | 422 | `validation_failed` | `messages` is empty or longer than 10; a message has no `whatsapp_api.type`; `name` is blank. |
226
+
227
+ The `QUICK` rule is enforced here because legacy did not: it stored a `QUICK`
228
+ reply holding an image happily, and the panel then refused to use it forever —
229
+ the only explanation being a toast at the moment an agent reached for it.
230
+
231
+ ---
232
+
233
+ ## update
234
+
235
+ ```ts
236
+ crm.cannedReplies.update(ref: string, input: UpdateCannedReplyInput): Promise<CannedReply>
237
+ ```
238
+
239
+ `PATCH /v1/audience/canned-replies/:reply` · `canned_replies:write` · **200**
240
+
241
+ `ref` is an id or a name. The body is any subset of `create`'s.
242
+
243
+ `messages` is replaced **whole**, never merged: a reply is an ordered sequence,
244
+ and there is no sensible way to patch element two of a list whose length is also
245
+ changing. Send the array you want stored.
246
+
247
+ Renaming works. It silently did not in legacy, whose update built its `$set` from
248
+ `messages` alone and discarded every other field — while still *requiring* `type`
249
+ and `channel` in the request it then ignored.
250
+
251
+ An empty body is a no-op and returns the reply as it stands.
252
+
253
+ ### Errors
254
+
255
+ | Status | Code | When |
256
+ | --- | --- | --- |
257
+ | 404 | `not_found` | No such reply on this account. |
258
+ | 409 | `conflict` | A rename into a name that is already used. |
259
+ | 422 | `invalid_canned_reply` | The **merged** result breaks the `QUICK` rule. |
260
+
261
+ That last one is checked against the merge, so flipping a three-message reply to
262
+ `QUICK` is refused — while renaming one that *already* breaks the rule is not.
263
+ The check runs only when the patch touches `type` or `messages`: a rename alone
264
+ leaves a reply exactly as valid as it already was, and running the rule
265
+ regardless would make a v1 row unrenameable, punishing existing data for a rule
266
+ introduced after it was written.
267
+
268
+ ```ts
269
+ await crm.cannedReplies.update('price list', {
270
+ messages: [{ version: '2', whatsapp_api: { type: 'text', text: { body: 'Updated prices attached' } } }],
271
+ });
272
+ ```
273
+
274
+ ---
275
+
276
+ ## delete
277
+
278
+ ```ts
279
+ crm.cannedReplies.delete(ref: string): Promise<void>
280
+ ```
281
+
282
+ `DELETE /v1/audience/canned-replies/:reply` · `canned_replies:write` · **204**, no body.
283
+
284
+ `ref` is an id or a name. `404 not_found` if the reply is not the caller's.
285
+
286
+ Nothing else holds a reference to a canned reply — a sent message is a message
287
+ row, with its own copy of the content — so a delete leaves nothing dangling.
288
+
289
+ ---
290
+
291
+ ## Events
292
+
293
+ Each write publishes one, for panels that cache the list:
294
+
295
+ | Event | Payload |
296
+ | --- | --- |
297
+ | `canned_reply.created` | the full `CannedReply` |
298
+ | `canned_reply.updated` | the full `CannedReply` |
299
+ | `canned_reply.deleted` | `{ id, name }` |
300
+
301
+ The delete carries the **name** as well as the id, because a panel caching
302
+ replies by name — which is how an agent picks them — has no id to evict by.
@@ -261,3 +261,102 @@ if (Notification.permission === 'granted') {
261
261
 
262
262
  That also repairs the two cases a stored flag gets wrong: a rotated token, and a
263
263
  row pruned for staleness.
264
+
265
+ ---
266
+
267
+ ## What a device actually receives
268
+
269
+ Subscribing is only half the story. This is what arrives, so a frontend can
270
+ render it and an app can deep-link from it.
271
+
272
+ Today one thing notifies: **a message on a chat the device's owner is
273
+ responsible for.** The payload is FCM's, with the application's own keys under
274
+ `data`:
275
+
276
+ ```json
277
+ {
278
+ "notification": { "title": "Priya", "body": "Is this still available?" },
279
+ "data": {
280
+ "chat": "68f1c2a39b4e5d6f70812345",
281
+ "action_title": "View In Chat Panel",
282
+ "link": "https://app.acme.com/products/crmv2/chat/68f1c2a39b4e5d6f70812345",
283
+ "click_action": "https://app.acme.com/products/crmv2/chat/68f1c2a39b4e5d6f70812345",
284
+ "icon": "https://cdn.acme.com/push-192.png",
285
+ "badge": "https://cdn.acme.com/push-192.png"
286
+ },
287
+ "webpush": {
288
+ "notification": { "icon": "https://cdn.acme.com/push-192.png" },
289
+ "fcmOptions": { "link": "https://app.acme.com/products/crmv2/chat/68f1c2a39b4e5d6f70812345" }
290
+ }
291
+ }
292
+ ```
293
+
294
+ | Key | What it is |
295
+ | --- | --- |
296
+ | `notification.title` | Who wrote — the contact's first name, or `New User`. Not the agency's sender name, which is what every other notification would use. |
297
+ | `notification.body` | The message text, cut to 180 characters. A non-text message is named by its type instead: `Image Message`, `Location Message`. |
298
+ | `data.chat` | The chat id. The same id the inbox endpoints take. |
299
+ | `data.click_action` | **Web:** the absolute panel URL. **Android/iOS:** the bare chat id, because an app resolves a record rather than opening a URL. |
300
+ | `data.link` | Always the absolute URL, on every platform, so a native client can open the panel if it would rather. |
301
+ | `data.image` | An image message's media URL, for a preview. Absent otherwise. |
302
+ | `data.icon`, `data.badge` | The agency's notification icon, for a client drawing the notification itself. |
303
+ | `webpush.fcmOptions.link` | What a **browser** opens on click. This is the mechanism, not `click_action`. |
304
+
305
+ The host in every URL is the one configured for that device's surface, so the
306
+ same notification deep-links to `app.acme.com` for one reseller's user and
307
+ `portal.other.com` for another's.
308
+
309
+ ### Which messages notify
310
+
311
+ | The message | Notifies |
312
+ | --- | --- |
313
+ | A contact wrote in | yes |
314
+ | Somebody replied from the WhatsApp Business app | yes |
315
+ | An agent replied from the panel | no |
316
+ | A bot replied | no |
317
+ | A campaign sent | no |
318
+ | An API send | no |
319
+ | A delivery receipt moved to delivered or read | no |
320
+
321
+ A tick changing on a screen is not worth interrupting anyone for, and nobody
322
+ needs telling about a message they just sent themselves.
323
+
324
+ ### Who it reaches
325
+
326
+ The chat's **assigned agent**, or the **account owner** while it is unassigned
327
+ — one person, not the team. An unassigned chat belongs to whoever is minding
328
+ the queue.
329
+
330
+ There is no presence check: an agent with the conversation open on screen still
331
+ gets the push on their phone. That is the behaviour the panel has always had, and
332
+ changing it is a product decision rather than a detail of delivery.
333
+
334
+ ### Rendering it in a browser
335
+
336
+ If the payload carries a `notification` block, FCM displays it and opens
337
+ `webpush.fcmOptions.link` on click with no code from you — which is the
338
+ default worth keeping. A service worker that wants to draw its own instead
339
+ should read the `data` keys above:
340
+
341
+ ```js
342
+ messaging.onBackgroundMessage((payload) => {
343
+ self.registration.showNotification(payload.notification.title, {
344
+ body: payload.notification.body,
345
+ icon: payload.data.icon,
346
+ image: payload.data.image,
347
+ data: { url: payload.data.link },
348
+ });
349
+ });
350
+
351
+ self.addEventListener('notificationclick', (event) => {
352
+ event.notification.close();
353
+ event.waitUntil(clients.openWindow(event.notification.data.url));
354
+ });
355
+ ```
356
+
357
+ Two things to know if you are porting the existing worker. A notification FCM
358
+ displayed itself nests the payload under `notification.data.FCM_MSG`, so reading
359
+ `event.notification.data.click_action` off it finds nothing — take the link
360
+ from your own `showNotification` call, as above. And `localStorage` does not
361
+ exist in a service worker: a gate written against it throws, and the handler
362
+ around it never runs.
@@ -1,16 +1,28 @@
1
1
  # Campaign messages (reporting)
2
2
 
3
- `crm.campaignMessages` — `/v1/campaign-messages`
3
+ `crm.campaignMessages` — `/v1/campaigns/:id/report`
4
4
 
5
5
  What actually happened to each message a campaign sent. One row per attempted
6
6
  send: who it went to, how far it got, what came back, and why it failed.
7
7
 
8
8
  | Action | Route | Scope |
9
9
  | --- | --- | --- |
10
- | [`stats`](#stats) | `GET /v1/campaign-messages/stats` | `campaigns:read` |
11
- | [`list`](#list) | `GET /v1/campaign-messages` | `campaigns:read` |
10
+ | [`stats`](#stats) | `GET /v1/campaigns/:id/report` | `campaigns:read` |
11
+ | [`list`](#list) | `GET /v1/campaigns/:id/report/messages` | `campaigns:read` |
12
12
  | [`iterate`](#iterate) | the same, paged for you | `campaigns:read` |
13
- | [`replies`](#replies) | `GET /v1/campaign-messages/replies` | `campaigns:read` |
13
+ | [`replies`](#replies) | `GET /v1/campaigns/:id/report/replies` | `campaigns:read` |
14
+
15
+ ## The campaign is the path
16
+
17
+ A report has no meaning without a campaign, so it is addressed as the campaign's
18
+ own sub-resource rather than as a collection of rows that a campaign filters.
19
+ Every method takes the id first, and the options object holds only what
20
+ *narrows* a report that already exists — a message index, a status, a page.
21
+
22
+ ```ts
23
+ await crm.campaignMessages.stats(campaignId);
24
+ await crm.campaignMessages.list(campaignId, { status: 'failed' });
25
+ ```
14
26
 
15
27
  ## Where these numbers come from
16
28
 
@@ -96,23 +108,22 @@ those having gone to one person, and there is one per recipient per message.
96
108
  ## stats
97
109
 
98
110
  ```ts
99
- crm.campaignMessages.stats(query: CampaignMessageStatsQuery): Promise<CampaignMessageStats>
111
+ crm.campaignMessages.stats(campaign: string, query?: CampaignMessageStatsQuery): Promise<CampaignMessageStats>
100
112
  ```
101
113
 
102
- `GET /v1/campaign-messages/stats` · `campaigns:read` · **200**
114
+ `GET /v1/campaigns/:id/report` · `campaigns:read` · **200**
103
115
 
104
116
  ### Query
105
117
 
106
118
  | Field | Type | Required | Notes |
107
119
  | --- | --- | --- | --- |
108
- | `campaign` | string | yes | The campaign id, as a 24-character ObjectId. |
109
- | `message` | number | no | One message of a multi-message campaign, zero-based. |
120
+ | `message` | number | no | One message of a multi-message campaign, zero-based. Omit for the whole campaign. |
110
121
 
111
122
  ```ts
112
- const overall = await crm.campaignMessages.stats({ campaign });
123
+ const overall = await crm.campaignMessages.stats(campaign);
113
124
  // { total: 12480, sent: 12410, delivered: 11980, read: 7420, replied: 310, failed: 70 }
114
125
 
115
- const second = await crm.campaignMessages.stats({ campaign, message: 1 });
126
+ const second = await crm.campaignMessages.stats(campaign, { message: 1 });
116
127
  ```
117
128
 
118
129
  **No date window**, deliberately. A campaign is a single send — its rows are
@@ -134,16 +145,15 @@ someone else's id is real.
134
145
  ## list
135
146
 
136
147
  ```ts
137
- crm.campaignMessages.list(query: ListCampaignMessagesQuery): Promise<Paginated<CampaignMessageRecord>>
148
+ crm.campaignMessages.list(campaign: string, query?: ListCampaignMessagesQuery): Promise<Paginated<CampaignMessageRecord>>
138
149
  ```
139
150
 
140
- `GET /v1/campaign-messages` · `campaigns:read` · **200**
151
+ `GET /v1/campaigns/:id/report/messages` · `campaigns:read` · **200**
141
152
 
142
153
  ### Query
143
154
 
144
155
  | Field | Type | Default | Notes |
145
156
  | --- | --- | --- | --- |
146
- | `campaign` | string | — | Required. |
147
157
  | `message` | number | — | Zero-based index of one campaign message. |
148
158
  | `status` | `sent` `delivered` `read` `replied` `failed` | — | Cumulative; see below. Omit for every row. |
149
159
  | `exact` | boolean | `false` | Only rows sitting at `status` right now. |
@@ -155,10 +165,10 @@ crm.campaignMessages.list(query: ListCampaignMessagesQuery): Promise<Paginated<C
155
165
 
156
166
  ```ts
157
167
  // the two queries a report screen actually makes
158
- const failures = await crm.campaignMessages.list({ campaign, status: 'failed', rows: 100 });
168
+ const failures = await crm.campaignMessages.list(campaign, { status: 'failed', rows: 100 });
159
169
  failures.data[0].error; // why that one did not go
160
170
 
161
- const unread = await crm.campaignMessages.list({ campaign, status: 'delivered', exact: true });
171
+ const unread = await crm.campaignMessages.list(campaign, { status: 'delivered', exact: true });
162
172
  unread.pagination.total; // equals stats.delivered - stats.read
163
173
  ```
164
174
 
@@ -175,7 +185,7 @@ beyond it.
175
185
  ### Reply filters
176
186
 
177
187
  `reply_type` and `replied_within_hours` are **refused** with
178
- `400 validation_failed` unless `status` is `replied`. They cannot match anything
188
+ `422 validation_failed` unless `status` is `replied`. They cannot match anything
179
189
  on a non-replied row, and a filter that cannot match is a caller mistake worth
180
190
  reporting rather than an empty page.
181
191
 
@@ -194,7 +204,7 @@ paging over a list that reorders itself skips and repeats rows.
194
204
  ## iterate
195
205
 
196
206
  ```ts
197
- crm.campaignMessages.iterate(query: ListCampaignMessagesQuery): AsyncGenerator<CampaignMessageRecord>
207
+ crm.campaignMessages.iterate(campaign: string, query?: ListCampaignMessagesQuery): AsyncGenerator<CampaignMessageRecord>
198
208
  ```
199
209
 
200
210
  Every matching row, paged through for you. **This is what an export is** — there
@@ -202,7 +212,7 @@ is no export endpoint, nothing to queue, nothing to poll, and no generated file
202
212
  that expires before someone downloads it.
203
213
 
204
214
  ```ts
205
- for await (const row of crm.campaignMessages.iterate({ campaign })) {
215
+ for await (const row of crm.campaignMessages.iterate(campaign)) {
206
216
  csv.write([row.identifier, row.name, row.status, row.error ?? '']);
207
217
  }
208
218
  ```
@@ -221,21 +231,20 @@ repeat or miss a row. For a stable snapshot, export after the campaign completes
221
231
  ## replies
222
232
 
223
233
  ```ts
224
- crm.campaignMessages.replies(query: CampaignRepliesQuery): Promise<CampaignReplyBreakdown>
234
+ crm.campaignMessages.replies(campaign: string, query?: CampaignRepliesQuery): Promise<CampaignReplyBreakdown>
225
235
  ```
226
236
 
227
- `GET /v1/campaign-messages/replies` · `campaigns:read` · **200**
237
+ `GET /v1/campaigns/:id/report/replies` · `campaigns:read` · **200**
228
238
 
229
239
  ### Query
230
240
 
231
241
  | Field | Type | Default | Notes |
232
242
  | --- | --- | --- | --- |
233
- | `campaign` | string | — | Required. |
234
243
  | `message` | number | — | Zero-based index of one campaign message. |
235
244
  | `limit` | number | `50` | Distinct replies to return, 1–200. |
236
245
 
237
246
  ```ts
238
- const { groups, replied, distinct } = await crm.campaignMessages.replies({ campaign });
247
+ const { groups, replied, distinct } = await crm.campaignMessages.replies(campaign);
239
248
 
240
249
  groups; // [{ reply: 'Yes', replyType: 'button', count: 182 }, { reply: 'No', … }]
241
250
  replied; // 310 — every replied row, whether or not its group is listed
@@ -444,6 +444,6 @@ send, its status is advanced by the channel's delivery webhooks, and every numbe
444
444
  is counted off those rows when you ask for it:
445
445
 
446
446
  ```ts
447
- const stats = await crm.campaignMessages.stats({ campaign: id });
447
+ const stats = await crm.campaignMessages.stats(id);
448
448
  // { total, sent, delivered, read, replied, failed }
449
449
  ```