ab-ecosystem-cpass 1.0.5 → 1.0.7

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 (42) hide show
  1. package/README.md +62 -1
  2. package/dist/client.d.ts +24 -0
  3. package/dist/client.js +32 -5
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.js +20 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal/bytes.d.ts +38 -0
  9. package/dist/internal/bytes.js +84 -0
  10. package/dist/internal/bytes.js.map +1 -0
  11. package/dist/internal/device.d.ts +24 -0
  12. package/dist/internal/device.js +91 -0
  13. package/dist/internal/device.js.map +1 -0
  14. package/dist/internal/md5.d.ts +4 -0
  15. package/dist/internal/md5.js +109 -0
  16. package/dist/internal/md5.js.map +1 -0
  17. package/dist/internal/receiver-file.d.ts +106 -0
  18. package/dist/internal/receiver-file.js +128 -0
  19. package/dist/internal/receiver-file.js.map +1 -0
  20. package/dist/resources/audience/notifications.d.ts +171 -0
  21. package/dist/resources/audience/notifications.js +215 -0
  22. package/dist/resources/audience/notifications.js.map +1 -0
  23. package/dist/resources/campaign-audience.d.ts +132 -0
  24. package/dist/resources/campaign-audience.js +224 -0
  25. package/dist/resources/campaign-audience.js.map +1 -0
  26. package/dist/resources/campaign-messages.d.ts +150 -0
  27. package/dist/resources/campaign-messages.js +172 -0
  28. package/dist/resources/campaign-messages.js.map +1 -0
  29. package/dist/resources/campaigns.d.ts +239 -0
  30. package/dist/resources/campaigns.js +300 -0
  31. package/dist/resources/campaigns.js.map +1 -0
  32. package/dist/types/audience.d.ts +82 -0
  33. package/dist/types/campaign.d.ts +319 -0
  34. package/dist/types/campaign.js +3 -0
  35. package/dist/types/campaign.js.map +1 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/index.js +2 -0
  38. package/dist/types/index.js.map +1 -1
  39. package/docs/audience/notifications.md +263 -0
  40. package/docs/campaign/campaign-messages.md +273 -0
  41. package/docs/campaign/campaigns.md +449 -0
  42. package/package.json +1 -1
