ab-ecosystem-cpass 1.0.11 → 1.0.13

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 (39) hide show
  1. package/README.md +1 -0
  2. package/dist/client.d.ts +6 -0
  3. package/dist/client.js +7 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.js +8 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal/embedded-signup.d.ts +46 -0
  9. package/dist/internal/embedded-signup.js +318 -0
  10. package/dist/internal/embedded-signup.js.map +1 -0
  11. package/dist/resources/channels/onboarding.d.ts +89 -0
  12. package/dist/resources/channels/onboarding.js +96 -0
  13. package/dist/resources/channels/onboarding.js.map +1 -0
  14. package/dist/types/campaign.d.ts +8 -0
  15. package/dist/types/channels.d.ts +129 -0
  16. package/dist/types/channels.js.map +1 -1
  17. package/docs/audience/canned-replies.md +3 -0
  18. package/docs/audience/contact-tags.md +4 -0
  19. package/docs/audience/contacts.md +30 -1
  20. package/docs/audience/crm-triggers.md +3 -0
  21. package/docs/audience/custom-fields.md +23 -0
  22. package/docs/audience/exports.md +6 -0
  23. package/docs/audience/imports.md +7 -0
  24. package/docs/audience/notifications.md +11 -0
  25. package/docs/audience/scheduled-messages.md +5 -0
  26. package/docs/audience/segments.md +4 -0
  27. package/docs/audience/webhooks.md +7 -0
  28. package/docs/campaign/campaigns.md +27 -0
  29. package/docs/channels/activity.md +3 -0
  30. package/docs/channels/billing.md +2 -0
  31. package/docs/channels/channel-props.md +19 -0
  32. package/docs/channels/channels.md +35 -2
  33. package/docs/channels/onboarding.md +530 -0
  34. package/docs/channels/templates.md +22 -0
  35. package/docs/channels/wallet.md +2 -0
  36. package/docs/channels/webhooks.md +6 -0
  37. package/docs/data-store.md +18 -0
  38. package/docs/inbox/connection.md +88 -0
  39. package/package.json +1 -1
