ab-ecosystem-cpass 1.0.12 → 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.
@@ -8,67 +8,93 @@ walk the account holder through Meta's own **Embedded Signup** — a wizard on
8
8
  `facebook.com` where they pick or create a WhatsApp Business Account, add a
9
9
  number, and grant our app access to it.
10
10
 
11
- So the backend cannot create a channel on its own. It hands out the URL that
12
- starts the wizard, and redeems what the browser comes back with.
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.
13
17
 
14
18
  | Action | Route | Scope |
15
19
  | --- | --- | --- |
16
20
  | [`types`](#types) | `GET /v1/channels/onboarding/types` | `channels:read` |
17
21
  | [`url`](#url) | `POST /v1/channels/onboarding/url` | `channels:write` |
18
- | [`exchange`](#exchange) | `POST /v1/channels/onboarding/exchange` | `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` |
19
24
  | [`complete`](#complete) | `POST /v1/channels/onboarding/complete` | `channels:write` |
20
- | [`connect`](#connect) | the three above, plus the popup | `channels:write` |
25
+ | [`exchange`](#exchange) | `POST /v1/channels/onboarding/exchange` | `channels:write` |
26
+ | [`connect`](#connect) | `url` + the popup + `pending` | `channels:write` |
21
27
 
22
- **Permissions** — `CRM.CHANNEL.WHATSAPP.CREATE`, checked on `url`, `exchange`
23
- **and** `complete`. Three checks rather than one because each call is a step
24
- towards a channel existing, and a role can be edited between them. `types`
25
- filters instead of refusing, like `GET /v1/channels`: a member who cannot create
26
- a WhatsApp channel sees the row marked `not_permitted` rather than losing the
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
27
33
  page.
28
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
+
29
43
  Only WhatsApp is onboarded here. Facebook and Instagram come through the same
30
44
  Meta dialog with a different signup configuration and a different set of ids
31
- coming back; they are still created by the legacy stack, and `types` reports them
32
- as `not_supported` so a picker can render the whole list it already renders.
45
+ coming back; they are still created by the legacy stack, so `types` leaves them
46
+ out entirely.
33
47
 
34
48
  ---
35
49
 
36
50
  ## The shape of it
37
51
 
38
52
  ```
39
- ┌─────────────────────────────── your console ──────────────────────────────┐
40
- │ │
41
- │ 1. types() "WhatsApp — 2 of 3 left" │
42
- │ │
43
- │ 2. url() ──────────────▶ { url, state } │
44
- │ │ │
45
- │ └── open a POPUP on that url ───▶ Meta's wizard │
46
- │ │ │
47
- │ code (on the redirect) ◀────────┤ │
48
- │ waba_id + phone_number_id │ │
49
- │ (postMessage) ◀────────┘ │
50
- │ │
51
- │ 3. exchange() ──────────────▶ { id, phoneNumber, defaults } │
52
- │ show the number on a card; let them confirm │
53
- │ │
54
- │ 4. complete() ──────────────▶ the channel │
55
- └───────────────────────────────────────────────────────────────────────────┘
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
56
74
  ```
57
75
 
58
- Two things about the middle of that are easy to get wrong, and both cost an
59
- afternoon:
76
+ Three things about the middle of that are worth knowing before you build it:
60
77
 
61
78
  - **The dialog cannot be put in an iframe.** Meta sends `X-Frame-Options` on it.
62
79
  A "modal" has to be your own chrome around a popup window, not an embedded
63
80
  page. An iframe renders blank, which looks like our bug.
64
- - **What comes back arrives in two halves.** The authorization `code` comes back
65
- the OAuth way, on the redirect. The `waba_id` and `phone_number_id` never
66
- appear in that URL — they arrive as a `postMessage` from `facebook.com`
67
- carrying `WA_EMBEDDED_SIGNUP`. A code with no ids cannot be verified and ids
68
- with no code cannot be redeemed, so both are needed.
69
-
70
- `connect()` handles both halves for you. Use the long way only if you already
71
- drive `FB.login`, or if your onboarding spans two page loads.
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.
72
98
 
73
99
  ---
74
100
 
@@ -78,24 +104,26 @@ drive `FB.login`, or if your onboarding spans two page loads.
78
104
  const { data } = await crm.channelOnboarding.types();
79
105
 
80
106
  // [
81
- // { type: 'whatsapp', label: 'WhatsApp', available: true,
107
+ // { type: 'whatsapp', label: 'WhatsApp',
82
108
  // slug: 'whatsapp_channels_28335', limit: 3, used: 1, remaining: 2 },
83
- // { type: 'facebook', label: 'Facebook Messenger', available: false,
84
- // reason: 'not_supported' },
85
- // { type: 'instagram', label: 'Instagram', available: false,
86
- // reason: 'not_supported' },
87
109
  // ]
88
110
  ```
89
111
 
90
- Branch on `available`, and say something different for each `reason`:
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:
91
114
 
92
- | `reason` | What happened | What to say |
93
- | --- | --- | --- |
94
- | `not_included` | The plan has none of this inclusion | Offer an upgrade |
95
- | `quota_exceeded` | Every unit is spent | "You have used all 3 of your WhatsApp channels" |
96
- | `not_permitted` | The account can, this person cannot | "Ask the account owner" |
97
- | `provider_not_configured` | No BSP is set up for the family | Our problem, not theirs — contact support |
98
- | `not_supported` | Not onboarded by this service yet | Hide the row, or point at the old panel |
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.
99
127
 
100
128
  `limit` and `remaining` are absent when the inclusion is granted without a count
101
129
  — some plans grant a channel family as a flag rather than a quantity, which
@@ -106,80 +134,164 @@ means *included, unmetered*. Absent is not zero.
106
134
  ## `url`
107
135
 
108
136
  ```ts
109
- const { data } = await crm.channelOnboarding.url();
110
- // { url, state, redirectUri, expiresAt, provider, remaining }
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 }
111
141
  ```
112
142
 
113
143
  | Field | |
114
144
  | --- | --- |
115
145
  | `type` | `whatsapp`, and the default |
116
146
  | `slug` | The inclusion to spend. Only needed when the plan carries more than one — each can sit behind a different BSP |
117
- | `redirectUri` | Must be one of the service's configured redirects. Omit for the default |
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 |
118
149
 
119
150
  Checks the plan **before** the account holder is sent anywhere: finding out
120
151
  afterwards means telling somebody who has just completed a business verification
121
152
  that they cannot have the channel. 402 `not_included` / `quota_exceeded` when
122
- the plan has no room, 409 `provider_not_configured` when no BSP is set up, 400
123
- `redirect_uri_not_allowed` for a redirect the service does not know.
124
-
125
- `state` is ours, not Meta's: signed, and bound to this account, this channel
126
- family and this provider. **Hand it back to `exchange()` unchanged.** It travels
127
- through Meta as the OAuth `state` parameter, so a browser that follows `url` gets
128
- it back for nothing — but `FB.login` will not round-trip it, so keep your copy.
129
- Good for an hour.
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.
130
209
 
131
210
  `remaining` is what is left *after* this one, which is the number to put next to
132
211
  "you can connect 2 more".
133
212
 
134
213
  ---
135
214
 
136
- ## `exchange`
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`
137
261
 
138
262
  ```ts
139
- const { data } = await crm.channelOnboarding.exchange({
140
- state, // from url()
141
- code, // from the dialog
142
- wabaId, // from the WA_EMBEDDED_SIGNUP message
143
- phoneNumberId,
144
- });
263
+ const { data } = await crm.channelOnboarding.pending(token);
145
264
 
146
265
  // {
147
- // id: '6721f0...', // pass to complete() as `token`
266
+ // id: '6721f0...',
148
267
  // expiresAt: '...',
149
- // phoneNumber: {
150
- // id: '...', wabaId: '...', displayPhoneNumber: '+91 93116 36081',
151
- // verifiedName: 'Acme Support', status: 'CONNECTED',
152
- // codeVerificationStatus: 'VERIFIED', qualityRating: 'GREEN',
153
- // },
154
- // defaults: { name: 'Acme Support', color: '#075E54', currency: 'INR' },
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' },
155
276
  // }
156
277
  ```
157
278
 
158
- **Nothing is created yet.** The number is a candidate: show
159
- `data.phoneNumber` on a card and let the person confirm it before it becomes a
160
- channel. That is why this is a separate call from `complete` — creating the
161
- channel the moment the code is redeemed leaves a half-configured channel behind
162
- every time somebody closes the tab on the confirmation screen.
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.
163
283
 
164
- `phoneNumber` is Meta's own description of the number, not an echo of what you
165
- sent: the service reads the WABA's numbers with the token it just minted, which
166
- is what proves the token covers that WABA and the number is really on it.
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.
167
287
 
168
- `id` stands for the long-lived token, which never leaves the service. The record
169
- it points at belongs to this account, can be used once, and expires in an hour.
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.
170
291
 
171
- | Status | Code | |
172
- | --- | --- | --- |
173
- | 400 | `onboarding_code_rejected` | Meta refused the code. It is single use, so the dialog has to be run again |
174
- | 400 | `onboarding_number_rejected` | The number is not on that WABA, or Meta has banned it |
175
- | 400 | `onboarding_state_expired` | More than an hour since `url()` |
176
- | 403 | `onboarding_state_mismatch` | The state was issued to another account |
177
- | 409 | `phone_number_in_use` | Already connected to a channel |
178
- | 409 | `phone_number_pending` | **This account's own** unfinished channel — `details.channel` is its id. Resume it rather than making a second one |
179
-
180
- `phone_number_in_use` is checked across every account, not just this one: a
181
- number carries one WABA webhook subscription at a time, so a second channel on it
182
- would quietly take over the first one's message traffic.
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.
183
295
 
184
296
  ---
185
297
 
@@ -188,6 +300,7 @@ would quietly take over the first one's message traffic.
188
300
  ```ts
189
301
  const { data: channel } = await crm.channelOnboarding.complete({
190
302
  token: candidate.id,
303
+ phoneNumberId: chosen.id, // required only for status=select
191
304
  name: 'Acme Support',
192
305
  color: '#075E54',
193
306
  currency: 'INR',
@@ -199,6 +312,11 @@ Creates the channel and returns it in the same shape `channels.get()` does.
199
312
  inclusion's, except that an INR plan falls back to USD for a number outside the
200
313
  Indian dial codes — Meta prices those in dollars however the plan is denominated.
201
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
+
202
320
  **The channel is created inactive.** Registering the number with Meta is what
203
321
  brings it up, and that is the next step in the console:
204
322
 
@@ -215,10 +333,17 @@ handshake has run. `channel.cloud.is_on_biz_app` is true for the second case.
215
333
 
216
334
  | Status | Code | |
217
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 |
218
338
  | 404 | `not_found` | No such session, or it belongs to another account |
219
339
  | 409 | `onboarding_already_completed` | A second call with the same token |
220
340
  | 410 | `onboarding_expired` | The confirmation screen sat for more than an hour |
221
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.
222
347
 
223
348
  A refusal here **releases the session rather than consuming it**, so pressing the
224
349
  button again is enough — the Meta dialog does not have to be run a second time.
@@ -231,15 +356,26 @@ Only a create that succeeds spends the token.
231
356
  ```ts
232
357
  // In the click handler for "Connect WhatsApp" — see the note below.
233
358
  const candidate = await crm.channelOnboarding.connect({
234
- onProgress: ({ phase, step }) => setStatus(phase === 'step' ? step : phase),
359
+ redirectUri: 'https://console.example.com/settings/channels',
360
+ onProgress: ({ phase }) => setStatus(phase),
235
361
  });
236
362
 
237
- // Then show the card and the form, and call complete().
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
+ });
238
369
  ```
239
370
 
240
- `url()` → popup → `exchange()`, resolving with exactly what `exchange()` returns.
241
- It creates nothing, so the person still confirms the number before it becomes a
242
- channel.
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.
243
379
 
244
380
  **Call it straight from the click handler**, not after an `await` of your own. It
245
381
  opens the popup before awaiting anything that would detach it from the gesture,
@@ -248,8 +384,9 @@ and the block is indistinguishable from the user having a popup blocker.
248
384
 
249
385
  | Option | |
250
386
  | --- | --- |
251
- | `type`, `slug`, `redirectUri` | Passed through to `url()` |
252
- | `onProgress` | `opened` → `step` (Meta's own step name) → `ids` / `code` |
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` |
253
390
  | `timeoutMs` | Default 15 minutes. The wizard can include a business verification |
254
391
  | `width`, `height`, `name` | The popup window |
255
392
 
@@ -259,57 +396,126 @@ Rejects with `EmbeddedSignupError`, whose `code` is one of:
259
396
  | --- | --- |
260
397
  | `popup_blocked` | Allow popups and try again |
261
398
  | `popup_closed` | They closed the window |
262
- | `cancelled` | They backed out inside the wizard; `message` names the step |
263
- | `signup_error` | Meta reported a problem; `message` is Meta's own text |
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`, ... |
264
400
  | `timeout` | Still open after `timeoutMs` |
265
- | `unsupported_environment` | Not a browser — drive the three calls yourself |
401
+ | `unsupported_environment` | Not a browser — drive the calls yourself |
266
402
 
267
- Everything the service refused arrives as the usual `ApiError`, so handle both:
403
+ Everything the service refused *before* the dialog opened arrives as the usual
404
+ `ApiError`, so handle both:
268
405
 
269
406
  ```ts
270
407
  try {
271
408
  const candidate = await crm.channelOnboarding.connect();
272
409
  } catch (err) {
273
410
  if (err instanceof EmbeddedSignupError) {
274
- if (err.code === 'cancelled') return; // not an error worth showing
411
+ if (err.code === 'popup_closed') return; // not an error worth showing
275
412
  return showNotice(err.message);
276
413
  }
277
- throw err; // a 402, a 409, ...
414
+ throw err; // a 402, a 409, ...
278
415
  }
279
416
  ```
280
417
 
281
418
  ### Driving the dialog yourself
282
419
 
283
- If you serve a page at the redirect, post the code back to the opener and
284
- `openSignup` will use it instead of reading the popup's URL:
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:
285
428
 
286
429
  ```html
287
430
  <!-- At your redirectUri -->
288
431
  <script>
289
- const params = new URLSearchParams(location.search);
290
- window.opener?.postMessage(
291
- { type: 'CRM_CHANNEL_ONBOARDING', code: params.get('code'), state: params.get('state') },
292
- window.origin,
293
- );
432
+ window.opener?.postMessage(location.search, window.origin);
433
+ window.close();
294
434
  </script>
295
435
  ```
296
436
 
297
- Or skip the popup helper entirely: call `url()`, run `FB.login` with the
298
- `config_id` from your own app configuration, and call `exchange()` with the
299
- `state` you kept and the ids from Meta's callback.
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 |
300
501
 
301
502
  ---
302
503
 
303
504
  ## What the service is doing for you
304
505
 
305
- Four checks that nothing downstream repeats:
506
+ Checks that nothing downstream repeats:
306
507
 
307
- 1. **The plan has room** — at all three steps, because the last unit can be spent
308
- in another tab while somebody is in the wizard.
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.
309
510
  2. **The number is not already connected** — across every account, for the
310
- webhook reason above.
311
- 3. **The token really covers the WABA** — both ids arrive in a request body, so
312
- they are claims until the WABA's numbers are read with the minted token.
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.
313
519
 
314
520
  And two things it deliberately leaves alone:
315
521
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ab-ecosystem-cpass",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "JavaScript/TypeScript SDK for the CPaaS CRM gateway: audience and channels",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",