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.
- package/README.md +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/embedded-signup.d.ts +24 -7
- package/dist/internal/embedded-signup.js +180 -37
- package/dist/internal/embedded-signup.js.map +1 -1
- package/dist/resources/channels/onboarding.d.ts +45 -18
- package/dist/resources/channels/onboarding.js +45 -25
- package/dist/resources/channels/onboarding.js.map +1 -1
- package/dist/types/campaign.d.ts +8 -0
- package/dist/types/channels.d.ts +67 -17
- package/dist/types/channels.js.map +1 -1
- package/docs/audience/contacts.md +7 -1
- package/docs/campaign/campaigns.md +7 -0
- package/docs/channels/channels.md +1 -0
- package/docs/channels/onboarding.md +333 -127
- package/package.json +1 -1
|
@@ -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.
|
|
12
|
-
|
|
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
|
-
| [
|
|
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
|
-
| [`
|
|
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`, `
|
|
23
|
-
**and** `complete`.
|
|
24
|
-
towards a channel existing, and a role can be edited between them.
|
|
25
|
-
filters instead of refusing, like `GET /v1/channels`: a member who
|
|
26
|
-
a WhatsApp channel
|
|
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,
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
|
93
|
-
| --- | --- |
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
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
|
-
|
|
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
|
-
| `
|
|
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,
|
|
123
|
-
`
|
|
124
|
-
|
|
125
|
-
`
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
##
|
|
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.
|
|
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...',
|
|
266
|
+
// id: '6721f0...',
|
|
148
267
|
// expiresAt: '...',
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
`
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
`
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
359
|
+
redirectUri: 'https://console.example.com/settings/channels',
|
|
360
|
+
onProgress: ({ phase }) => setStatus(phase),
|
|
235
361
|
});
|
|
236
362
|
|
|
237
|
-
//
|
|
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 →
|
|
241
|
-
|
|
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
|
-
| `
|
|
252
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
401
|
+
| `unsupported_environment` | Not a browser — drive the calls yourself |
|
|
266
402
|
|
|
267
|
-
Everything the service refused arrives as the usual
|
|
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 === '
|
|
411
|
+
if (err.code === 'popup_closed') return; // not an error worth showing
|
|
275
412
|
return showNotice(err.message);
|
|
276
413
|
}
|
|
277
|
-
throw err;
|
|
414
|
+
throw err; // a 402, a 409, ...
|
|
278
415
|
}
|
|
279
416
|
```
|
|
280
417
|
|
|
281
418
|
### Driving the dialog yourself
|
|
282
419
|
|
|
283
|
-
|
|
284
|
-
`
|
|
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
|
-
|
|
290
|
-
window.
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
`
|
|
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
|
-
|
|
506
|
+
Checks that nothing downstream repeats:
|
|
306
507
|
|
|
307
|
-
1. **The plan has room** — at
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
|