@@ -0,0 +1,530 @@
1
+ # Connecting a channel
2
+
3
+ `crm.channelOnboarding` — `/v1/channels/onboarding`
4
+
5
+ Creating a channel is the one channel operation that is not a request and a
6
+ response. The credentials belong to Meta, and the only way to get them is to
7
+ walk the account holder through Meta's own **Embedded Signup** — a wizard on
8
+ `facebook.com` where they pick or create a WhatsApp Business Account, add a
9
+ number, and grant our app access to it.
10
+
11
+ So the backend cannot create a channel on its own. What it can do is keep the
12
+ browser out of the credential business entirely: **Meta returns to us, not to
13
+ you.** The dialog is opened with our own `oauth-callback` as its redirect, so the
14
+ authorization code goes straight to this service, which redeems it, reads which
15
+ numbers the authorisation covers, and hands the browser back to a URL of yours
16
+ with nothing on it but an outcome and a token to finish from.
17
+
18
+ | Action | Route | Scope |
19
+ | --- | --- | --- |
20
+ | [`types`](#types) | `GET /v1/channels/onboarding/types` | `channels:read` |
21
+ | [`url`](#url) | `POST /v1/channels/onboarding/url` | `channels:write` |
22
+ | [the callback](#the-callback) | `GET /v1/channels/onboarding/oauth-callback` | **public** |
23
+ | [`pending`](#pending) | `GET /v1/channels/onboarding/pending/:token` | `channels:read` |
24
+ | [`complete`](#complete) | `POST /v1/channels/onboarding/complete` | `channels:write` |
25
+ | [`exchange`](#exchange) | `POST /v1/channels/onboarding/exchange` | `channels:write` |
26
+ | [`connect`](#connect) | `url` + the popup + `pending` | `channels:write` |
27
+
28
+ **Permissions** — `CRM.CHANNEL.WHATSAPP.CREATE`, checked on `url`, `pending`,
29
+ `exchange` **and** `complete`. Several checks rather than one because each call
30
+ is a step towards a channel existing, and a role can be edited between them.
31
+ `types` filters instead of refusing, like `GET /v1/channels`: a member who
32
+ cannot create a WhatsApp channel gets a list without it rather than losing the
33
+ page.
34
+
35
+ The callback is the exception, and necessarily so: a browser arriving on a
36
+ redirect carries no token of ours, so there is no role there to check. What it
37
+ trusts instead is the signed `state` Meta round-trips to it — this service
38
+ issued it, to that account, with that permission already checked, within the
39
+ hour. It never widens anything: the plan, the quota and the number are all
40
+ re-checked, and `complete` runs the role check again with a real caller behind
41
+ it.
42
+
43
+ Only WhatsApp is onboarded here. Facebook and Instagram come through the same
44
+ Meta dialog with a different signup configuration and a different set of ids
45
+ coming back; they are still created by the legacy stack, so `types` leaves them
46
+ out entirely.
47
+
48
+ ---
49
+
50
+ ## The shape of it
51
+
52
+ ```
53
+ your console Meta our backend
54
+ ──────────── ──── ───────────
55
+
56
+ types() ────────────────────────────────────────────▶ the picker
57
+ url() ────────────────────────────────────────────▶ { url, state }
58
+ │
59
+ └─ popup ─▶ oauth.automationsbuilder.com/whatsapp_channel_signup
60
+ │
61
+ └─ ▶ facebook.com/.../dialog/oauth
62
+ they pick or create a number
63
+ │
64
+ └─ code + state ──────────────▶ oauth-callback
65
+ · redeem the code
66
+ · long-lived token
67
+ · read the numbers
68
+ · write a record
69
+ ┌──────── 302 ─────────────────────────┘
70
+ ▼
71
+ ?status=ready&token=…
72
+ pending(token) ──────────────────────────────────────────▶ numbers + prefill
73
+ complete() ──────────────────────────────────────────▶ the channel
74
+ ```
75
+
76
+ Three things about the middle of that are worth knowing before you build it:
77
+
78
+ - **The dialog cannot be put in an iframe.** Meta sends `X-Frame-Options` on it.
79
+ A "modal" has to be your own chrome around a popup window, not an embedded
80
+ page. An iframe renders blank, which looks like our bug.
81
+ - **What you open is a launcher, not the dialog.** `url` answers a page on the
82
+ BSP app's own domain carrying the dialog as its `?redirect=`, because Meta ties
83
+ an app's login dialog to that domain and your console is not on it. The page
84
+ forwards and is otherwise not involved. `dialogUrl` is the dialog itself, if
85
+ you want to see what the app was asked for.
86
+ - **One redirect is registered with Meta, and it is ours.** Your `redirectUri`
87
+ is where *we* send the browser afterwards; Meta never sees it. So a new
88
+ console, a new environment or a new path needs no change at Meta — only an
89
+ entry on our return allowlist.
90
+ - **The number is not known until the token is read.** The dialog tells its
91
+ *opener* which WABA and number were set up, by `postMessage`; a server-side
92
+ callback has no opener. So the service asks Meta what the token was granted,
93
+ and what numbers those WABAs hold. Usually that is exactly one number and the
94
+ record is bound to it — but an authorisation can cover several, and then
95
+ somebody has to pick. That is `status=select`.
96
+
97
+ `connect()` drives the whole browser half for you.
98
+
99
+ ---
100
+
101
+ ## `types`
102
+
103
+ ```ts
104
+ const { data } = await crm.channelOnboarding.types();
105
+
106
+ // [
107
+ // { type: 'whatsapp', label: 'WhatsApp',
108
+ // slug: 'whatsapp_channels_28335', limit: 3, used: 1, remaining: 2 },
109
+ // ]
110
+ ```
111
+
112
+ **Every row returned is connectable.** Render them as they come: there is no
113
+ `available` flag to branch on and no row to grey out. A family is left out when:
114
+
115
+ | Left out because | What to say |
116
+ | --- | --- |
117
+ | The plan carries none of the inclusion | Offer an upgrade |
118
+ | Every unit of it is spent | "You have used all 3 of your WhatsApp channels" |
119
+ | The caller's role cannot create one | "Ask the account owner" |
120
+ | No BSP is configured for the family | Our problem, not theirs — contact support |
121
+ | The family is not onboarded here yet | Facebook and Instagram, today |
122
+
123
+ An empty list therefore means there is nothing to offer this caller at all, and
124
+ the "add a channel" button is what to hide. The reason is not returned; if you
125
+ need to explain one, `url()` answers with the specific refusal — `402` for the
126
+ plan, `403` for the role, `provider_not_configured` for the BSP.
127
+
128
+ `limit` and `remaining` are absent when the inclusion is granted without a count
129
+ — some plans grant a channel family as a flag rather than a quantity, which
130
+ means *included, unmetered*. Absent is not zero.
131
+
132
+ ---
133
+
134
+ ## `url`
135
+
136
+ ```ts
137
+ const { data } = await crm.channelOnboarding.url({
138
+ redirectUri: 'https://console.example.com/settings/channels?from=wizard',
139
+ });
140
+ // { url, dialogUrl, state, redirectUri, callbackUri, expiresAt, provider, remaining }
141
+ ```
142
+
143
+ | Field | |
144
+ | --- | --- |
145
+ | `type` | `whatsapp`, and the default |
146
+ | `slug` | The inclusion to spend. Only needed when the plan carries more than one — each can sit behind a different BSP |
147
+ | `provider` | The BSP to connect through, by provider code. Omit for whichever is active for the family |
148
+ | `redirectUri` | **Required.** Where **we** hand the browser back when the dialog is done |
149
+
150
+ Checks the plan **before** the account holder is sent anywhere: finding out
151
+ afterwards means telling somebody who has just completed a business verification
152
+ that they cannot have the channel. 402 `not_included` / `quota_exceeded` when
153
+ the plan has no room, 409 `provider_not_configured` when no BSP is set up, 409
154
+ `onboarding_callback_not_configured` when this deployment has no callback URL,
155
+ 400 `redirect_uri_required` when the call names no return at all, 400
156
+ `redirect_uri_not_allowed` for a return we are not configured for.
157
+
158
+ **`redirectUri` is required**, and nothing is defaulted in its place: the
159
+ callback is a redirect with no caller and no session, so this is the only thing
160
+ that says where the browser goes when the dialog is done. A signup with nowhere
161
+ to return to would strand somebody on a URL registered at Meta.
162
+
163
+ It is matched **by origin**, not exactly, so you are free to carry your own path
164
+ and query on it — `?from=wizard` above comes back alongside our `status`, which
165
+ is how one page can start several flows and tell them apart. Only the origin has
166
+ to be on the service's list, because that is what decides who receives a
167
+ connected number's details.
168
+
169
+ > **The origin check is currently commented out** in `url()` — any absolute URL
170
+ > a caller sends is taken as given, so `CHANNEL_ONBOARDING_REDIRECT_URIS` has no
171
+ > live effect at all now that there is no default to supply. That makes this an
172
+ > open redirect with a signup outcome attached: a `token` and a number's details
173
+ > land wherever the request asked. Fine behind a trusted console while a tunnel
174
+ > is in play; restore `resolveReturnUri` before this is reachable by anyone else.
175
+
176
+ `url` is the launcher —
177
+ `https://oauth.automationsbuilder.com/whatsapp_channel_signup?redirect=<the dialog>`
178
+ — and it is what you open. The dialog rides on it percent-encoded, so read it
179
+ back with `URLSearchParams` rather than by splitting on `redirect=`: the dialog
180
+ carries a query of its own, and the first `&` in it comes before the state.
181
+ `dialogUrl` is that inner URL, answered for inspection; opening it directly
182
+ skips the launcher, which works only if the Meta app is registered against
183
+ whatever origin you opened it from.
184
+
185
+ `provider` pins which BSP the channel is created against. Leave it out and the
186
+ service uses the row that is `active` for the family, which is the right answer
187
+ almost always; name a code when it is not — a second BSP, a per-currency app, an
188
+ app still being tested. A named code does **not** have to be the active one, but
189
+ it has to be configured for this family (400 `provider_type_mismatch`), it has
190
+ to exist (404 `provider_not_found`) and it has to carry `app_id`, `app_secret`
191
+ and `config_id` (409 `provider_not_configured`, which names the missing ones).
192
+ Whatever is resolved comes back as `provider` and is signed into the state, so
193
+ the callback redeems the code with that same app's secret — which matters,
194
+ because the BSP a channel is created against is permanent.
195
+
196
+ Note this is deliberately independent of `types`: that lists what the *active*
197
+ provider offers, so a family can be absent there and still connectable by
198
+ naming a code.
199
+
200
+ `callbackUri` is what Meta was given. It is the same for every call, and it is
201
+ the one URL that has to appear in each BSP app's *Valid OAuth Redirect URIs* —
202
+ answered here so an operator can read it off a live response rather than off a
203
+ deployment variable.
204
+
205
+ `state` is ours, not Meta's: signed, bound to this account, and carrying the
206
+ inclusion, the BSP and your `redirectUri`. **You do not need to keep it.** The
207
+ callback is the only thing that reads it, which is the point — there is nothing
208
+ for a browser to hold on to, lose, or have taken off it. Good for an hour.
209
+
210
+ `remaining` is what is left *after* this one, which is the number to put next to
211
+ "you can connect 2 more".
212
+
213
+ ---
214
+
215
+ ## The callback
216
+
217
+ ```
218
+ GET /v1/channels/onboarding/oauth-callback?code=…&state=…
219
+ ```
220
+
221
+ Meta's redirect, and not a call you make. It is public — a redirect cannot carry
222
+ a token — and it **always answers with a 302**, because there is a person in a
223
+ browser on the other end of it and a JSON error would strand them on a URL with
224
+ no way back. Everything it has to say it says on your own page:
225
+
226
+ ```
227
+ https://console.example.com/settings/channels
228
+ ?from=wizard ← yours, untouched
229
+ &status=ready
230
+ &token=6721f0… ← pass to pending() and complete()
231
+ &numbers=1
232
+ &wabaId=…&phoneNumberId=…
233
+ &displayPhoneNumber=%2B91+93116+36081&verifiedName=Acme+Support
234
+ &expiresAt=2026-10-26T09:12:00.000Z
235
+ ```
236
+
237
+ | `status` | What it means |
238
+ | --- | --- |
239
+ | `ready` | One number, already bound to the record. `complete()` needs nothing but a name |
240
+ | `select` | The authorisation covered several. Read them with `pending()` and let the person pick |
241
+ | `error` | `code` is the service's own code and `message` is a sentence you can show as it is |
242
+
243
+ `status=error` is how every refusal after the dialog arrives — `quota_exceeded`
244
+ if the last unit went in another tab, `phone_number_in_use` if somebody
245
+ connected the number meanwhile, `onboarding_code_rejected` if Meta would not
246
+ redeem the code, `onboarding_no_numbers` if the WABA has nothing connectable on
247
+ it. None of them is a status code you can catch; branch on `code`.
248
+
249
+ The one thing that does not redirect is a `state` we cannot read — expired,
250
+ tampered with, or missing. That field is the one naming the page to go back to,
251
+ so there is nowhere safe to send the browser, and it answers 400
252
+ `invalid_onboarding_state` / `onboarding_state_expired` instead.
253
+
254
+ **Nothing is created yet.** The record the `token` points at holds the
255
+ long-lived token, encrypted, belongs to this account, can be used once, and
256
+ expires in an hour.
257
+
258
+ ---
259
+
260
+ ## `pending`
261
+
262
+ ```ts
263
+ const { data } = await crm.channelOnboarding.pending(token);
264
+
265
+ // {
266
+ // id: '6721f0...',
267
+ // expiresAt: '...',
268
+ // type: 'whatsapp',
269
+ // numbers: [
270
+ // { id: '...', wabaId: '...', displayPhoneNumber: '+91 93116 36081',
271
+ // verifiedName: 'Acme Support', status: 'CONNECTED',
272
+ // codeVerificationStatus: 'VERIFIED', qualityRating: 'GREEN' },
273
+ // ],
274
+ // phoneNumber: { ... }, // set when there was exactly one
275
+ // defaults: { name: 'Acme Support', color: '#075E54', currency: 'USD' },
276
+ // }
277
+ ```
278
+
279
+ What the `token` on the return URL refers to, in full: Meta's own description of
280
+ each number the authorisation covers, and the prefill for the confirmation form.
281
+ The query string carries a summary of the same thing, so for `status=ready` you
282
+ can skip this call and show the form from the URL alone.
283
+
284
+ `numbers` is what to render as a picker when `status=select`. A number another
285
+ channel already holds is not in it — the service drops those rather than
286
+ offering a choice that cannot be taken.
287
+
288
+ `phoneNumber` is Meta's description, not an echo of anything you sent: the
289
+ service read the WABA's numbers with the token it had just minted, which is what
290
+ proves the token covers that WABA and the number is really on it.
291
+
292
+ 404 `not_found` for a record that has expired, been completed, or belongs to
293
+ another account — one answer for all three, because whose it is is not something
294
+ to disclose.
295
+
296
+ ---
297
+
298
+ ## `complete`
299
+
300
+ ```ts
301
+ const { data: channel } = await crm.channelOnboarding.complete({
302
+ token: candidate.id,
303
+ phoneNumberId: chosen.id, // required only for status=select
304
+ name: 'Acme Support',
305
+ color: '#075E54',
306
+ currency: 'INR',
307
+ });
308
+ ```
309
+
310
+ Creates the channel and returns it in the same shape `channels.get()` does.
311
+ `name` is required; `color` defaults to WhatsApp's green and `currency` to the
312
+ inclusion's, except that an INR plan falls back to USD for a number outside the
313
+ Indian dial codes — Meta prices those in dollars however the plan is denominated.
314
+
315
+ `phoneNumberId` names which number to connect, and is needed exactly when the
316
+ record covers more than one. It has to be one of them: a number the
317
+ authorisation did not cover is refused rather than attempted, or a caller could
318
+ connect a WABA nobody granted us.
319
+
320
+ **The channel is created inactive.** Registering the number with Meta is what
321
+ brings it up, and that is the next step in the console:
322
+
323
+ ```ts
324
+ // A number new to the Cloud API: set its PIN.
325
+ await crm.channels.setProp(channel.id, 'wa_pin_update', { body: { pin: '123456' } });
326
+
327
+ // A number coming off the WhatsApp Business app: already registered with Meta.
328
+ await crm.channels.setProp(channel.id, 'wa_biz_update');
329
+ ```
330
+
331
+ `channel.cloud.wa_biz_registration` says which — `pending` until the BSP
332
+ handshake has run. `channel.cloud.is_on_biz_app` is true for the second case.
333
+
334
+ | Status | Code | |
335
+ | --- | --- | --- |
336
+ | 400 | `onboarding_number_required` | The record covers several numbers and none was named |
337
+ | 400 | `onboarding_number_not_authorised` | `phoneNumberId` is not one the authorisation covered |
338
+ | 404 | `not_found` | No such session, or it belongs to another account |
339
+ | 409 | `onboarding_already_completed` | A second call with the same token |
340
+ | 410 | `onboarding_expired` | The confirmation screen sat for more than an hour |
341
+ | 409 | `phone_number_in_use` | Somebody connected the number while the confirmation screen was open |
342
+ | 409 | `phone_number_pending` | **This account's own** unfinished channel — `details.channel` is its id. Resume it rather than making a second one |
343
+
344
+ `phone_number_in_use` is checked across every account, not just this one: a
345
+ number carries one WABA webhook subscription at a time, so a second channel on it
346
+ would quietly take over the first one's message traffic.
347
+
348
+ A refusal here **releases the session rather than consuming it**, so pressing the
349
+ button again is enough — the Meta dialog does not have to be run a second time.
350
+ Only a create that succeeds spends the token.
351
+
352
+ ---
353
+
354
+ ## `connect`
355
+
356
+ ```ts
357
+ // In the click handler for "Connect WhatsApp" — see the note below.
358
+ const candidate = await crm.channelOnboarding.connect({
359
+ redirectUri: 'https://console.example.com/settings/channels',
360
+ onProgress: ({ phase }) => setStatus(phase),
361
+ });
362
+
363
+ // candidate.numbers — one, or several to choose between
364
+ const { data: channel } = await crm.channelOnboarding.complete({
365
+ token: candidate.id,
366
+ phoneNumberId: candidate.phoneNumber?.id ?? chosen.id,
367
+ name: form.name,
368
+ });
369
+ ```
370
+
371
+ `url()` → popup → wait for the popup to come back to your `redirectUri` →
372
+ `pending()`, resolving with exactly what `pending()` returns. It creates
373
+ nothing, so the person still confirms the number before it becomes a channel.
374
+
375
+ **`redirectUri` is required, and has to be a page on this page's own origin**,
376
+ since reading the outcome off the popup is what ends the wait. A landing page on
377
+ another origin cannot be read at all, and the wait ends with `popup_closed` when
378
+ the person shuts the window.
379
+
380
+ **Call it straight from the click handler**, not after an `await` of your own. It
381
+ opens the popup before awaiting anything that would detach it from the gesture,
382
+ because a `window.open` the browser does not attribute to a click is blocked —
383
+ and the block is indistinguishable from the user having a popup blocker.
384
+
385
+ | Option | |
386
+ | --- | --- |
387
+ | `redirectUri` | **Required**, and on this page's own origin. Passed through to `url()` |
388
+ | `type`, `slug`, `provider` | Passed through to `url()` |
389
+ | `onProgress` | `opened` → `returned` |
390
+ | `timeoutMs` | Default 15 minutes. The wizard can include a business verification |
391
+ | `width`, `height`, `name` | The popup window |
392
+
393
+ Rejects with `EmbeddedSignupError`, whose `code` is one of:
394
+
395
+ | `code` | |
396
+ | --- | --- |
397
+ | `popup_blocked` | Allow popups and try again |
398
+ | `popup_closed` | They closed the window |
399
+ | `signup_error` | The flow came back refused. `message` is the sentence to show, and `reason` is the service's own code — `quota_exceeded`, `phone_number_in_use`, ... |
400
+ | `timeout` | Still open after `timeoutMs` |
401
+ | `unsupported_environment` | Not a browser — drive the calls yourself |
402
+
403
+ Everything the service refused *before* the dialog opened arrives as the usual
404
+ `ApiError`, so handle both:
405
+
406
+ ```ts
407
+ try {
408
+ const candidate = await crm.channelOnboarding.connect();
409
+ } catch (err) {
410
+ if (err instanceof EmbeddedSignupError) {
411
+ if (err.code === 'popup_closed') return; // not an error worth showing
412
+ return showNotice(err.message);
413
+ }
414
+ throw err; // a 402, a 409, ...
415
+ }
416
+ ```
417
+
418
+ ### Driving the dialog yourself
419
+
420
+ Without the helper: call `url()`, open `data.url` yourself (or navigate to it),
421
+ and read `status` and `token` off the query string when the browser lands back
422
+ on your `redirectUri`. The flow survives a full page load, which is the reason
423
+ to do it this way — the state lives in the signed `state` and in the pending
424
+ record, not in your tab.
425
+
426
+ If your landing page runs inside a popup and wants to close itself, post the
427
+ query back to the opener and `awaitSignupReturn` will take it from there:
428
+
429
+ ```html
430
+ <!-- At your redirectUri -->
431
+ <script>
432
+ window.opener?.postMessage(location.search, window.origin);
433
+ window.close();
434
+ </script>
435
+ ```
436
+
437
+ ### The dialog shape
438
+
439
+ `url()` builds `extras` from the provider row, defaulting to what the panel runs
440
+ today: `sessionInfoVersion: '2'` and `featureType:
441
+ 'whatsapp_business_app_onboarding'`, the variant that also onboards a number
442
+ sitting on the WhatsApp Business app. A BSP whose app is configured differently
443
+ overrides either on its `crmchannelproviders` row -- `provider.feature_type`,
444
+ `provider.session_info_version` -- without a deploy. `only_waba_sharing` there
445
+ is the migration flow.
446
+
447
+ ### Setting it up
448
+
449
+ Three things, and none of them is in code:
450
+
451
+ 1. `CHANNEL_ONBOARDING_CALLBACK_URL` is this deployment's public
452
+ `/v1/channels/onboarding/oauth-callback`, and that exact string has to be in
453
+ *Valid OAuth Redirect URIs* on every BSP app you onboard through. Meta
454
+ compares it exactly, trailing slash included.
455
+ 2. `CHANNEL_ONBOARDING_REDIRECT_URIS` lists the origins we may hand the browser
456
+ back to, comma separated. This one is ours alone; Meta never sees it. With
457
+ the origin check commented out in `url()` and `redirectUri` now required,
458
+ **this variable currently does nothing** — set it anyway, so restoring
459
+ `resolveReturnUri` is a one-line change rather than a deployment.
460
+ 3. `CHANNEL_ONBOARDING_LAUNCH_URL` is the launcher page, and it already defaults
461
+ to the shared one. Change it only if a BSP app is registered against a
462
+ different domain; set it empty to hand the dialog URL back directly.
463
+
464
+ With either of the first two unset, `url()` refuses rather than starting a
465
+ dialog that has nowhere to come back to.
466
+
467
+ ---
468
+
469
+ ## `exchange`
470
+
471
+ ```ts
472
+ const { data } = await crm.channelOnboarding.exchange({
473
+ state, // from url()
474
+ code, // from the dialog
475
+ wabaId, // from the WA_EMBEDDED_SIGNUP message
476
+ phoneNumberId,
477
+ });
478
+ // { id, expiresAt, phoneNumber, defaults }
479
+ ```
480
+
481
+ **The other path, for a code you hold yourself.** It only applies to a BSP app
482
+ whose registered redirect is a page of *yours* rather than this service's
483
+ callback — the `FB.login` flow, where the code comes back on your redirect and
484
+ the `waba_id` / `phone_number_id` arrive separately as a `postMessage` from
485
+ `facebook.com` carrying `WA_EMBEDDED_SIGNUP`. The SDK's `openSignup` waits for
486
+ both halves and is kept for exactly this.
487
+
488
+ Everything else is the same as the callback's: the code is redeemed, the token
489
+ is traded for a long-lived one, the number is verified against the WABA, and a
490
+ pending record comes back to `complete()`. The difference is that you name the
491
+ number instead of the service discovering it.
492
+
493
+ | Status | Code | |
494
+ | --- | --- | --- |
495
+ | 400 | `onboarding_code_rejected` | Meta refused the code. It is single use, so the dialog has to be run again |
496
+ | 400 | `onboarding_number_rejected` | The number is not on that WABA, or Meta has banned it |
497
+ | 400 | `onboarding_state_expired` | More than an hour since `url()` |
498
+ | 403 | `onboarding_state_mismatch` | The state was issued to another account |
499
+ | 409 | `phone_number_in_use` | Already connected to a channel |
500
+ | 409 | `phone_number_pending` | **This account's own** unfinished channel — `details.channel` is its id. Resume it rather than making a second one |
501
+
502
+ ---
503
+
504
+ ## What the service is doing for you
505
+
506
+ Checks that nothing downstream repeats:
507
+
508
+ 1. **The plan has room** — at every step, because the last unit can be spent in
509
+ another tab while somebody is in the wizard.
510
+ 2. **The number is not already connected** — across every account, for the
511
+ webhook reason above. In the callback this filters as well as refuses: a
512
+ number that is taken is left out of the choice.
513
+ 3. **The token really covers the WABA** — the numbers are read back with the
514
+ minted token, which is the only thing that proves it.
515
+ 4. **The credential never reaches a browser.** The code goes to us, not to your
516
+ page; the long-lived token is encrypted at rest and swapped for a channel id;
517
+ what travels on the return URL is an outcome and a one-hour, single-use token
518
+ scoped to one account.
519
+
520
+ And two things it deliberately leaves alone:
521
+
522
+ - **The webhook subscription.** Meta is pointed at our listener at another level,
523
+ so onboarding neither registers it nor refuses a channel over it. Check it with
524
+ `channels.getProp(id, 'webhook_status')` and re-point it with
525
+ `channels.setProp(id, 'webhook_listener_update')` if it is ever not in place —
526
+ a channel whose WABA is not subscribed sends perfectly well and reports nothing
527
+ back, which is invisible until somebody replies.
528
+ - **The credit-line handshake** with the BSP. That runs on the first
529
+ `wa_pin_update` or `wa_biz_update`, where it was already implemented and where
530
+ it is idempotent.
@@ -21,6 +21,28 @@ channel is a `404` rather than a match.
21
21
  | [`delete`](#delete) | `DELETE /v1/channels/:channelId/templates/:id` | `channels:write` |
22
22
  | [`sync`](#sync) | `POST /v1/channels/:channelId/templates/sync` | `channels:write` |
23
23
 
24
+ **Permissions** — the feature is `CRM.TEMPLATE`, flat and with no plan option of
25
+ its own. A team member needs the key; an owner does not.
26
+
27
+ | Action | Key |
28
+ | --- | --- |
29
+ | `list` | `CRM.TEMPLATE.LIST` |
30
+ | `get` | `CRM.TEMPLATE.READ` |
31
+ | `create` | `CRM.TEMPLATE.CREATE` |
32
+ | `update`, `sync` | `CRM.TEMPLATE.UPDATE` |
33
+ | `delete` | `CRM.TEMPLATE.DELETE` |
34
+
35
+ `sync` pulls the provider's templates over the stored ones, which is a write to
36
+ every template on the channel — hence `UPDATE`.
37
+
38
+ The **channel** these hang off is gated separately, per type
39
+ (`CRM.CHANNEL.WHATSAPP`, `CRM.CHANNEL.SMS`, `CRM.CHANNEL.RCS`), and that gate is
40
+ not yet applied: the type is only knowable from the channel row, and on
41
+ `/channels/:id/:prop` the verb depends on which prop is named — so both need the
42
+ handler rather than the route. Until it lands, a member holding
43
+ `CRM.TEMPLATE.LIST` can read templates on any channel the account owns.
44
+
45
+
24
46
  ## The template object
25
47
 
26
48
  ```ts
@@ -13,6 +13,8 @@ through this API. To send a user to the top-up page, read
13
13
  | --- | --- | --- |
14
14
  | [`list`](#list) | `GET /v1/channels/:channelId/wallet` | `channels:read` |
15
15
 
16
+ **Permissions** — `CRM.CHANNEL.USAGE.READ`, shared with the conversation counts.
17
+
16
18
  ## The wallet log object
17
19
 
18
20
  ```ts
@@ -20,6 +20,12 @@ changes from the audience module.
20
20
  | [`update`](#update) | `PATCH /v1/channels/:channelId/webhooks/:id` | `channels:write` |
21
21
  | [`delete`](#delete) | `DELETE /v1/channels/:channelId/webhooks/:id` | `channels:write` |
22
22
 
23
+ **Permissions** — `CRM.CHANNEL.WEBHOOK`: `LIST`, `READ`, `CREATE`, `UPDATE`,
24
+ `DELETE`. The two channel props that read and re-point the provider-side
25
+ callback (`webhook_status`, `webhook_listener_update`) belong to this feature
26
+ too, not to the channel: re-pointing a listener diverts every inbound message on
27
+ that channel.
28
+
23
29
  ## The webhook object
24
30
 
25
31
  ```ts
@@ -26,6 +26,24 @@ connections it holds to customer clusters cannot crowd out anything but itself.
26
26
  | [`dataStoreCollections`](#collections) | `…/:store/collections` | `datastores:read` / `datastores:write` |
27
27
  | [`dataStoreRecords`](#records) | `…/:store/collections/:collection/records` | `datastore-records:read` / `datastore-records:write` |
28
28
 
29
+ **Permissions** — three features, one per level:
30
+
31
+ | | Keys |
32
+ | --- | --- |
33
+ | stores | `CRM.DATASTORE`: `LIST`, `READ`, `CREATE`, `UPDATE`, `DELETE`, **`TEST`** |
34
+ | collections | `CRM.DATASTORE.COLLECTION`: `LIST`, `READ`, `CREATE`, `UPDATE`, `DELETE` |
35
+ | records | `CRM.DATASTORE.RECORD`: `LIST`, `READ`, `CREATE`, `UPDATE`, `DELETE` |
36
+
37
+ `TEST` is a verb of its own because testing a connection opens an outbound
38
+ connection to the customer's database, which is more than reading the row that
39
+ describes it. `READ` on a store exposes the connection target; the credential is
40
+ never returned.
41
+
42
+ On records, `search` is `LIST` (a read with a body), and the bulk endpoints are
43
+ the verb they perform — `bulk-update` is `UPDATE`, `bulk-delete` is `DELETE`.
44
+ `CRM.DATASTORE.RECORD` is the most consequential key here: it reads and writes
45
+ rows in the customer's own database.
46
+
29
47
  ## The model
30
48
 
31
49
  Three things, nested: