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.
- package/README.md +1 -0
- package/dist/client.d.ts +6 -0
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/embedded-signup.d.ts +46 -0
- package/dist/internal/embedded-signup.js +318 -0
- package/dist/internal/embedded-signup.js.map +1 -0
- package/dist/resources/channels/onboarding.d.ts +89 -0
- package/dist/resources/channels/onboarding.js +96 -0
- package/dist/resources/channels/onboarding.js.map +1 -0
- package/dist/types/campaign.d.ts +8 -0
- package/dist/types/channels.d.ts +129 -0
- package/dist/types/channels.js.map +1 -1
- package/docs/audience/canned-replies.md +3 -0
- package/docs/audience/contact-tags.md +4 -0
- package/docs/audience/contacts.md +30 -1
- package/docs/audience/crm-triggers.md +3 -0
- package/docs/audience/custom-fields.md +23 -0
- package/docs/audience/exports.md +6 -0
- package/docs/audience/imports.md +7 -0
- package/docs/audience/notifications.md +11 -0
- package/docs/audience/scheduled-messages.md +5 -0
- package/docs/audience/segments.md +4 -0
- package/docs/audience/webhooks.md +7 -0
- package/docs/campaign/campaigns.md +27 -0
- package/docs/channels/activity.md +3 -0
- package/docs/channels/billing.md +2 -0
- package/docs/channels/channel-props.md +19 -0
- package/docs/channels/channels.md +35 -2
- package/docs/channels/onboarding.md +530 -0
- package/docs/channels/templates.md +22 -0
- package/docs/channels/wallet.md +2 -0
- package/docs/channels/webhooks.md +6 -0
- package/docs/data-store.md +18 -0
- package/docs/inbox/connection.md +88 -0
- 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
|
package/docs/channels/wallet.md
CHANGED
|
@@ -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
|
package/docs/data-store.md
CHANGED
|
@@ -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:
|