@@ -0,0 +1,449 @@
1
+ # Campaigns
2
+
3
+ `crm.campaigns` — `/v1/campaigns`
4
+
5
+ One message sent to many people.
6
+
7
+ | Action | Route | Notes |
8
+ | --- | --- | --- |
9
+ | [`list`](#list) | `GET /v1/campaigns` | cursor-paged |
10
+ | [`get`](#get) | `GET /v1/campaigns/:id` | |
11
+ | [`create`](#create) | `POST /v1/campaigns` | you supply `filters` |
12
+ | [`createBulk`](#createbulk) | upload + `POST /v1/campaigns` | **numbers or CSV; the SDK does the rest** |
13
+ | [`createGroup`](#creategroup) | uploads + `POST /v1/campaigns` | **one bucket per channel or template** |
14
+ | [`uploadReceivers`](#uploadreceivers) | upload only | the file half of `createBulk` |
15
+ | [`update`](#update) | `PATCH /v1/campaigns/:id` | before it runs |
16
+ | [`schedule`](#schedule) | `POST /v1/campaigns/:id/schedule` | |
17
+ | [`cancel`](#cancel) | `POST /v1/campaigns/:id/cancel` | |
18
+ | [`delete`](#delete) | `DELETE /v1/campaigns/:id` | |
19
+
20
+ ---
21
+
22
+ ## The audience is one field with five modes
23
+
24
+ A campaign's recipients live in `filters`, and **the mode is decided by the shape
25
+ of the array**, not by a separate selector:
26
+
27
+ | `filters` | Audience |
28
+ | --- | --- |
29
+ | `[]` | every contact in the account |
30
+ | `ContactFilterEntry[]` | a segment — see [contact filter](../contact-filter.md) |
31
+ | `[{ numbers, hash }]` | a bulk list from an uploaded file |
32
+ | `[{ type: 'channel' \| 'template', numbers, hash, … }]` | a **group** campaign: one bucket per channel or template |
33
+ | `[{ type: 'status' \| 'selection', … }]` | re-target of an earlier campaign |
34
+
35
+ Modes cannot be mixed. Bulk and re-target take **exactly one entry** —
36
+ everything downstream reads `filters[0]` and ignores the rest, so the server
37
+ rejects a second entry rather than silently dropping it.
38
+
39
+ **Group is the exception**: many entries are the whole point, one per bucket. Its
40
+ buckets must all split the same way, though — a `channel` bucket beside a
41
+ `template` bucket describes two different splits of the same recipients, and is
42
+ refused.
43
+
44
+ ### Recipients are counted when the campaign sends, not when you create it
45
+
46
+ `campaign.receivers` is `0` on a campaign you just created. The audience is a
47
+ stored *query* — even a bulk list is a file the sending service reads later — so
48
+ the final count is only known at send time, and a campaign scheduled for next
49
+ week can legitimately reach a different number of people than the preview
50
+ showed.
51
+
52
+ `createBulk` returns its own `receivers` count for exactly this reason: it is the
53
+ number of rows written into the file, known now, and it is what you should show a
54
+ user.
55
+
56
+ ---
57
+
58
+ ## `createBulk`
59
+
60
+ The bulk path, start to finish. You hand it numbers or a CSV; it hands you a
61
+ created campaign.
62
+
63
+ ```ts
64
+ const { campaign, receivers, skipped, duplicates } = await crm.campaigns.createBulk({
65
+ name: 'Diwali offer',
66
+ audience: { numbers: '919000000001, 919000000002\n919000000003' },
67
+ messages: [{ channel: { id: channelId }, message: template }],
68
+ });
69
+
70
+ console.log(`Sending to ${receivers}`); // 3
71
+ ```
72
+
73
+ ### Why this is a method and not a recipe
74
+
75
+ A bulk recipient list does not travel inside the campaign. A million numbers is
76
+ not a request body, and the numbers are personal data that should not sit in an
77
+ API log. So the list takes a different route:
78
+
79
+ 1. the recipients are **encrypted** into a file, under a secret generated for
80
+ that one file;
81
+ 2. the file is **uploaded** to object storage through a presigned URL, so the
82
+ bytes never pass through the API;
83
+ 3. the campaign stores a **pointer** — `{ numbers: <url>, hash: <secret> }` — and
84
+ the sending service fetches and decrypts the file when the campaign runs.
85
+
86
+ Every one of those steps has to be exactly right or the campaign fails at send
87
+ time, hours later, with nothing useful in the error. That is why it is one call
88
+ here rather than a page of instructions.
89
+
90
+ ### The four ways to describe an audience
91
+
92
+ #### Numbers
93
+
94
+ ```ts
95
+ audience: { numbers: '919000000001, 919000000002\n919000000003' }
96
+ audience: { numbers: ['+91 90000 00001', '919000000002'] }
97
+ ```
98
+
99
+ A string is split on **commas, newlines, tabs, semicolons and spaces — all of
100
+ them**, in any mixture. Each entry is reduced to digits, so `+91 90000-00001`
101
+ and `919000000001` are the same number. Entries with no digits are dropped.
102
+
103
+ No per-recipient variables: use one of the forms below if your template has
104
+ placeholders.
105
+
106
+ #### CSV or TSV text
107
+
108
+ ```ts
109
+ audience: { csv: await file.text(), recipientColumn: 'phone' }
110
+ ```
111
+
112
+ The first row is the header. The delimiter is detected (tab, then semicolon, then
113
+ comma) unless you pass `delimiter`. Quoted fields are parsed properly, so
114
+ `"1,299"` is one value and `"He said ""yes"""` is one field containing a quote.
115
+
116
+ **Every column becomes a template variable**, including the recipient column
117
+ itself. Headers are normalised the same way the panel always did them —
118
+ lower-cased, with each non-alphanumeric character replaced by `_` — so
119
+ `First Name` is the variable `first_name`.
120
+
121
+ #### Rows you already parsed
122
+
123
+ ```ts
124
+ audience: { rows: await sheet.getRows(), recipientColumn: 'phone' }
125
+ ```
126
+
127
+ From your own CSV reader, a spreadsheet API, or a database query. Values are
128
+ stringified; the recipient column is matched by its raw name or its normalised
129
+ one.
130
+
131
+ #### Receivers you built
132
+
133
+ ```ts
134
+ audience: {
135
+ receivers: [
136
+ { waba: '919000000001', custom_fields: [{ id: 'name', value: 'Priya' }] },
137
+ ],
138
+ }
139
+ ```
140
+
141
+ The escape hatch. Taken exactly as given — nothing is normalised, nothing is
142
+ de-duplicated.
143
+
144
+ ### What it drops, and tells you about
145
+
146
+ ```ts
147
+ const { receivers, skipped, duplicates } = await crm.campaigns.createBulk({ … });
148
+ ```
149
+
150
+ - `skipped` — entries with no usable number: blank cells, a `Total` footer row,
151
+ `N/A`.
152
+ - `duplicates` — entries whose number already appeared.
153
+
154
+ Both are **counted and reported** rather than passed over. A row file that is
155
+ one-third blank is something you want to know about before the send, not after.
156
+
157
+ De-duplication is on by default because the cost of a duplicate is not a wasted
158
+ table row — it is the same person getting the same marketing message twice,
159
+ billed twice, and more likely to report the sender. Pass `dedupe: false` if you
160
+ genuinely want two sends to one number.
161
+
162
+ Nothing is uploaded and no campaign is created if the audience resolves to zero
163
+ recipients, or to more than **1,000,000** — both throw `validation_failed`.
164
+
165
+ ### Previewing before you send
166
+
167
+ The helpers are exported, so a UI can resolve an audience and show the user what
168
+ it found before anything is uploaded:
169
+
170
+ ```ts
171
+ import { resolveAudience, parseDelimited } from 'ab-ecosystem-cpass';
172
+
173
+ const { headers } = parseDelimited(text); // offer these as the column picker
174
+ const { receivers, skipped, duplicates } = resolveAudience({ csv: text, recipientColumn });
175
+
176
+ // "12,480 recipients · 3 duplicates dropped · 2 rows without a number"
177
+ ```
178
+
179
+ `resolveAudience` does no I/O and no crypto. It is the same function `createBulk`
180
+ calls, so what you preview is what gets sent.
181
+
182
+ ### Scheduling one
183
+
184
+ ```ts
185
+ await crm.campaigns.createBulk({
186
+ name: 'Renewal reminder',
187
+ audience: { csv, recipientColumn: 'phone' },
188
+ messages: [{ channel: { id: channelId }, message: template }],
189
+ trigger: 'schedule',
190
+ scheduledAt: '2026-11-01T04:30:00.000Z',
191
+ });
192
+ ```
193
+
194
+ The file is uploaded immediately; only the send waits.
195
+
196
+ ### Two things to be careful about
197
+
198
+ **The `hash` is a credential, not a checksum.** It is the key the file is
199
+ encrypted under, and the file's URL is a public storage link. Anyone holding both
200
+ can read the recipient list. Do not log it, do not put it in an error report, and
201
+ do not copy a `filters` entry into another system.
202
+
203
+ **A campaign is not rolled back if creation fails after upload.** If the upload
204
+ succeeds and the `POST` then fails, you have an uploaded file and no campaign —
205
+ harmless, but the file stays. Retry with [`uploadReceivers`](#uploadreceivers)
206
+ and `create` if you want to avoid re-uploading a large list.
207
+
208
+ ---
209
+
210
+ ## `createGroup`
211
+
212
+ Splits the recipients into buckets and sends each through a different channel, or
213
+ renders each with a different message.
214
+
215
+ ```ts
216
+ const { campaign, buckets, receivers } = await crm.campaigns.createGroup({
217
+ name: 'Diwali offer',
218
+ distributeBy: 'channel',
219
+ buckets: [
220
+ { channel: channelA, audience: { rows: north, recipientColumn: 'phone' } },
221
+ { channel: channelB, audience: { rows: south, recipientColumn: 'phone' } },
222
+ ],
223
+ messages: [{ channel: { id: channelA }, message: template }],
224
+ });
225
+ ```
226
+
227
+ `distributeBy: 'template'` instead splits across the messages, and each bucket
228
+ names its message by `pairIndex`:
229
+
230
+ ```ts
231
+ buckets: [
232
+ { pairIndex: 0, audience: { numbers: variantA } },
233
+ { pairIndex: 1, audience: { numbers: variantB } },
234
+ ]
235
+ ```
236
+
237
+ ### Three ways it differs from `createBulk`
238
+
239
+ Each of these is something that would be wrong if you assembled the payload by
240
+ hand:
241
+
242
+ - **One file per bucket.** Each bucket is encrypted under its own secret and
243
+ uploaded separately, so `filters` carries one entry per bucket. This is the
244
+ only audience mode with more than one entry.
245
+ - **A different file format.** A bucket file is a `||`-delimited table with a
246
+ header row — not the JSON array a bulk file holds. The reading side picks by
247
+ campaign type, so the wrong encoding decrypts cleanly and then parses to
248
+ nothing.
249
+ - **Columns are fixed by the first recipient.** A table has one header, so every
250
+ row carries the same variables in the same order. Values are addressed by
251
+ column name, so a row whose variables arrived in a different order is still
252
+ written correctly; a row missing one gets an empty cell rather than a shift.
253
+
254
+ ```
255
+ waba||first_name||amount
256
+ 919000000001||Priya||1299
257
+ 919000000002||Ravi||499
258
+ ```
259
+
260
+ Newlines and literal `||` inside a value are replaced with a space. The format
261
+ has no escape for either, and one would end the row early and shift every later
262
+ value into the wrong column.
263
+
264
+ ### Empty buckets are dropped, not refused
265
+
266
+ A bucket whose audience resolves to nobody is left out of `filters` entirely.
267
+ Distributing a list across six channels routinely leaves one or two with nothing
268
+ in them, and that is not an error. The call fails only when **every** bucket is
269
+ empty — or when the buckets together exceed 1,000,000 recipients.
270
+
271
+ `result.buckets` therefore holds only the buckets that were uploaded, in the
272
+ order given, each with its own `receivers` / `skipped` / `duplicates` and its
273
+ `fileUrl`. The top-level counts are the totals.
274
+
275
+ ### De-duplication is per bucket
276
+
277
+ A number appearing in two buckets is sent to twice. That is usually the intent —
278
+ the buckets are different channels — and it is not something this can
279
+ second-guess. Within one bucket, duplicates are dropped as everywhere else.
280
+
281
+ ### Extra keys ride along
282
+
283
+ ```ts
284
+ { channel: channelA, extra: { priority: 2 }, audience: { … } }
285
+ ```
286
+
287
+ `extra` is merged onto the filter entry beside `numbers` and `hash`. The campaign
288
+ contract passes anything it does not recognise through to the sending service
289
+ untouched, so this is the seam for distribution metadata the SDK does not model.
290
+ It cannot overwrite `numbers`, `hash`, `type` or `count`.
291
+
292
+ ### How it is dispatched
293
+
294
+ Stored as `type: 'group_campaign'`, which is what the campaign list and reports
295
+ show. On the hand-off to the sending service it is **relabelled
296
+ `bulk_campaign`**, because the two differ only in how many receiver files there
297
+ are and the bulk handling is what reads a file of numbers.
298
+
299
+ The filters are **not** translated along with the name — every bucket, with its
300
+ channel binding and its count, is forwarded exactly as stored.
301
+
302
+ ---
303
+
304
+ ## `uploadReceivers`
305
+
306
+ The file half on its own.
307
+
308
+ ```ts
309
+ const filter = await crm.campaigns.uploadReceivers(receivers);
310
+ // { numbers: 'https://…/im_m95ibl56.txt', hash: '081389a62af715e91f92026eac67910a' }
311
+
312
+ // For a group bucket, which uses the || table format:
313
+ const bucket = await crm.campaigns.uploadReceivers(receivers, undefined, 'table');
314
+
315
+ await crm.campaigns.create({
316
+ name: 'Diwali offer',
317
+ type: 'bulk_campaign',
318
+ filters: [filter],
319
+ messages: [{ channel: { id: channelId }, message: template }],
320
+ });
321
+ ```
322
+
323
+ Useful when the two halves should fail independently: a large upload you do not
324
+ want to repeat if campaign creation is rejected, or several campaigns addressed
325
+ to one uploaded list.
326
+
327
+ Keep the returned `hash` with the URL. There is no copy of it anywhere else —
328
+ lose it and the file cannot be decrypted by anything, including us.
329
+
330
+ ---
331
+
332
+ ## How the file is encrypted
333
+
334
+ Documented because it is a cross-service contract: the sending service decrypts
335
+ these, so the scheme cannot be changed on one side alone.
336
+
337
+ ```
338
+ secret = 16 random bytes, hex -- the campaign's `hash`
339
+ key = md5(secret) -- 32 hex characters
340
+ key bytes = the ASCII bytes of that hex -- 32 bytes, so AES-256
341
+ iv = ASCII bytes of '678025308de70905'
342
+ plaintext = JSON.stringify([{ waba, custom_fields }, …]) -- bulk
343
+ | waba||col||col
344
+ … (a || table with a header row) -- group bucket
345
+ ciphertext = AES-256-CBC, PKCS#7 padded
346
+ file body = base64(ciphertext)
347
+ ```
348
+
349
+ Two parts are counter-intuitive and both are deliberate, because this has to
350
+ match what `CryptoJS.AES.encrypt(text, keyWordArray, { iv })` produces:
351
+
352
+ - **The key is the hex *text*, not the 16 digest bytes.** `md5()` returns a
353
+ 32-character string, parsed as UTF-8 — which is 32 bytes, hence AES-256. Using
354
+ the raw digest would be AES-128 and would not decrypt.
355
+ - **There is no `Salted__` header.** CryptoJS writes one only when deriving a key
356
+ from a passphrase; given a key directly, its default formatter emits bare
357
+ base64.
358
+
359
+ The fixed IV is a known weakness — it means two files encrypted under the same
360
+ secret share leading blocks. What keeps it from mattering is that **the secret is
361
+ fresh for every file**, so the IV is never reused under one key. Revising the
362
+ format means a random IV prepended to the ciphertext, and it has to land on the
363
+ sending service before it lands here.
364
+
365
+ ---
366
+
367
+ ## `list`
368
+
369
+ ```ts
370
+ const { data, nextCursor } = await crm.campaigns.list({ limit: 50, status: 'completed' });
371
+ ```
372
+
373
+ | Query | Type | Notes |
374
+ | --- | --- | --- |
375
+ | `limit` | `number` | 1–100, default 50 |
376
+ | `cursor` | `string` | from the previous page's `nextCursor` |
377
+ | `status` | `pending` `processing` `completed` `failed` `cancelled` | |
378
+ | `campaignType` | `general` `payment` | |
379
+ | `type` | `string` | dispatch route, e.g. `bulk_campaign` |
380
+ | `hidden` | `boolean` | |
381
+
382
+ ## `get`
383
+
384
+ ```ts
385
+ const campaign = await crm.campaigns.get(id);
386
+ ```
387
+
388
+ ## `create`
389
+
390
+ For an audience you have already expressed as `filters` — every contact,
391
+ a segment, or a re-target. For a bulk list use [`createBulk`](#createbulk).
392
+
393
+ | Field | Type | Default |
394
+ | --- | --- | --- |
395
+ | `name` | `string` | required, 1–160 |
396
+ | `messages` | `CampaignMessage[]` | required, at least one |
397
+ | `type` | `string` | `general` |
398
+ | `campaignType` | `general` `payment` | `general` |
399
+ | `trigger` | `immediate` `schedule` | `immediate` |
400
+ | `scheduledAt` | ISO datetime | required when `trigger` is `schedule` |
401
+ | `channelType` | `string` | `whatsapp` |
402
+ | `messagingPattern` | `string` | `A2O` |
403
+ | `filters` | `CampaignFilter[]` | `[]` — every contact |
404
+ | `showMessageInPanel` | `boolean` | `true` |
405
+ | `isRepeating` | `boolean` | `false` |
406
+ | `repeat` | `{ every, unit, until }` | — |
407
+ | `hidden` | `boolean` | `false` |
408
+
409
+ ## `update`
410
+
411
+ ```ts
412
+ await crm.campaigns.update(id, { name: 'Diwali offer (revised)' });
413
+ ```
414
+
415
+ Every `create` field, all optional. Only meaningful before the campaign runs.
416
+
417
+ ## `schedule`
418
+
419
+ ```ts
420
+ await crm.campaigns.schedule(id, '2026-11-01T04:30:00.000Z');
421
+ ```
422
+
423
+ ## `cancel`
424
+
425
+ ```ts
426
+ await crm.campaigns.cancel(id);
427
+ ```
428
+
429
+ ## `delete`
430
+
431
+ ```ts
432
+ await crm.campaigns.delete(id);
433
+ ```
434
+
435
+ ---
436
+
437
+ # Reporting
438
+
439
+ Campaign reporting lives on its own page: **[campaign messages](campaign-messages.md)**
440
+ — `crm.campaignMessages`.
441
+
442
+ There is no report collection and no export job. One row is written per attempted
443
+ send, its status is advanced by the channel's delivery webhooks, and every number
444
+ is counted off those rows when you ask for it:
445
+
446
+ ```ts
447
+ const stats = await crm.campaignMessages.stats({ campaign: id });
448
+ // { total, sent, delivered, read, replied, failed }
449
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ab-ecosystem-cpass",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "JavaScript/TypeScript SDK for the CPaaS CRM gateway: audience and channels",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",