@polymorfa/sdk 0.1.0-dev.20260922093854

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (347) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1881 -0
  3. package/dist/bridge.d.ts +31 -0
  4. package/dist/bridge.d.ts.map +1 -0
  5. package/dist/bridge.js +39 -0
  6. package/dist/bridge.js.map +1 -0
  7. package/dist/calls/api.d.ts +109 -0
  8. package/dist/calls/api.d.ts.map +1 -0
  9. package/dist/calls/api.js +179 -0
  10. package/dist/calls/api.js.map +1 -0
  11. package/dist/calls/call.d.ts +286 -0
  12. package/dist/calls/call.d.ts.map +1 -0
  13. package/dist/calls/call.js +860 -0
  14. package/dist/calls/call.js.map +1 -0
  15. package/dist/calls/client.d.ts +124 -0
  16. package/dist/calls/client.d.ts.map +1 -0
  17. package/dist/calls/client.js +447 -0
  18. package/dist/calls/client.js.map +1 -0
  19. package/dist/calls/diagnostics.d.ts +74 -0
  20. package/dist/calls/diagnostics.d.ts.map +1 -0
  21. package/dist/calls/diagnostics.js +133 -0
  22. package/dist/calls/diagnostics.js.map +1 -0
  23. package/dist/calls/errors.d.ts +37 -0
  24. package/dist/calls/errors.d.ts.map +1 -0
  25. package/dist/calls/errors.js +52 -0
  26. package/dist/calls/errors.js.map +1 -0
  27. package/dist/calls/events.d.ts +15 -0
  28. package/dist/calls/events.d.ts.map +1 -0
  29. package/dist/calls/events.js +34 -0
  30. package/dist/calls/events.js.map +1 -0
  31. package/dist/calls/index.d.ts +6 -0
  32. package/dist/calls/index.d.ts.map +1 -0
  33. package/dist/calls/index.js +7 -0
  34. package/dist/calls/index.js.map +1 -0
  35. package/dist/calls/internal.d.ts +18 -0
  36. package/dist/calls/internal.d.ts.map +1 -0
  37. package/dist/calls/internal.js +18 -0
  38. package/dist/calls/internal.js.map +1 -0
  39. package/dist/calls/lifecycle.d.ts +111 -0
  40. package/dist/calls/lifecycle.d.ts.map +1 -0
  41. package/dist/calls/lifecycle.js +490 -0
  42. package/dist/calls/lifecycle.js.map +1 -0
  43. package/dist/calls/media.d.ts +102 -0
  44. package/dist/calls/media.d.ts.map +1 -0
  45. package/dist/calls/media.js +414 -0
  46. package/dist/calls/media.js.map +1 -0
  47. package/dist/calls/protocol.d.ts +216 -0
  48. package/dist/calls/protocol.d.ts.map +1 -0
  49. package/dist/calls/protocol.js +268 -0
  50. package/dist/calls/protocol.js.map +1 -0
  51. package/dist/calls/token.d.ts +43 -0
  52. package/dist/calls/token.d.ts.map +1 -0
  53. package/dist/calls/token.js +146 -0
  54. package/dist/calls/token.js.map +1 -0
  55. package/dist/client.d.ts +65 -0
  56. package/dist/client.d.ts.map +1 -0
  57. package/dist/client.js +119 -0
  58. package/dist/client.js.map +1 -0
  59. package/dist/credentials.d.ts +50 -0
  60. package/dist/credentials.d.ts.map +1 -0
  61. package/dist/credentials.js +73 -0
  62. package/dist/credentials.js.map +1 -0
  63. package/dist/errors.d.ts +71 -0
  64. package/dist/errors.d.ts.map +1 -0
  65. package/dist/errors.js +129 -0
  66. package/dist/errors.js.map +1 -0
  67. package/dist/index.d.ts +70 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +61 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/media/whatsapp.d.ts +105 -0
  72. package/dist/media/whatsapp.d.ts.map +1 -0
  73. package/dist/media/whatsapp.js +624 -0
  74. package/dist/media/whatsapp.js.map +1 -0
  75. package/dist/messaging/bansafe.d.ts +30 -0
  76. package/dist/messaging/bansafe.d.ts.map +1 -0
  77. package/dist/messaging/bansafe.js +86 -0
  78. package/dist/messaging/bansafe.js.map +1 -0
  79. package/dist/messaging/business.d.ts +35 -0
  80. package/dist/messaging/business.d.ts.map +1 -0
  81. package/dist/messaging/business.js +145 -0
  82. package/dist/messaging/business.js.map +1 -0
  83. package/dist/messaging/calls.d.ts +9 -0
  84. package/dist/messaging/calls.d.ts.map +1 -0
  85. package/dist/messaging/calls.js +16 -0
  86. package/dist/messaging/calls.js.map +1 -0
  87. package/dist/messaging/campaigns.d.ts +19 -0
  88. package/dist/messaging/campaigns.d.ts.map +1 -0
  89. package/dist/messaging/campaigns.js +77 -0
  90. package/dist/messaging/campaigns.js.map +1 -0
  91. package/dist/messaging/channels.d.ts +23 -0
  92. package/dist/messaging/channels.d.ts.map +1 -0
  93. package/dist/messaging/channels.js +104 -0
  94. package/dist/messaging/channels.js.map +1 -0
  95. package/dist/messaging/chats.d.ts +14 -0
  96. package/dist/messaging/chats.d.ts.map +1 -0
  97. package/dist/messaging/chats.js +51 -0
  98. package/dist/messaging/chats.js.map +1 -0
  99. package/dist/messaging/client-tokens.d.ts +15 -0
  100. package/dist/messaging/client-tokens.d.ts.map +1 -0
  101. package/dist/messaging/client-tokens.js +64 -0
  102. package/dist/messaging/client-tokens.js.map +1 -0
  103. package/dist/messaging/client.d.ts +58 -0
  104. package/dist/messaging/client.d.ts.map +1 -0
  105. package/dist/messaging/client.js +100 -0
  106. package/dist/messaging/client.js.map +1 -0
  107. package/dist/messaging/contacts.d.ts +20 -0
  108. package/dist/messaging/contacts.d.ts.map +1 -0
  109. package/dist/messaging/contacts.js +75 -0
  110. package/dist/messaging/contacts.js.map +1 -0
  111. package/dist/messaging/groups.d.ts +34 -0
  112. package/dist/messaging/groups.d.ts.map +1 -0
  113. package/dist/messaging/groups.js +97 -0
  114. package/dist/messaging/groups.js.map +1 -0
  115. package/dist/messaging/identities.d.ts +9 -0
  116. package/dist/messaging/identities.d.ts.map +1 -0
  117. package/dist/messaging/identities.js +21 -0
  118. package/dist/messaging/identities.js.map +1 -0
  119. package/dist/messaging/labels.d.ts +16 -0
  120. package/dist/messaging/labels.d.ts.map +1 -0
  121. package/dist/messaging/labels.js +65 -0
  122. package/dist/messaging/labels.js.map +1 -0
  123. package/dist/messaging/media.d.ts +72 -0
  124. package/dist/messaging/media.d.ts.map +1 -0
  125. package/dist/messaging/media.js +142 -0
  126. package/dist/messaging/media.js.map +1 -0
  127. package/dist/messaging/messages.d.ts +18 -0
  128. package/dist/messaging/messages.d.ts.map +1 -0
  129. package/dist/messaging/messages.js +32 -0
  130. package/dist/messaging/messages.js.map +1 -0
  131. package/dist/messaging/observation-policies.d.ts +10 -0
  132. package/dist/messaging/observation-policies.d.ts.map +1 -0
  133. package/dist/messaging/observation-policies.js +28 -0
  134. package/dist/messaging/observation-policies.js.map +1 -0
  135. package/dist/messaging/onboarding.d.ts +53 -0
  136. package/dist/messaging/onboarding.d.ts.map +1 -0
  137. package/dist/messaging/onboarding.js +69 -0
  138. package/dist/messaging/onboarding.js.map +1 -0
  139. package/dist/messaging/presence.d.ts +12 -0
  140. package/dist/messaging/presence.d.ts.map +1 -0
  141. package/dist/messaging/presence.js +43 -0
  142. package/dist/messaging/presence.js.map +1 -0
  143. package/dist/messaging/privacy.d.ts +11 -0
  144. package/dist/messaging/privacy.d.ts.map +1 -0
  145. package/dist/messaging/privacy.js +34 -0
  146. package/dist/messaging/privacy.js.map +1 -0
  147. package/dist/messaging/profile.d.ts +13 -0
  148. package/dist/messaging/profile.d.ts.map +1 -0
  149. package/dist/messaging/profile.js +49 -0
  150. package/dist/messaging/profile.js.map +1 -0
  151. package/dist/messaging/quick-replies.d.ts +13 -0
  152. package/dist/messaging/quick-replies.d.ts.map +1 -0
  153. package/dist/messaging/quick-replies.js +45 -0
  154. package/dist/messaging/quick-replies.js.map +1 -0
  155. package/dist/messaging/quicklinks.d.ts +69 -0
  156. package/dist/messaging/quicklinks.d.ts.map +1 -0
  157. package/dist/messaging/quicklinks.js +45 -0
  158. package/dist/messaging/quicklinks.js.map +1 -0
  159. package/dist/messaging/session-configuration.d.ts +67 -0
  160. package/dist/messaging/session-configuration.d.ts.map +1 -0
  161. package/dist/messaging/session-configuration.js +2 -0
  162. package/dist/messaging/session-configuration.js.map +1 -0
  163. package/dist/messaging/sessions.d.ts +26 -0
  164. package/dist/messaging/sessions.d.ts.map +1 -0
  165. package/dist/messaging/sessions.js +108 -0
  166. package/dist/messaging/sessions.js.map +1 -0
  167. package/dist/messaging/templates.d.ts +15 -0
  168. package/dist/messaging/templates.d.ts.map +1 -0
  169. package/dist/messaging/templates.js +67 -0
  170. package/dist/messaging/templates.js.map +1 -0
  171. package/dist/messaging/testing-configuration.d.ts +89 -0
  172. package/dist/messaging/testing-configuration.d.ts.map +1 -0
  173. package/dist/messaging/testing-configuration.js +14 -0
  174. package/dist/messaging/testing-configuration.js.map +1 -0
  175. package/dist/messaging/types.d.ts +1793 -0
  176. package/dist/messaging/types.d.ts.map +1 -0
  177. package/dist/messaging/types.js +25 -0
  178. package/dist/messaging/types.js.map +1 -0
  179. package/dist/messaging/users.d.ts +9 -0
  180. package/dist/messaging/users.d.ts.map +1 -0
  181. package/dist/messaging/users.js +15 -0
  182. package/dist/messaging/users.js.map +1 -0
  183. package/dist/messaging/voip.d.ts +51 -0
  184. package/dist/messaging/voip.d.ts.map +1 -0
  185. package/dist/messaging/voip.js +293 -0
  186. package/dist/messaging/voip.js.map +1 -0
  187. package/dist/messaging/webhooks.d.ts +13 -0
  188. package/dist/messaging/webhooks.d.ts.map +1 -0
  189. package/dist/messaging/webhooks.js +48 -0
  190. package/dist/messaging/webhooks.js.map +1 -0
  191. package/dist/node.d.ts +55 -0
  192. package/dist/node.d.ts.map +1 -0
  193. package/dist/node.js +237 -0
  194. package/dist/node.js.map +1 -0
  195. package/dist/pagination.d.ts +24 -0
  196. package/dist/pagination.d.ts.map +1 -0
  197. package/dist/pagination.js +28 -0
  198. package/dist/pagination.js.map +1 -0
  199. package/dist/platform/api-keys.d.ts +10 -0
  200. package/dist/platform/api-keys.d.ts.map +1 -0
  201. package/dist/platform/api-keys.js +22 -0
  202. package/dist/platform/api-keys.js.map +1 -0
  203. package/dist/platform/audiences.d.ts +16 -0
  204. package/dist/platform/audiences.d.ts.map +1 -0
  205. package/dist/platform/audiences.js +46 -0
  206. package/dist/platform/audiences.js.map +1 -0
  207. package/dist/platform/audit-logs.d.ts +9 -0
  208. package/dist/platform/audit-logs.d.ts.map +1 -0
  209. package/dist/platform/audit-logs.js +20 -0
  210. package/dist/platform/audit-logs.js.map +1 -0
  211. package/dist/platform/bansafe.d.ts +24 -0
  212. package/dist/platform/bansafe.d.ts.map +1 -0
  213. package/dist/platform/bansafe.js +70 -0
  214. package/dist/platform/bansafe.js.map +1 -0
  215. package/dist/platform/billing.d.ts +13 -0
  216. package/dist/platform/billing.d.ts.map +1 -0
  217. package/dist/platform/billing.js +27 -0
  218. package/dist/platform/billing.js.map +1 -0
  219. package/dist/platform/campaigns.d.ts +28 -0
  220. package/dist/platform/campaigns.d.ts.map +1 -0
  221. package/dist/platform/campaigns.js +92 -0
  222. package/dist/platform/campaigns.js.map +1 -0
  223. package/dist/platform/customers.d.ts +23 -0
  224. package/dist/platform/customers.d.ts.map +1 -0
  225. package/dist/platform/customers.js +140 -0
  226. package/dist/platform/customers.js.map +1 -0
  227. package/dist/platform/developer-resources.d.ts +101 -0
  228. package/dist/platform/developer-resources.d.ts.map +1 -0
  229. package/dist/platform/developer-resources.js +250 -0
  230. package/dist/platform/developer-resources.js.map +1 -0
  231. package/dist/platform/developer-types.d.ts +378 -0
  232. package/dist/platform/developer-types.d.ts.map +1 -0
  233. package/dist/platform/developer-types.js +2 -0
  234. package/dist/platform/developer-types.js.map +1 -0
  235. package/dist/platform/event-stream.d.ts +114 -0
  236. package/dist/platform/event-stream.d.ts.map +1 -0
  237. package/dist/platform/event-stream.js +308 -0
  238. package/dist/platform/event-stream.js.map +1 -0
  239. package/dist/platform/media.d.ts +13 -0
  240. package/dist/platform/media.d.ts.map +1 -0
  241. package/dist/platform/media.js +33 -0
  242. package/dist/platform/media.js.map +1 -0
  243. package/dist/platform/members.d.ts +9 -0
  244. package/dist/platform/members.d.ts.map +1 -0
  245. package/dist/platform/members.js +15 -0
  246. package/dist/platform/members.js.map +1 -0
  247. package/dist/platform/opt-outs.d.ts +15 -0
  248. package/dist/platform/opt-outs.d.ts.map +1 -0
  249. package/dist/platform/opt-outs.js +36 -0
  250. package/dist/platform/opt-outs.js.map +1 -0
  251. package/dist/platform/organizations.d.ts +9 -0
  252. package/dist/platform/organizations.d.ts.map +1 -0
  253. package/dist/platform/organizations.js +15 -0
  254. package/dist/platform/organizations.js.map +1 -0
  255. package/dist/platform/project-tokens.d.ts +9 -0
  256. package/dist/platform/project-tokens.d.ts.map +1 -0
  257. package/dist/platform/project-tokens.js +16 -0
  258. package/dist/platform/project-tokens.js.map +1 -0
  259. package/dist/platform/projects.d.ts +24 -0
  260. package/dist/platform/projects.d.ts.map +1 -0
  261. package/dist/platform/projects.js +83 -0
  262. package/dist/platform/projects.js.map +1 -0
  263. package/dist/platform/quicklink-settings.d.ts +85 -0
  264. package/dist/platform/quicklink-settings.d.ts.map +1 -0
  265. package/dist/platform/quicklink-settings.js +35 -0
  266. package/dist/platform/quicklink-settings.js.map +1 -0
  267. package/dist/platform/response.d.ts +10 -0
  268. package/dist/platform/response.d.ts.map +1 -0
  269. package/dist/platform/response.js +41 -0
  270. package/dist/platform/response.js.map +1 -0
  271. package/dist/platform/security-incidents.d.ts +10 -0
  272. package/dist/platform/security-incidents.d.ts.map +1 -0
  273. package/dist/platform/security-incidents.js +22 -0
  274. package/dist/platform/security-incidents.js.map +1 -0
  275. package/dist/platform/session-bans.d.ts +13 -0
  276. package/dist/platform/session-bans.d.ts.map +1 -0
  277. package/dist/platform/session-bans.js +21 -0
  278. package/dist/platform/session-bans.js.map +1 -0
  279. package/dist/platform/session-configuration.d.ts +15 -0
  280. package/dist/platform/session-configuration.d.ts.map +1 -0
  281. package/dist/platform/session-configuration.js +36 -0
  282. package/dist/platform/session-configuration.js.map +1 -0
  283. package/dist/platform/sessions.d.ts +26 -0
  284. package/dist/platform/sessions.d.ts.map +1 -0
  285. package/dist/platform/sessions.js +121 -0
  286. package/dist/platform/sessions.js.map +1 -0
  287. package/dist/platform/sip-trunks.d.ts +141 -0
  288. package/dist/platform/sip-trunks.d.ts.map +1 -0
  289. package/dist/platform/sip-trunks.js +140 -0
  290. package/dist/platform/sip-trunks.js.map +1 -0
  291. package/dist/platform/types.d.ts +897 -0
  292. package/dist/platform/types.d.ts.map +1 -0
  293. package/dist/platform/types.js +2 -0
  294. package/dist/platform/types.js.map +1 -0
  295. package/dist/raw.d.ts +26 -0
  296. package/dist/raw.d.ts.map +1 -0
  297. package/dist/raw.js +79 -0
  298. package/dist/raw.js.map +1 -0
  299. package/dist/system.d.ts +37 -0
  300. package/dist/system.d.ts.map +1 -0
  301. package/dist/system.js +45 -0
  302. package/dist/system.js.map +1 -0
  303. package/dist/transport/body.d.ts +6 -0
  304. package/dist/transport/body.d.ts.map +1 -0
  305. package/dist/transport/body.js +33 -0
  306. package/dist/transport/body.js.map +1 -0
  307. package/dist/transport/content-disposition.d.ts +9 -0
  308. package/dist/transport/content-disposition.d.ts.map +1 -0
  309. package/dist/transport/content-disposition.js +107 -0
  310. package/dist/transport/content-disposition.js.map +1 -0
  311. package/dist/transport/http.d.ts +35 -0
  312. package/dist/transport/http.d.ts.map +1 -0
  313. package/dist/transport/http.js +660 -0
  314. package/dist/transport/http.js.map +1 -0
  315. package/dist/transport/idempotency.d.ts +10 -0
  316. package/dist/transport/idempotency.d.ts.map +1 -0
  317. package/dist/transport/idempotency.js +13 -0
  318. package/dist/transport/idempotency.js.map +1 -0
  319. package/dist/transport/retry.d.ts +11 -0
  320. package/dist/transport/retry.d.ts.map +1 -0
  321. package/dist/transport/retry.js +45 -0
  322. package/dist/transport/retry.js.map +1 -0
  323. package/dist/transport/types.d.ts +58 -0
  324. package/dist/transport/types.d.ts.map +1 -0
  325. package/dist/transport/types.js +2 -0
  326. package/dist/transport/types.js.map +1 -0
  327. package/dist/version.d.ts +2 -0
  328. package/dist/version.d.ts.map +1 -0
  329. package/dist/version.js +2 -0
  330. package/dist/version.js.map +1 -0
  331. package/dist/webhooks/events.d.ts +741 -0
  332. package/dist/webhooks/events.d.ts.map +1 -0
  333. package/dist/webhooks/events.js +80 -0
  334. package/dist/webhooks/events.js.map +1 -0
  335. package/dist/webhooks/index.d.ts +4 -0
  336. package/dist/webhooks/index.d.ts.map +1 -0
  337. package/dist/webhooks/index.js +4 -0
  338. package/dist/webhooks/index.js.map +1 -0
  339. package/dist/webhooks/utilities.d.ts +33 -0
  340. package/dist/webhooks/utilities.d.ts.map +1 -0
  341. package/dist/webhooks/utilities.js +67 -0
  342. package/dist/webhooks/utilities.js.map +1 -0
  343. package/dist/webhooks/verify.d.ts +9 -0
  344. package/dist/webhooks/verify.d.ts.map +1 -0
  345. package/dist/webhooks/verify.js +77 -0
  346. package/dist/webhooks/verify.js.map +1 -0
  347. package/package.json +58 -0
package/README.md ADDED
@@ -0,0 +1,1881 @@
1
+ # `@polymorfa/sdk`
2
+
3
+ The handwritten Polymorfa server SDK for TypeScript and Node.js.
4
+
5
+ This package has not been published to npm. Build it from a clone of the
6
+ development branch and install the packed tarball:
7
+
8
+ ```bash
9
+ npm ci
10
+ npm run build:workspaces
11
+ npm pack -w @polymorfa/sdk
12
+ ```
13
+
14
+ The Calls client is part of this package as `@polymorfa/sdk/calls`.
15
+ `@polymorfa/sdk/calls/internal` exists for the Polymorfa browser package;
16
+ applications must not import it.
17
+
18
+ Import the management and Messaging clients, errors, response metadata,
19
+ request options, pagination, webhook utilities, and public request/response
20
+ types from the package root:
21
+
22
+ ```ts
23
+ import {
24
+ BridgeClient,
25
+ Client,
26
+ MessagingClient,
27
+ PolymorfaError,
28
+ SystemClient,
29
+ webhooks,
30
+ type RequestOptions,
31
+ } from "@polymorfa/sdk";
32
+ ```
33
+
34
+ See the repository README for the complete development contract and current
35
+ typed-resource coverage. This package has no runtime dependencies and requires
36
+ Node.js 20 or newer.
37
+
38
+ ## Management client and project views
39
+
40
+ `Client` has one ownership context for its lifetime. Construct an organization
41
+ client with an organization API key, then derive immutable project views with
42
+ `project(projectId)`:
43
+
44
+ ```ts
45
+ const platform = new Client({
46
+ credential: {
47
+ type: "organizationApiKey",
48
+ value: process.env.POLYMORFA_PLATFORM_API_KEY!,
49
+ },
50
+ apiVersion: "1.0.0",
51
+ });
52
+
53
+ const project = platform.project("project_123");
54
+ const events = await project.events.list({ limit: 25 });
55
+ console.log(events.items, events.response.metadata.requestId);
56
+ ```
57
+
58
+ A project token can construct only a project view and requires `projectId`:
59
+
60
+ ```ts
61
+ const project = new Client({
62
+ credential: {
63
+ type: "projectToken",
64
+ value: process.env.POLYMORFA_PROJECT_TOKEN!,
65
+ },
66
+ projectId: "project_123",
67
+ });
68
+ ```
69
+
70
+ The server verifies that initial binding. Rebinding the same project-token
71
+ client to a different project fails before transport. A project view exposes
72
+ only owner-bound management resources; organization resources such as
73
+ `projects`, `members`, and `billing` stay on the organization client.
74
+ `MessagingClient` remains separate because its session APIs and credentials
75
+ have a different authorization boundary.
76
+
77
+ Messaging credentials are explicit: `apiKey` for an organization server key,
78
+ `projectToken` for the single opaque project-token format, and `clientToken`
79
+ for the browser action allowlist. Server credentials fail in browser runtimes.
80
+ An organization key is exactly `pmfa_` plus 72 unpadded base64url characters;
81
+ a project token is exactly `pmfa_pt_` plus 94. The SDK checks only this public
82
+ v1 grammar and never decodes or decrypts the credential.
83
+
84
+ The SDK rejects `pmfa_ct_` browser tokens and CLI-only `pmfa_ls_` listener
85
+ credentials before a management request. It also rejects retired call-agent
86
+ `pmfa_at_` and socket `pmfa_wst_` tickets and simulated-device `pmfa_sd_`
87
+ capabilities as server API keys. It does not expose a listener,
88
+ `AsyncIterable`, event emitter, or forwarding API. Live forwarding belongs to
89
+ `polymorfa listen`.
90
+
91
+ ## System and Bridge clients
92
+
93
+ `SystemClient` is credential-free. Its four methods preserve the normal
94
+ `ApiResponse<T>` metadata while calling public service probes:
95
+
96
+ ```ts
97
+ const system = new SystemClient();
98
+ const [status, version, health, ping] = await Promise.all([
99
+ system.status(),
100
+ system.version(),
101
+ system.health(),
102
+ system.ping(),
103
+ ]);
104
+ ```
105
+
106
+ `BridgeClient` accepts only `{ type: "projectToken", value }`. It exposes
107
+ `routes.resolve()` for regional Bridge route discovery:
108
+
109
+ ```ts
110
+ const bridge = new BridgeClient({
111
+ credential: {
112
+ type: "projectToken",
113
+ value: process.env.POLYMORFA_PROJECT_TOKEN!,
114
+ },
115
+ });
116
+
117
+ const route = await bridge.routes.resolve();
118
+ console.log(route.data.wsUrl, route.metadata.requestId);
119
+ ```
120
+
121
+ `BridgeClient` does not open the returned WebSocket or manage its lifecycle.
122
+ It is also unrelated to the CLI-only SSE listener protocol. Project tokens and
123
+ listener credentials are not interchangeable; `pmfa_ls_` fails before a
124
+ Bridge request.
125
+
126
+ ## Customers
127
+
128
+ Use `Client.customers` to manage project-owned Customers and their
129
+ Numbers. The resource covers the complete Customers contract, including
130
+ enablement, profile lifecycle, pairing links, recent events, and Number
131
+ transfers.
132
+
133
+ ```ts
134
+ const customer = await platform.customers.create(
135
+ {
136
+ projectId: "project_123",
137
+ name: "Ada",
138
+ externalCustomerId: "crm_456",
139
+ },
140
+ { idempotencyKey: crypto.randomUUID() },
141
+ );
142
+
143
+ const pairing = await platform.customers.createPairingLink(
144
+ customer.data.data.id,
145
+ {
146
+ projectId: "project_123",
147
+ methods: ["qr", "phone"],
148
+ },
149
+ { idempotencyKey: crypto.randomUUID() },
150
+ );
151
+
152
+ console.log(pairing.data.data.url);
153
+ ```
154
+
155
+ The pairing URL is returned once. An idempotent replay returns the same link
156
+ record with `url: null`. `customers.list()` preserves both the Customer array
157
+ and the cursor metadata from the API response.
158
+
159
+ ## BanSafe Health and telemetry
160
+
161
+ `Client.banSafe` reads Health, telemetry collection status, the fixed
162
+ signal catalogue, findings, restrictions, incidents, claims, and Health action
163
+ history. Paged methods preserve the API's `data` array and `page` metadata.
164
+
165
+ ```ts
166
+ const health = await platform.banSafe.getHealth("support");
167
+ const telemetry = await platform.banSafe.getTelemetry("support");
168
+ const actions = await platform.banSafe.listHealthActions({
169
+ projectId: "project_123",
170
+ session: "support",
171
+ status: "succeeded",
172
+ });
173
+
174
+ console.log(
175
+ health.data.data.health,
176
+ telemetry.data.data.collection.state,
177
+ actions.data.page.hasMore,
178
+ );
179
+ ```
180
+
181
+ Use `platform.projects` for project Safe Mode, warm-up, Ban Insurance evidence,
182
+ and Health policy settings. Use `platform.sessions` for one number's Safe Mode
183
+ override. `MessagingClient.banSafe` exposes the same settings on the Messaging
184
+ API for organization API keys and project tokens; its responses carry
185
+ `success: true` beside `data`. Browser client tokens fail before any request.
186
+
187
+ Claim `measuredCents`, `capCents`, and `amountCents` are credit quantities with
188
+ up to six decimal places, not integer cents. Finding acknowledgement and
189
+ enforcement appeals require a signed-in dashboard session and are not SDK
190
+ methods.
191
+
192
+ ```ts
193
+ const policy = await platform.projects.getHealthPolicy("project_123");
194
+ await platform.projects.updateHealthPolicy("project_123", {
195
+ version: policy.data.data.version,
196
+ enabled: true,
197
+ threshold: 50,
198
+ sessionAction: "slow_down",
199
+ slowDownMps: 0.5,
200
+ emailNotification: true,
201
+ webhookNotification: true,
202
+ });
203
+
204
+ const messaging = new MessagingClient({
205
+ credential: {
206
+ type: "projectToken",
207
+ value: process.env.POLYMORFA_PROJECT_TOKEN!,
208
+ },
209
+ });
210
+ const safeMode = await messaging.banSafe.getSessionSafeMode("support");
211
+ console.log(safeMode.data.data.effective.presence);
212
+ ```
213
+
214
+ Finding acknowledgement and restriction appeals require a signed-in dashboard
215
+ user. The organization-key SDK does not expose those two mutations.
216
+
217
+ ## Browser client tokens
218
+
219
+ `MessagingClient.clientTokens` mints short-lived tokens and manages the live
220
+ session rules that authorize them. Use it only on the server. The browser-safe
221
+ transport and allowed-action resources live in `@polymorfa/browser`; the
222
+ Next.js-compatible route adapter lives in `@polymorfa/nextjs`.
223
+
224
+ Minting a client token and updating its session rules require all six client
225
+ delegation scopes: `sessions:manage`, `messages:write`, `contacts:read`,
226
+ `presence:read`, `presence:observe`, and `mcp`. The issuing key must cover
227
+ every action that the session rules can delegate to the browser token.
228
+ `clientTokens.mint` (`POST /platform/client-tokens`) is the only token issuer,
229
+ including for Calls; there are no call-specific tokens or tickets.
230
+
231
+ ### Customer-scoped tokens (beta)
232
+
233
+ Pass a Polymorfa Customer ID in `customer` instead of `session` to mint one
234
+ token for the numbers a Customer owns. The issuing key also needs
235
+ `customers:read`, and the team must be enrolled in the Customer-scoped client
236
+ tokens beta.
237
+
238
+ ```ts
239
+ const { data } = await messaging.clientTokens.mint({
240
+ customer: "0190f0b6-7c1e-7a55-9d1a-2f0c6b1e4a10",
241
+ ephemeralId: "user_42",
242
+ allow: ["send_message", "read_presence"],
243
+ ttlSeconds: 900,
244
+ });
245
+ ```
246
+
247
+ The token covers the numbers the Customer owns at mint time. A number moved
248
+ to another Customer stops working with the token on the next request; a
249
+ number moved to this Customer needs a new token. Each request is still
250
+ limited by that session's client rules, and `allow` (typed as
251
+ `CustomerClientTokenAction`) narrows it further. Customer-scoped tokens can't
252
+ use Calls or MCP. Never pass your own external ID as `customer`; look up the
253
+ Customer on your server first. The SDK throws `PolymorfaConfigurationError`
254
+ before sending if both or neither of `session` and `customer` are set, or if
255
+ `allow` is set without `customer`.
256
+
257
+ ## Session connection lifecycle
258
+
259
+ Session administration uses Platform routes and requires a server credential.
260
+ The existing `MessagingClient.sessions` method names remain available.
261
+ Start an existing Linked Device session, then retrieve its connection status with
262
+ `sessions.retrieve`. The standard pairing flow is QuickLink. Direct JSON QR and phone
263
+ pairing-code routes require `sessions:manage` plus an explicit organization
264
+ entitlement; without it, the API returns `403` and the application must create
265
+ a QuickLink. Follow a returned operation ID with `Client.operations.get` or
266
+ `Client.operations.wait`.
267
+
268
+ ```ts
269
+ const started = await messaging.sessions.start("support", {
270
+ idempotencyKey: "start-support",
271
+ });
272
+
273
+ console.log(started.data.data.started);
274
+ console.log((await messaging.sessions.retrieve("support")).data);
275
+ ```
276
+
277
+ `sessions.retrieve` is the typed source of session connection status. The
278
+ pinned API contract does not expose session logs or a separate
279
+ connection-status endpoint. The API no longer emits `session.qr` webhook
280
+ events. Applications must observe QuickLink state through the QuickLink flow;
281
+ the entitlement-gated `sessions.qr` and `sessions.requestPairingCode` methods
282
+ remain available only for organizations that have direct pairing enabled.
283
+
284
+ ## Project templates
285
+
286
+ `MessagingClient.templates` provides the seven canonical project-template
287
+ operations: list, create, retrieve, update, delete, preview, and submit to Meta.
288
+ Definitions use the exported `TemplateDefinition` model instead of generic
289
+ component objects.
290
+
291
+ ```ts
292
+ const created = await messaging.templates.create("support", {
293
+ name: "order_ready",
294
+ definition: {
295
+ version: 1,
296
+ kind: "standard",
297
+ category: "UTILITY",
298
+ language: "en_US",
299
+ body: "Hello {{name}}, your order is ready.",
300
+ variables: [{ name: "name", type: "text", example: "Ada" }],
301
+ },
302
+ });
303
+
304
+ await messaging.templates.preview("support", created.data.data.id, {
305
+ values: { name: "Grace" },
306
+ });
307
+ ```
308
+
309
+ Keep this client on the server. Browser builders use an application-owned
310
+ route, such as `createTemplateBuilderRoute` from `@polymorfa/nextjs`.
311
+
312
+ ## Contacts
313
+
314
+ `MessagingClient.contacts` exposes the complete Linked Device contact surface.
315
+ Read operations require `contacts:read`; blocking and unblocking require
316
+ `contacts:manage`.
317
+
318
+ ```ts
319
+ const contacts = await messaging.contacts.list("support");
320
+ const registrations = await messaging.contacts.check("support", [
321
+ "+15551234567",
322
+ "+15557654321",
323
+ ]);
324
+
325
+ const firstRegistration = registrations.data.data[0];
326
+ if (firstRegistration?.exists && firstRegistration.id) {
327
+ await messaging.contacts.block("support", firstRegistration.id, {
328
+ idempotencyKey: "block-abusive-contact",
329
+ });
330
+ }
331
+
332
+ console.log(contacts.data.data, contacts.metadata.requestId);
333
+ ```
334
+
335
+ The resource also provides `retrieve`, `picture`, `info`, `devices`,
336
+ `businessProfile`, `blocklist`, and `unblock`. Contact operations are not
337
+ available for Cloud API sessions.
338
+
339
+ ## Polymorfa Calls
340
+
341
+ `MessagingClient.voip` controls calls from a server. Every incoming call rings
342
+ until a participant accepts or rejects it; nothing answers automatically.
343
+
344
+ ```ts
345
+ const placed = await messaging.voip.place(
346
+ { session: "support", to: "+15551234567", participant: "agent-7" },
347
+ { idempotencyKey: "place-order-1042" },
348
+ );
349
+
350
+ const accepted = await messaging.voip.accept(incomingCallId, {
351
+ exclusive: true,
352
+ participant: "agent-7",
353
+ });
354
+ console.log(accepted.data.data.answeredBy); // "server:agent-7"
355
+
356
+ await messaging.voip.addParticipant(placed.data.data.callId, {
357
+ to: "+15557654321",
358
+ });
359
+ await messaging.voip.leave(incomingCallId, { connectionId: "conn_desk_1" });
360
+ await messaging.voip.end(placed.data.data.callId);
361
+ ```
362
+
363
+ - `place` requires `session` with a server credential and accepts `video`,
364
+ `exclusive`, and `participant`. Send an idempotency key to retry safely.
365
+ - `accept` answers a ringing call. Later accepts from other participants join
366
+ the call unless a participant claimed it with `exclusive: true`; those
367
+ requests fail with `409 call_claimed` (`PolymorfaConflictError`). Repeating
368
+ an accept as the same participant has no further effect.
369
+ - `reject` declines a ringing call and fails with `409 call_not_ringing`
370
+ otherwise.
371
+ - `leave` closes one media connection. `end` ends the call for everyone.
372
+ - `addParticipant` invites another WhatsApp user and returns a
373
+ `VoipParticipant`.
374
+
375
+ A server credential acts as `server:<participant>`; `participant` matches
376
+ `[A-Za-z0-9._:@-]{1,128}` and defaults to `default`. A client token acts as its
377
+ own participant, so the SDK rejects `participant` for client tokens. The SDK
378
+ checks `participant` and `connectionId` (`[A-Za-z0-9_-]{8,64}`) before sending.
379
+
380
+ `voip.retrieveCallSettings(session)` and `voip.updateCallSettings(session,
381
+ { conferenceMode, inboundRoute, sipTrunkId, sipClaim, hostCloudApiCalls })` read and change the
382
+ session's call settings through `/platform/sessions/{session}/call-settings`.
383
+ `callsEnabled: false` turns calling off for the session: placing, answering,
384
+ joining, inviting and media fail with `PolymorfaAuthorizationError`
385
+ (`calls_disabled`), incoming calls are declined, and calls in progress
386
+ continue. `conferenceMode` (default `true`) lets every participant you connect
387
+ to a call (browser, app and server connections, and SIP trunk callers) hear
388
+ the WhatsApp party and each other; with `false`, each hears only the WhatsApp
389
+ party. The WhatsApp party always hears all of your participants, and nobody
390
+ hears their own audio in either mode. `inboundRoute` is `clients` (the default) or
391
+ `sip_trunk`, which also sends incoming calls to `sipTrunkId`; `sipClaim`
392
+ (default `true`) makes the trunk's answer claim the call. On a Cloud API
393
+ session, `hostCloudApiCalls: true` has Polymorfa Calls answer incoming calls;
394
+ with the default `false`, your Graph API integration answers them. An update changes
395
+ only the settings you send. Pass the `revision` you read as
396
+ `expectedRevision` to fail with `PolymorfaConflictError` (`state_conflict`) if
397
+ the settings changed meanwhile.
398
+ These methods require a server credential.
399
+
400
+ `voip.report(callId, report)` sends diagnostics your app measured for one of
401
+ its media connections: `{ kind: "quality", connectionId, quality }` with at
402
+ least one of `rttMs`, `jitterMs`, `packetsLost`, `packetsReceived`,
403
+ `audioCodec`, `videoCodec`, `candidateType` and `reconnects`, or
404
+ `{ kind: "error", connectionId, error: { code } }`. `client` optionally names
405
+ the SDK (`sdk`, `version`, `platform`). The SDK rejects fields the platform
406
+ does not accept before sending. The platform accepts one quality report per
407
+ connection every 5 seconds and 20 error reports per minute, while the call is
408
+ live and for 10 minutes after it ends. Treat reports as best-effort: do not
409
+ retry a `4xx`, and drop reports refused with `429` or `503`. Client tokens
410
+ need the `voip_signal` action and cannot send `participant`. The browser and
411
+ Calls clients send these reports for you.
412
+
413
+ ## SIP trunks
414
+
415
+ `Client.sipTrunks` manages the SIP trunks that connect a PBX to a project's
416
+ calls. SIP trunks are part of Calls and need no enrollment.
417
+ Team clients name the project on `list` and `create`; project clients use their
418
+ own project.
419
+
420
+ ```ts
421
+ const project = platform.project("018f0000-0000-7000-8000-000000000002");
422
+ const { data } = await project.sipTrunks.create({
423
+ name: "Head office PBX",
424
+ direction: "both",
425
+ outbound: { targetUri: "sips:pbx.example.com", transport: "tls" },
426
+ inbound: { session: "support", allowedAddresses: ["203.0.113.10"] },
427
+ });
428
+ // Store data.inboundCredentials now; the password is not returned again.
429
+ await project.sipTrunks.update(data.trunk.id, {
430
+ enabled: false,
431
+ expectedRevision: data.trunk.revision,
432
+ });
433
+ ```
434
+
435
+ `retrieve`, `update`, `delete`, and `rotateCredentials` take a trunk ID. A
436
+ project client built from a team key reads the trunk first and refuses a trunk
437
+ of another project with `PolymorfaNotFoundError`. Conflicts raise
438
+ `PolymorfaConflictError` with `code` `sip_trunk_in_use`,
439
+ `sip_trunk_revision_conflict`, `sip_trunk_limit`, or `state_conflict`.
440
+
441
+ ## Calls and stable user identity
442
+
443
+ The `calls`, `identities`, and `users` resources use public Polymorfa user IDs.
444
+ Identity resolution accepts an ID, phone number, or BSUID. Calling and security
445
+ code checks require a connected Linked Device Number; identity resolution also
446
+ supports Cloud Numbers when their business portfolio is configured.
447
+
448
+ ```ts
449
+ await messaging.calls.reject(
450
+ "support",
451
+ incomingCallId,
452
+ { from: callerId },
453
+ { idempotencyKey: incomingCallId },
454
+ );
455
+
456
+ const identity = await messaging.identities.resolve("support", {
457
+ phoneNumber: "+15551234567",
458
+ });
459
+
460
+ if (identity.data.data.id) {
461
+ const code = await messaging.users.getSecurityCode(
462
+ "support",
463
+ identity.data.data.id,
464
+ );
465
+ console.log(code.data.data.numericCode, code.data.data.qrCode);
466
+ }
467
+ ```
468
+
469
+ `calls.reject` requires `chats:manage`. Its call ID must contain 1 through 128
470
+ characters and the JSON `from` field must contain the incoming caller's
471
+ public Polymorfa user ID or E.164 phone number. Path identifiers are URL-encoded.
472
+ The SDK sends an idempotency key
473
+ when supplied and only permits automatic retries of this POST when that key is
474
+ nonempty. This session-scoped route is separate from the Polymorfa Calls
475
+ routes on `MessagingClient.voip`.
476
+
477
+ The pinned OpenAPI declares a generic synchronous `SuccessResponse` for call
478
+ rejection. The live runner returns
479
+ `{ success: true, data: { status: "REJECTED" } }`. A caller can explicitly send
480
+ `Prefer: respond-async` through `RequestOptions.headers`, in which case the live
481
+ RPC returns HTTP 202 with `{ success: true, data: { requestId } }`.
482
+ `RejectCallResponse` represents all three source-observable shapes.
483
+
484
+ `identities.resolve` requires `contacts:read`. `ResolveIdentityParams` is a discriminated
485
+ union that permits exactly one of these inputs:
486
+
487
+ - `phoneNumber`: digits with an optional leading `+`; the runner trims
488
+ surrounding whitespace and returns a normalized leading `+` when known
489
+ - `id`: a decimal Polymorfa user ID
490
+ - `username`: 3 through 35 characters, with an optional four-digit
491
+ `usernameKey`
492
+
493
+ `usernameKey` is invalid without `username`, and competing identity inputs are
494
+ rejected before runner dispatch. The response can contain the stable `id`, a
495
+ phone number, a BSUID, a username, and `keyRequired` when
496
+ WhatsApp needs the username's four-digit key. The source exposes no bulk
497
+ resolution, search, list, pagination, or retained identity history.
498
+
499
+ `users.getSecurityCode` also requires `contacts:read` and accepts only a stable
500
+ decimal Polymorfa user ID. The result contains that ID,
501
+ optional known aliases, a 60-digit `numericCode`, and a base64-encoded display
502
+ `qrCode`. The runner deliberately excludes WhatsApp's private verification QR
503
+ payload, and the API schema rejects an upstream response that does not match
504
+ the public shape. The API marks successful and failed responses
505
+ `Cache-Control: private, no-store`; callers can inspect that header through
506
+ `ApiResponse.metadata.headers`. This GET is always synchronous. The source
507
+ exposes no security-code list, cache, history, refresh, or verification-submit
508
+ operation.
509
+
510
+ All three methods preserve request IDs and response metadata and accept the
511
+ standard timeout, cancellation, API-version, custom-header, and retry options.
512
+
513
+ ## Groups
514
+
515
+ `MessagingClient.groups` exposes all 21 operations in the pinned Groups tag.
516
+ Reads require `groups:read`; mutations require `groups:manage`.
517
+
518
+ ```ts
519
+ const groups = await messaging.groups.list("support");
520
+ const group = groups.data.data[0];
521
+
522
+ if (group) {
523
+ const participants = await messaging.groups.listParticipants(
524
+ "support",
525
+ group.id,
526
+ );
527
+
528
+ await messaging.groups.addParticipants(
529
+ "support",
530
+ group.id,
531
+ { participants: ["15551234567"] },
532
+ { idempotencyKey: "add-support-participant" },
533
+ );
534
+
535
+ console.log(participants.data.data, participants.metadata.requestId);
536
+ }
537
+ ```
538
+
539
+ The resource includes create and retrieve, invite-code lookup and revocation,
540
+ join-info lookup, join and leave, participant add/remove/promote/demote,
541
+ subject and description updates, profile pictures, and all four group
542
+ permission settings. `delete` maps the API's DELETE leave alias; it does not
543
+ delete the remote group for every participant. Group identifiers and session
544
+ names are encoded as path segments, and every mutation accepts idempotency,
545
+ timeout, cancellation, and API-version request options.
546
+
547
+ The source exposes no pagination for group or participant lists. Browser client
548
+ tokens cannot access Groups routes because no Groups action exists in the
549
+ client-token allowlist; use a server API key with the required scope.
550
+
551
+ ## Messages
552
+
553
+ `MessagingClient.messages` maps the complete five-operation Messages tag:
554
+ `send`, `markSeen`, `setTyping`, `react`, and `star`. All five require
555
+ `messages:write` when called with a server API key and accept the standard
556
+ `RequestOptions`, including idempotency, cancellation, timeouts, custom
557
+ headers, and API-version overrides.
558
+
559
+ The source has one send route rather than separate routes for each message
560
+ kind. `SendMessageRequest` is therefore a union of the exact typed payloads for
561
+ text, image/file/voice/video media, polls, locations, contacts, phone-number
562
+ requests, products, product lists, orders, lists, buttons, address messages,
563
+ and flows. Template sends use `SendTemplateMessageRequest`. Select exactly one
564
+ message kind inside `content`; `conversation` selects its destination.
565
+
566
+ ```ts
567
+ await messaging.messages.send(
568
+ "support",
569
+ {
570
+ conversation: { phoneNumber: "+15551234567" },
571
+ content: {
572
+ buttons: {
573
+ body: "Continue with this request?",
574
+ buttons: [
575
+ { type: "reply", text: "Continue", id: "continue" },
576
+ { type: "reply", text: "Cancel", id: "cancel" },
577
+ ],
578
+ },
579
+ },
580
+ quotedMessage: {
581
+ id: "739182640518204",
582
+ },
583
+ },
584
+ { idempotencyKey: "reply-to-message-id" },
585
+ );
586
+ ```
587
+
588
+ Reply context uses `quotedMessage`; forwarding is represented by
589
+ `isForwarded`. Neither is a separate endpoint. The pinned contract exposes no
590
+ message history, list, search, or standalone forward/reply route.
591
+
592
+ Client tokens can call all five Messages operations only when the corresponding
593
+ live rule is enabled: `send_message` for send and star, `send_reaction` for
594
+ react, `send_typing` for typing, and `send_seen` for seen markers. Send and
595
+ reaction are also subject to recipient rules and send limits. Edit and delete
596
+ are not Messages routes: they remain `MessagingClient.chats.editMessage` and
597
+ `deleteMessage`, require `chats:manage` with a server key, and are not in the
598
+ client-token allowlist.
599
+
600
+ ### Idempotent sends
601
+
602
+ These methods send an `Idempotency-Key` on every call:
603
+
604
+ - `messages.send` and `messages.react`
605
+ - `chats.editMessage` and `chats.deleteMessage`
606
+ - `channels.reactToMessage`
607
+ - `campaigns.create` and `campaigns.launch`
608
+
609
+ If you don't pass `idempotencyKey`, the SDK generates a random UUID for the
610
+ call. Every automatic retry of that call reuses the key, so the API never
611
+ runs the write twice. If the first attempt succeeded but its response was lost,
612
+ the retry fails with `PolymorfaConflictError` (`idempotency_completed`) instead
613
+ of sending the message again. Pass your own key, such as
614
+ an order event ID, to deduplicate across processes or restarts:
615
+
616
+ ```ts
617
+ await messaging.messages.send(
618
+ "support",
619
+ {
620
+ conversation: { phoneNumber: "+15551234567" },
621
+ content: { text: "Shipped" },
622
+ },
623
+ { idempotencyKey: `order-${orderId}-shipped` },
624
+ );
625
+ ```
626
+
627
+ The API keeps each key for 24 hours per credential. Reusing a key for a
628
+ different request fails with `PolymorfaConflictError` (`idempotency_conflict`).
629
+ A retry that arrives while the first request is still running receives
630
+ `idempotency_in_progress`, and the SDK retries it after `Retry-After`. When a
631
+ response carries `Idempotent-Replayed: true`, the SDK treats it as final and
632
+ does not retry. A replayed `result_unknown`, or `idempotency_outcome_unknown`,
633
+ means the first attempt's outcome is unknown, so check message events before
634
+ you send again with a new key.
635
+
636
+ `BrowserMessagingClient.messages.send` and `react` generate keys the same way.
637
+
638
+ ## Messaging media
639
+
640
+ `MessagingClient.media` is distinct from `Client.media`. It covers the
641
+ Messaging Media tag for Linked Device sessions. Every download method requires
642
+ a server credential with `media:read`; client tokens cannot call media routes.
643
+
644
+ | Method | Result |
645
+ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
646
+ | `downloadStream(mediaId, options)` | `{ body: ReadableStream<Uint8Array>, contentType?, contentLength?, filename?, requestId?, redirected, metadata }` |
647
+ | `downloadBlob(mediaId, options)` | `{ blob, filename?, requestId? }`, with `blob.type` set from `Content-Type` |
648
+ | `downloadUrl(mediaId, options)` | `{ streamed: false, url, expiresAt? }` or `{ streamed: true, url: undefined }` |
649
+ | `download(mediaId, options)` | `ApiResponse<ArrayBuffer>` (buffers the whole file) |
650
+ | `retrieve(mediaId)` | `MessagingMediaInfo` |
651
+ | `persist(mediaId)` | Saves the object to the tenant's object storage; requires `media:manage` |
652
+
653
+ The API either streams the file or answers `302` with a fresh signed storage
654
+ URL. `downloadStream`, `downloadBlob` and `downloadUrl` send requests with
655
+ `redirect: "manual"`. When the SDK follows a redirect, it requests the storage
656
+ URL without the `Authorization` header, custom headers or cookies.
657
+
658
+ ```ts
659
+ import { Readable } from "node:stream";
660
+
661
+ const download = await messaging.media.downloadStream("media-id", {
662
+ signal: request.signal,
663
+ });
664
+ console.log(download.contentType, download.filename, download.requestId);
665
+ Readable.fromWeb(download.body).pipe(response);
666
+ ```
667
+
668
+ - `downloadStream` retries only before it returns the body. After that, a
669
+ failed read errors the stream with `PolymorfaConnectionError`, or with
670
+ `PolymorfaCancelledError` if the caller aborted it. `timeoutMs` applies until
671
+ response headers arrive. `signal` also cancels a body that is still being
672
+ read.
673
+ - `filename` comes from `Content-Disposition`. The SDK prefers the RFC 6266
674
+ `filename*` value and keeps only the last path segment. Treat it as a
675
+ display name, not as a path.
676
+ - `downloadUrl` does not follow the redirect. It returns the signed URL, so
677
+ your app can hand a browser a direct link instead of proxying the bytes.
678
+ The URL is short-lived and acts as a bearer credential: never log or store
679
+ it, and give it only to a user you have already authorized for this media.
680
+ `expiresAt` is derived from SigV4 or `Expires` query parameters when they
681
+ are present. When the API streams the file instead, `downloadUrl` cancels
682
+ the body and returns `{ streamed: true }`.
683
+ - API errors keep the existing error classes, such as
684
+ `PolymorfaNotFoundError` for 404 and `PolymorfaAuthorizationError` for 403.
685
+ A failed storage request raises `PolymorfaError` with code
686
+ `media_storage_error`. A redirect to anything other than HTTPS raises code
687
+ `invalid_redirect`.
688
+
689
+ `download()` buffers the response into an `ArrayBuffer` and keeps its existing
690
+ behavior. Timeout and cancellation stay active while the body is buffered.
691
+ `response.metadata.headers` keeps `Content-Type`. Credentialed clients send
692
+ `download()` requests with `redirect: "error"`, so `download()` fails when the
693
+ API answers with a storage redirect. Use `downloadStream` or `downloadBlob`
694
+ when media may be served from object storage.
695
+
696
+ ### Write media to a file (Node.js)
697
+
698
+ `@polymorfa/sdk/node` contains the Node-only helpers, which import `node:fs`
699
+ and `node:crypto`. The main entry does not import `node:fs`.
700
+
701
+ ```ts
702
+ import { downloadMediaToFile } from "@polymorfa/sdk/node";
703
+
704
+ await downloadMediaToFile(messaging.media, "media-id", "./attachment.bin", {
705
+ signal,
706
+ });
707
+ ```
708
+
709
+ The helper writes to a sibling temporary file (`.<name>.<uuid>.partial`,
710
+ mode `0600`) and renames it into place when the download finishes. If the
711
+ download fails or is aborted, the helper deletes the temporary file and leaves
712
+ any existing file at the destination unchanged. With `overwrite: false`, the
713
+ final step is an atomic link that fails with `PolymorfaConflictError` (code
714
+ `file_exists`) when the destination exists. `maxBytes` stops the download with
715
+ `media_too_large`, either from `Content-Length` before any bytes are read or
716
+ once too many bytes arrive. `writeStreamToFile(body, path, options)` applies
717
+ the same steps to any web stream.
718
+
719
+ ### Download media directly from WhatsApp
720
+
721
+ When a project does not persist media, image, video, audio, document and
722
+ sticker message webhooks include `media`. This field is a base64 protobuf of
723
+ the WhatsApp attachment, including its CDN URL, `directPath`, hashes and
724
+ `mediaKey`. The SDK can fetch the encrypted file directly from the WhatsApp
725
+ CDN and decrypt it locally. This makes no Polymorfa API call and sends no
726
+ Polymorfa credential.
727
+
728
+ ```ts
729
+ import { constructWebhookEvent, isEvent } from "@polymorfa/sdk";
730
+ import {
731
+ downloadWhatsAppMediaToFile,
732
+ nodeMediaCrypto,
733
+ } from "@polymorfa/sdk/node";
734
+
735
+ const event = await constructWebhookEvent(rawBody, signature, secret);
736
+ if (
737
+ isEvent(event, "message.received") &&
738
+ typeof event.payload.media === "string"
739
+ ) {
740
+ // Buffered, verified before any byte is released (default).
741
+ const media = await messaging.media.downloadFromWhatsApp(event.payload, {
742
+ maxBytes: 50 * 1024 * 1024,
743
+ });
744
+ const blob = await media.blob(); // typed with media.mimetype
745
+
746
+ // Streamed to disk; renamed into place only after verification.
747
+ await downloadWhatsAppMediaToFile(event.payload, "./incoming.bin");
748
+
749
+ // Streamed to a consumer that can discard partial output on error.
750
+ const live = await messaging.media.downloadFromWhatsApp(event.payload, {
751
+ crypto: nodeMediaCrypto,
752
+ verify: "streaming",
753
+ });
754
+ }
755
+ ```
756
+
757
+ `downloadWhatsAppMedia(input, options)` is the standalone form.
758
+ `decodeWhatsAppMedia(base64, messageType)` returns the decoded descriptor.
759
+ `deriveWhatsAppMediaKeys` and `decryptWhatsAppMedia(bytesOrStream, keys,
760
+ options)` cover apps that fetch the encrypted bytes themselves.
761
+
762
+ Verification follows the WhatsApp client order:
763
+
764
+ 1. HKDF-SHA256 expands `mediaKey` to 112 bytes with the per-type info string.
765
+ Stickers use the image string. The first 80 bytes give the IV, cipher key
766
+ and MAC key.
767
+ 2. The encrypted file is `ciphertext || mac10`. The SDK checks its SHA-256
768
+ against `fileEncSha256` when that hash is present.
769
+ 3. The SDK compares the HMAC-SHA256 of `iv || ciphertext`, truncated to 10
770
+ bytes, in constant time.
771
+ 4. The SDK decrypts with AES-256-CBC and strict PKCS#7 unpadding, then checks
772
+ the plaintext SHA-256 against `fileSha256`. A descriptor without
773
+ `fileSha256` is rejected.
774
+
775
+ A failure raises `PolymorfaMediaIntegrityError`. Its `code` is one of
776
+ `media_invalid_descriptor`, `media_too_short`, `media_too_large`,
777
+ `media_invalid_ciphertext`, `media_enc_hash_mismatch`, `media_mac_mismatch`,
778
+ `media_invalid_padding` or `media_hash_mismatch`.
779
+
780
+ - **Verification modes.** The default WebCrypto backend buffers the file and
781
+ releases plaintext only after every check passes. `nodeMediaCrypto`
782
+ decrypts incrementally and supports `verify: "streaming"`, which emits
783
+ plaintext before the MAC is verified. If verification then fails, the
784
+ stream errors and consumers must discard everything they received. Asking
785
+ for `streaming` without an incremental backend raises
786
+ `PolymorfaConfigurationError`.
787
+ - **Limits.** `maxBytes` defaults to 256 MiB and applies to the plaintext.
788
+ The SDK also caps the encrypted size at the padded `fileLength` from the
789
+ descriptor. It rejects an oversized `Content-Length` before reading the
790
+ body. The descriptor input is limited to 1 MiB of base64. The decoder
791
+ interprets only varint and length-delimited fields, and rejects wrong key
792
+ or hash lengths.
793
+ - **Hosts.** The SDK tries the descriptor `url` first. It then tries
794
+ `https://mmg.whatsapp.net` with `directPath` and the `hash`, `mms-type` and
795
+ `__wa-mms` parameters. Every URL, including redirect targets, must be HTTPS
796
+ on `*.whatsapp.net` with the default port.
797
+ - **Browsers.** `mmg.whatsapp.net` returned `access-control-allow-origin: *`
798
+ to an unauthenticated probe on 2026-09-17. This was checked only on error
799
+ responses, not on a real object. Even if a browser can fetch the file,
800
+ decrypting there means giving the browser the `mediaKey`. Keep the
801
+ descriptor on the server and use the `whatsapp` mode of
802
+ `createMediaDownloadRoute` from `@polymorfa/nextjs`.
803
+ - **Privacy.** `media` contains a decryption key. Treat stored webhook
804
+ payloads as secrets, never log them, and delete them when your retention
805
+ period ends. The CDN URL expires, and once WhatsApp removes the object the
806
+ file can no longer be downloaded. Messages with `mediaUrl` (persisted media)
807
+ have no `media` field, so use the Media API for them.
808
+
809
+ The pinned source specifies no maximum download size. Retrieve metadata first
810
+ when an application must enforce its own memory limit. It exposes no Messaging
811
+ media upload, deletion, resumable upload, range-download, or list endpoint.
812
+ Message-send `url` and `base64` fields are send inputs, not media-upload APIs.
813
+ Media routes are absent from the client-token allowlist, so every API method
814
+ requires a server API key. `persist` accepts an idempotency key through the
815
+ standard `RequestOptions`.
816
+
817
+ `MessagingMediaInfo.s3Url` includes `null` because the API returns a null value
818
+ before persistence even though the generated schema marks the field as
819
+ optional. The SDK type reflects the verified response.
820
+
821
+ ## Labels and observation policies
822
+
823
+ `MessagingClient.labels` covers all six direct Linked Device label operations.
824
+ Reads require `labels:read`; create, update, delete, and chat-label replacement
825
+ require `labels:manage`.
826
+
827
+ ```ts
828
+ const labels = await messaging.labels.list("support", {
829
+ includeObservation: true,
830
+ });
831
+
832
+ await messaging.labels.replaceForChat(
833
+ "support",
834
+ "15551234567@s.whatsapp.net",
835
+ { labels: ["priority", "customer"] },
836
+ { idempotencyKey: "replace-customer-labels" },
837
+ );
838
+
839
+ console.log(labels.data.data, labels.metadata.requestId);
840
+ ```
841
+
842
+ `replaceForChat` maps the source `setChatLabels` operation and replaces the
843
+ complete label set. Passing an empty array detaches every label. The source has
844
+ no incremental attach/detach route and no message-label route. Label reads are
845
+ not paginated; `includeObservation` selects either the legacy label array or
846
+ the typed observation envelope.
847
+
848
+ The Labels tag also includes the cross-cutting policy routes exposed as
849
+ `MessagingClient.observationPolicies`. Project methods require
850
+ `presence:read` or `presence:observe`; setting `labelMode` additionally requires
851
+ `labels:manage`. Session methods carry the same scopes and are Linked Device
852
+ only. Policy updates replace the supplied presence and typing modes while the
853
+ label mode remains optional in the pinned request schema.
854
+
855
+ Neither labels nor observation policies appears in the client-token action
856
+ allowlist. Use a server API key; the API rejects browser client tokens before
857
+ route handling.
858
+
859
+ ## Business App quick replies
860
+
861
+ `MessagingClient.quickReplies` exposes the complete four-operation quick-reply
862
+ subfamily in the Business App contract. Listing requires `profile:read`;
863
+ create, full replacement, and delete require `profile:write`.
864
+
865
+ ```ts
866
+ const remembered = await messaging.quickReplies.list("support");
867
+
868
+ const created = await messaging.quickReplies.create(
869
+ "support",
870
+ {
871
+ shortcut: "hours",
872
+ message: "We are open from 09:00 to 18:00.",
873
+ keywords: ["open", "hours"],
874
+ },
875
+ { idempotencyKey: "create-hours-quick-reply" },
876
+ );
877
+
878
+ await messaging.quickReplies.replace(
879
+ "support",
880
+ created.data.data.id,
881
+ {
882
+ shortcut: "openinghours",
883
+ message: "We are open weekdays from 09:00 to 18:00.",
884
+ keywords: ["open", "hours", "weekday"],
885
+ count: 0,
886
+ },
887
+ { idempotencyKey: "replace-hours-quick-reply" },
888
+ );
889
+
890
+ console.log(remembered.data.data.status, remembered.metadata.requestId);
891
+ ```
892
+
893
+ The list response is a bounded observation collection containing policy,
894
+ freshness status, and associated label IDs. It is not paginated. The update
895
+ route replaces the complete quick reply, so the SDK names it `replace` instead
896
+ of implying a partial update. The source exposes no retrieve-by-ID, send, or
897
+ manual sync operation.
898
+
899
+ The pinned public observation-policy request schemas do not include
900
+ `quickReplyMode`, although policy responses contain that field. Quick-reply
901
+ CRUD therefore does not alter observation policy, and the SDK does not add an
902
+ undocumented policy update field.
903
+
904
+ Quick-reply routes are absent from the browser client-token action allowlist.
905
+ Use a server API key with the required profile scope.
906
+
907
+ ## Session profile
908
+
909
+ `MessagingClient.profile` exposes the complete five-operation Profile tag for
910
+ Linked Device sessions. `get` requires `profile:read`; `setName`, `setStatus`,
911
+ `setPicture`, and `deletePicture` require `profile:write`.
912
+
913
+ ```ts
914
+ const profile = await messaging.profile.get("support");
915
+
916
+ await messaging.profile.setName(
917
+ "support",
918
+ { name: "Polymorfa Support" },
919
+ { idempotencyKey: "profile-name-2026-08-19" },
920
+ );
921
+
922
+ await messaging.profile.setPicture(
923
+ "support",
924
+ { url: "https://cdn.example.com/support-profile.jpg" },
925
+ { idempotencyKey: "profile-picture-2026-08-19" },
926
+ );
927
+
928
+ console.log(profile.data.data, profile.metadata.requestId);
929
+ ```
930
+
931
+ Picture input is JSON containing optional `url` and `base64` string fields. It
932
+ is not a binary upload or streaming method. The pinned public schema does not
933
+ declare those fields mutually exclusive and does not publish a size limit. The
934
+ pinned runner prefers non-empty base64 when both fields are supplied, rejects a
935
+ payload with neither source, and internally limits fetched or decoded data to
936
+ 50 MiB. Use one source per request for unambiguous behavior.
937
+
938
+ Profile routes are absent from the browser client-token action allowlist. Use a
939
+ server API key with the required profile scope. The Profile tag has no profile
940
+ history, picture download, or standalone upload operation.
941
+
942
+ ## Privacy
943
+
944
+ `MessagingClient.privacy` exposes the complete three-operation Privacy tag for
945
+ Linked Device sessions. `get` requires `profile:read`; `set` and
946
+ `setDefaultDisappearingTimer` require `profile:write`.
947
+
948
+ ```ts
949
+ const privacy = await messaging.privacy.get("support");
950
+
951
+ await messaging.privacy.set(
952
+ "support",
953
+ { setting: "online", value: "match_last_seen" },
954
+ { idempotencyKey: "privacy-online-2026-08-19" },
955
+ );
956
+
957
+ await messaging.privacy.setDefaultDisappearingTimer(
958
+ "support",
959
+ { durationSeconds: 604800 },
960
+ { idempotencyKey: "privacy-default-timer-2026-08-19" },
961
+ );
962
+
963
+ console.log(privacy.data.data, privacy.metadata.requestId);
964
+ ```
965
+
966
+ `PrivacySettingMutation` is discriminated by `setting`; incompatible values
967
+ fail type checking. `PRIVACY_SETTING_VALUES` exposes the same matrix at runtime
968
+ for command parsers and validation:
969
+
970
+ | Setting | Accepted values |
971
+ | --------------------------------------- | ---------------------------------------------- |
972
+ | `groupadd`, `last`, `status`, `profile` | `all`, `contacts`, `contact_blacklist`, `none` |
973
+ | `readreceipts` | `all`, `none` |
974
+ | `online` | `all`, `match_last_seen` |
975
+ | `calladd` | `all`, `known` |
976
+ | `messages` | `all`, `contacts` |
977
+ | `defense` | `on_standard`, `off` |
978
+ | `stickers` | `contacts`, `contact_allowlist`, `none` |
979
+
980
+ Default disappearing-message durations are seconds: `0` disables the account
981
+ default, `86400` is one day, `604800` is seven days, and `7776000` is 90 days.
982
+ This account default does not replace the per-chat timer exposed by
983
+ `MessagingClient.chats.setDisappearingTimer`.
984
+
985
+ Privacy routes are absent from the browser client-token action allowlist. Use a
986
+ server API key with the required profile scope. The Privacy tag has no privacy
987
+ history, allowlist/blacklist member-management, or pagination operation.
988
+
989
+ ## Presence
990
+
991
+ `MessagingClient.presence` exposes the complete four-operation Presence tag
992
+ for Linked Device sessions. `get` and `getForChat` require `presence:read`,
993
+ `set` requires `presence:write`, and `subscribe` requires `presence:observe`.
994
+
995
+ ```ts
996
+ const self = await messaging.presence.get("support");
997
+
998
+ await messaging.presence.set(
999
+ "support",
1000
+ { presence: "available" },
1001
+ { idempotencyKey: "presence-self-2026-08-20" },
1002
+ );
1003
+
1004
+ const subscription = await messaging.presence.subscribe(
1005
+ "support",
1006
+ "15551234567@s.whatsapp.net",
1007
+ { idempotencyKey: "presence-subscription-2026-08-20" },
1008
+ );
1009
+ const observed = await messaging.presence.getForChat(
1010
+ "support",
1011
+ "15551234567@s.whatsapp.net",
1012
+ );
1013
+
1014
+ console.log(
1015
+ self.data.data,
1016
+ observed.data.data,
1017
+ subscription.metadata.requestId,
1018
+ );
1019
+ ```
1020
+
1021
+ `PRESENCE_STATES`, `PRESENCE_OBSERVATION_STATUSES`,
1022
+ `PRESENCE_UNKNOWN_REASONS`, and `PRESENCE_CHAT_STATES` are root runtime
1023
+ exports for input validation and response narrowing. Self presence is the
1024
+ runner's remembered desired and last successfully sent value. Its
1025
+ `authoritative` field is always `false`; `get` does not query remote account
1026
+ state.
1027
+
1028
+ Chat presence is also not a live query. `getForChat` reads the bounded
1029
+ observation projection controlled by `MessagingClient.observationPolicies`.
1030
+ `off` and `events` modes can return an unknown state without retained presence;
1031
+ `cache` mode returns `fresh` or `stale` cached observations. Typing observation
1032
+ is reported alongside presence but remains distinct from
1033
+ `MessagingClient.messages.setTyping`.
1034
+
1035
+ The pinned runtime makes each successful subscription or renewal valid for 120
1036
+ seconds and returns the exact `expiresAt`; consumers must use that timestamp
1037
+ rather than assuming a fixed lifetime. Subscriptions accept user and LID
1038
+ identifiers, while chat reads also accept groups. Observation limits can return
1039
+ 429 and place presence subscriptions in a temporary suspension window. The
1040
+ source exposes no stream, watch, history, polling helper, or unsubscribe route.
1041
+
1042
+ Browser client tokens can call `get` and `getForChat` with the
1043
+ `read_presence` action and `subscribe` with `subscribe_presence`. They cannot
1044
+ call `set`; that route requires a server API key. Client-token rules also bind
1045
+ the request to the token's session. The server SDK accepts either credential
1046
+ kind and leaves the live action check to the API.
1047
+
1048
+ The pinned OpenAPI describes the synchronous `set` result as a generic
1049
+ `SuccessResponse`, while the pinned live RPC handler returns
1050
+ `{ success: true, data: { status: "OK" } }`. `SetPresenceResponse` represents
1051
+ both shapes, plus the documented async-accepted envelope. The subscription
1052
+ type likewise includes its documented async response when callers explicitly
1053
+ send `Prefer: respond-async`.
1054
+
1055
+ ## Business App
1056
+
1057
+ `MessagingClient.business` exposes the 25 credential-compatible Business App
1058
+ operations outside the separately maintained `quickReplies` resource. Every
1059
+ operation requires a connected Linked Device session. Cloud API sessions,
1060
+ project credentials, browser client tokens, dashboard sessions, and staff
1061
+ credentials cannot use this resource.
1062
+
1063
+ ```ts
1064
+ const catalog = await messaging.business.getCatalog("sales", {
1065
+ jid: "15551234567@s.whatsapp.net",
1066
+ limit: 25,
1067
+ });
1068
+
1069
+ const product = await messaging.business.createProduct(
1070
+ "sales",
1071
+ {
1072
+ name: "Mint tea",
1073
+ currency: "USD",
1074
+ price: "12000",
1075
+ images: [{ url: "https://cdn.example.com/tea.jpg" }],
1076
+ },
1077
+ { idempotencyKey: crypto.randomUUID() },
1078
+ );
1079
+
1080
+ console.log(
1081
+ catalog.data.data.products,
1082
+ catalog.data.data.next,
1083
+ product.metadata.requestId,
1084
+ );
1085
+ ```
1086
+
1087
+ The exact server scopes are:
1088
+
1089
+ | Scope | Operations |
1090
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
1091
+ | `profile:read` | `getProfile`, `getMerchantCompliance` |
1092
+ | `profile:write` | `updateProfile`, `setCoverPhoto`, `deleteCoverPhoto`, `setMerchantCompliance`, catalog creation/cart mutation, and every product or collection mutation |
1093
+ | `business:read` | `getCatalog`, `getProduct`, `listCollections`, `getCollection`, `getOrder`, `getLinkedAccounts`, `getEligibility` |
1094
+
1095
+ All 17 non-GET operations accept `RequestOptions`, including idempotency keys.
1096
+ They also represent the live `{ success: true, data: { requestId } }` result
1097
+ returned when a caller explicitly sends `Prefer: respond-async`. This includes
1098
+ `getOrder`, which is a token-bearing POST lookup despite its read semantics.
1099
+ GET operations remain synchronous.
1100
+
1101
+ ### Profile and account state
1102
+
1103
+ The profile surface is `getProfile`, `updateProfile`, `setCoverPhoto`, and
1104
+ `deleteCoverPhoto`. Profile updates accept address, email, description, one or
1105
+ two HTTP(S) websites, or business hours. `specific_hours` days require
1106
+ distinct minute-of-day `openTime` and `closeTime` values; `open_24h` and
1107
+ `appointment_only` reject those fields. A profile update must contain at least
1108
+ one field, and a week cannot contain duplicate days.
1109
+
1110
+ `business.getProfile` always addresses the connected account's own Business
1111
+ App profile. It reuses the public `BusinessProfile` response type but remains
1112
+ distinct from `contacts.businessProfile`, which reads another contact's
1113
+ profile by identifier.
1114
+
1115
+ Cover photos use one JSON source: an HTTP(S) URL fetched by the API or base64
1116
+ data. There is no multipart, binary, file, streaming, resumable, or separate
1117
+ upload operation. The API permits at most 5 MiB after decoding and 6,990,508
1118
+ base64 characters. Remote downloads are capped at 5 MiB, require a public
1119
+ host, reject URL credentials and private/reserved addresses, revalidate each
1120
+ redirect, and time out after 60 seconds. The SDK's union prevents supplying a
1121
+ URL and base64 together.
1122
+
1123
+ `getMerchantCompliance` and `setMerchantCompliance` read or completely replace
1124
+ the merchant entity, customer-care, and grievance-officer fields. The server
1125
+ trims strings and enforces the documented UTF-8 byte limits. `getLinkedAccounts`
1126
+ returns optional Facebook Page, Facebook Business, Instagram Professional, and
1127
+ WhatsApp ad-identity records. `getEligibility` returns at most the six exact
1128
+ feature kinds declared by `BusinessFeature` with live upstream status strings.
1129
+ Neither read offers history or pagination.
1130
+
1131
+ ### Catalogs, products, collections, and orders
1132
+
1133
+ `getCatalog` requires a public business-owner ID. It accepts an opaque `after`
1134
+ cursor, `limit` from 1 through 100, and optional image dimensions from 1 through 1024. Its response contains `products`, optional `next`, and optional
1135
+ `previous`. `listCollections` uses the same owner ID and cursor model with
1136
+ `collectionLimit` from 1 through 20 and `itemLimit` from 1 through 100; its
1137
+ response contains `collections` and optional `next`. These are explicit cursor
1138
+ fields, not offset pages, and the SDK does not synthesize `hasMore`.
1139
+
1140
+ `getCollection` accepts `after` and a product `limit`, but the pinned response
1141
+ contains only the collection and products—no next cursor. The SDK preserves
1142
+ that source limitation rather than claiming automatic pagination. Product,
1143
+ collection, business-owner, order, cover-photo, and session identifiers are
1144
+ encoded by the SDK. Cursors and order tokens remain query/body values rather
1145
+ than path data.
1146
+
1147
+ Product creation and replacement require one through ten image sources. Each
1148
+ image is exactly one of:
1149
+
1150
+ - `url`: an arbitrary public HTTPS image fetched through the bounded,
1151
+ SSRF-safe downloader, with a 16 MiB response cap;
1152
+ - `base64`: JSON base64 capped at 16 MiB decoded and 22,369,624 encoded
1153
+ characters; or
1154
+ - `mediaUrl`: an existing HTTPS URL on a WhatsApp or Meta host, reused without
1155
+ downloading.
1156
+
1157
+ `videoUrls` contain at most ten existing WhatsApp or Meta HTTPS URLs. The
1158
+ resource does not invent file-path, byte-array, multipart, streaming,
1159
+ resumable-upload, or media-upload methods. Product prices are unsigned integer
1160
+ amounts in thousandths with up to 18 digits. Currency is a three-letter
1161
+ uppercase code and is required with `price`; `currency` and `salePrice` are
1162
+ invalid without `price`. Omitting `hidden` produces `false` in the pinned
1163
+ runner. During replacement, that can trigger the separate visibility mutation
1164
+ and unhide an existing product, so callers preserving a hidden product must
1165
+ send `hidden: true` explicitly.
1166
+
1167
+ Collection creation requires one through 100 unique product IDs. Updates must
1168
+ change the name or include at least one product addition/removal; each list is
1169
+ unique and an ID cannot occur in both. Reordering requires one through 100
1170
+ unique collection moves with indices from 0 through 99. Product and collection
1171
+ appeal reasons are trimmed, nonempty, and capped at 4,096 UTF-8 bytes. The
1172
+ generated OpenAPI exposes a 4,096-character maximum, so multibyte text can pass
1173
+ schema validation and still fail the byte check; the SDK leaves that API error
1174
+ visible as a typed validation error.
1175
+
1176
+ `getOrder` requires the exact order ID and opaque lookup token supplied by the
1177
+ Business App event. It is not an order list, search, history, checkout, or
1178
+ fulfilment API. The source likewise exposes no catalog listing independent of
1179
+ a business-owner ID, no product search, no collection search, and no upload
1180
+ progress.
1181
+
1182
+ ## Channels
1183
+
1184
+ `history.sync` payloads distinguish linked-device indexes and their preserved
1185
+ WhatsApp archive from Cloud history's `{ kind: "history", value }` envelope.
1186
+ Narrow `HistorySyncPayload` with `"kind" in payload` before reading provider-specific
1187
+ fields; `LinkedHistorySyncPayload` and `CloudHistorySyncPayload` are exported.
1188
+
1189
+ `MessagingClient.channels` exposes the complete 13-operation Channels tag for
1190
+ connected Linked Device sessions. Channels are WhatsApp newsletters in the
1191
+ protocol layer, but the SDK keeps the public API's `channels` terminology and
1192
+ does not merge them with chats or groups.
1193
+
1194
+ ```ts
1195
+ const channels = await messaging.channels.list("support");
1196
+ const channel = await messaging.channels.retrieve(
1197
+ "support",
1198
+ "120363000000000000@newsletter",
1199
+ );
1200
+ const messages = await messaging.channels.listMessages(
1201
+ "support",
1202
+ "120363000000000000@newsletter",
1203
+ { count: 25, before: 951 },
1204
+ );
1205
+ const updates = await messaging.channels.listMessageUpdates(
1206
+ "support",
1207
+ "120363000000000000@newsletter",
1208
+ { count: 25, since: 1787212800, after: 951 },
1209
+ );
1210
+
1211
+ console.log(
1212
+ channels.data.data,
1213
+ channel.data.data,
1214
+ messages.data.data,
1215
+ updates.data.data,
1216
+ );
1217
+ ```
1218
+
1219
+ The read surface is `list`, `retrieve`, `listMessages`,
1220
+ `listMessageUpdates`, and `subscribeToLiveUpdates`; each requires
1221
+ `channels:read`. `create`, `delete`, `markMessageViewed`, `reactToMessage`,
1222
+ `follow`, `unfollow`, `mute`, and `unmute` require `channels:manage`. All
1223
+ mutations accept `RequestOptions`, including an idempotency key.
1224
+
1225
+ Message history and update history are bounded arrays, not `CursorPage`
1226
+ objects. Both accept `count` from 1 through 100 and default to 50. Message
1227
+ history accepts a positive numeric message server ID as `before`. Update
1228
+ history accepts a non-negative Unix timestamp in seconds as `since` and a
1229
+ positive numeric message server ID as `after`; the pinned runner treats
1230
+ `since: 0` as unset. Responses do not include `next`, `hasMore`, or a cursor,
1231
+ so the SDK does not synthesize them. Update rows use the same `ChannelMessage`
1232
+ shape as message rows, but `text` can be absent because update payloads contain
1233
+ view and reaction counts without message content.
1234
+
1235
+ `subscribeToLiveUpdates` performs a temporary subscription mutation and
1236
+ returns the upstream `durationSeconds`. It does not return a stream, iterator,
1237
+ websocket, or listener. The source exposes no explicit unsubscribe operation;
1238
+ applications can query `listMessageUpdates` while their upstream subscription
1239
+ is active.
1240
+
1241
+ No channel route is present in the browser client-token action allowlist. Use a
1242
+ server API key with `channels:read` or `channels:manage`; client tokens are
1243
+ rejected before route execution. Every operation requires an active Linked
1244
+ Device connection.
1245
+
1246
+ The pinned source has two request/response discrepancies:
1247
+
1248
+ - `CreateChannelRequest` publicly declares optional `picture`, while the
1249
+ runner unmarshals a field named `profileUrl`. The current handler therefore
1250
+ creates the channel without applying the declared picture. The SDK exposes
1251
+ only the contract field and does not claim that picture setup succeeds.
1252
+ - Follow, unfollow, mute, unmute, viewed, and reaction routes declare generic
1253
+ `SuccessResponse` results. The live runner returns data envelopes with the
1254
+ statuses `FOLLOWED`, `UNFOLLOWED`, `MUTED`, `UNMUTED`, `VIEWED`, and
1255
+ `UPDATED`. The action response types represent both shapes, plus the live
1256
+ RPC async-accepted result available through `Prefer: respond-async` even
1257
+ though these routes omit 202 from the pinned OpenAPI.
1258
+
1259
+ The public reaction schema permits at most 32 characters. The runner performs
1260
+ a second check against 32 UTF-8 bytes, so a multibyte reaction can pass route
1261
+ validation and still receive a 400 response. Empty reaction text is preserved
1262
+ and removes the caller's reaction upstream. Channel, session, and public
1263
+ message identifiers are URL-encoded by the SDK.
1264
+
1265
+ ## Messaging campaigns
1266
+
1267
+ `MessagingClient.campaigns` exposes the complete nine-operation project-slug
1268
+ campaign workflow: `list`, `create`, `retrieve`, `analytics`, `launch`,
1269
+ `pause`, `resume`, `stop`, and `requeue`. Reads require `campaigns:read`;
1270
+ creation and lifecycle changes require `campaigns:manage`.
1271
+
1272
+ ```ts
1273
+ const created = await messaging.campaigns.create(
1274
+ "support",
1275
+ {
1276
+ name: "August launch",
1277
+ templateId: "order-ready",
1278
+ recipientListId: "active-customers",
1279
+ scheduledAt: Date.parse("2026-08-25T09:00:00Z"),
1280
+ },
1281
+ { idempotencyKey: "campaign-august-create" },
1282
+ );
1283
+
1284
+ const launched = await messaging.campaigns.launch(
1285
+ "support",
1286
+ created.data.data.id,
1287
+ {},
1288
+ { idempotencyKey: "campaign-august-launch" },
1289
+ );
1290
+
1291
+ console.log(launched.data.data.operationId, launched.metadata.requestId);
1292
+ ```
1293
+
1294
+ Launch, pause, resume, and stop append durable lifecycle commands and return the
1295
+ campaign's current persisted state plus an `operationId`. They do not wait for
1296
+ the campaign state to change. Read the campaign resource to inspect its status,
1297
+ or follow the returned operation with `Client.operations.wait(operationId)` and
1298
+ stop it with `Client.operations.cancel(operationId)`. The API exposes no
1299
+ campaign watcher or stream route of its own. Launch accepts an optional
1300
+ epoch-millisecond schedule. Pause requires a running campaign, resume requires
1301
+ a paused campaign, and stop accepts draft, running, or paused campaigns.
1302
+
1303
+ `requeue` is a direct transaction, not a durable operation. It moves failed
1304
+ recipients back to pending and can also include recipients skipped with an
1305
+ error. Its `{ requeued }` result is the number actually moved. Lists are
1306
+ complete newest-first arrays; the source exposes no cursor, page token, search,
1307
+ event history, replay, or delivery-listener endpoint.
1308
+
1309
+ This Messaging family is distinct from `Client.campaigns`, which maps
1310
+ the Management API's organization-key campaign model. The Messaging routes
1311
+ accept organization API keys and project tokens bound to the exact path
1312
+ project. Browser client tokens are not allowlisted for any campaign action and
1313
+ fail before the handler.
1314
+ Campaigns are project control-plane objects and have no Linked Device versus
1315
+ Cloud session-mode discriminator.
1316
+
1317
+ For organization-key calls, the live list and create handlers resolve the path
1318
+ project slug. The other seven handlers currently authorize the organization
1319
+ and campaign ID but do not verify that the campaign belongs to the supplied
1320
+ slug. Callers must still supply the intended project slug; the SDK encodes it
1321
+ and does not weaken this source behavior. The pinned OpenAPI campaign schema
1322
+ omits several JSON repository fields and leaves analytics untyped. The SDK
1323
+ exports the exact live analytics counters and preserves the extra campaign
1324
+ fields as optional `unknown` values rather than asserting undocumented shapes.
1325
+
1326
+ `create` and `launch` send an `Idempotency-Key` on every call, and the API
1327
+ records it for 24 hours, so a retry after an unseen success never creates a
1328
+ second campaign or launches twice (see [Idempotent sends](#idempotent-sends)).
1329
+ The other lifecycle commands are retried only when you pass an idempotency
1330
+ key, and the API does not persist that header for them. Repeating one can
1331
+ conflict with the resulting state or append another intent; repeating requeue
1332
+ normally reports zero after the matching recipients have already moved. The live API reports entitlement failures as `402` and invalid
1333
+ lifecycle state conflicts as `400`, rather than the more specific statuses
1334
+ suggested by their semantics.
1335
+
1336
+ The source exposes no Messaging campaign update, deletion, archive, duplicate,
1337
+ recipient listing, or campaign event inspection operation. The SDK does not
1338
+ substitute similarly named Management API routes or `raw.request` calls for
1339
+ those gaps.
1340
+
1341
+ ## Chats
1342
+
1343
+ `MessagingClient.chats` exposes the credential-compatible Linked Device chat
1344
+ management surface. Every operation requires `chats:manage`.
1345
+
1346
+ ```ts
1347
+ await messaging.chats.editMessage(
1348
+ "support",
1349
+ "15551234567@s.whatsapp.net",
1350
+ "message-id",
1351
+ { text: "Corrected copy" },
1352
+ { idempotencyKey: "edit-message-id" },
1353
+ );
1354
+
1355
+ await messaging.chats.setDisappearingTimer(
1356
+ "support",
1357
+ "15551234567@s.whatsapp.net",
1358
+ { durationSeconds: 604800 },
1359
+ );
1360
+ ```
1361
+
1362
+ The duration is typed to the four values accepted by the API: disabled, one
1363
+ day, one week, or 90 days. The resource also provides `deleteMessage`,
1364
+ `archive`, and `unarchive`. Chat operations are not available for Cloud API
1365
+ sessions.
1366
+
1367
+ ## Webhooks and events
1368
+
1369
+ `MessagingClient.webhooks` lists, creates, retrieves, updates, and deletes
1370
+ webhook registrations with a server credential carrying `webhooks:manage`.
1371
+ Webhook mutations accept the same `RequestOptions` as every other resource,
1372
+ including idempotency keys, cancellation, timeouts, and API-version overrides.
1373
+
1374
+ Use `webhooks.verify` with the exact raw request bytes before inspecting an
1375
+ inbound Messaging delivery. `isEvent` narrows known event names to their
1376
+ exported payload types:
1377
+
1378
+ ```ts
1379
+ const event = await webhooks.verify({
1380
+ body: rawBody,
1381
+ signature,
1382
+ secret: webhookSecret,
1383
+ });
1384
+
1385
+ if (isEvent(event, "history.sync")) {
1386
+ if ("kind" in event.payload) {
1387
+ console.log("Meta Cloud API history batch", event.payload.value);
1388
+ } else {
1389
+ console.log(event.payload.syncType, event.payload.progress);
1390
+ }
1391
+ } else if (isEvent(event, "contact.sync")) {
1392
+ console.log(event.payload.kind, event.payload.value);
1393
+ } else if (isEvent(event, "message.echo")) {
1394
+ console.log(event.payload.source, event.externalId);
1395
+ } else if (isEvent(event, "call.received")) {
1396
+ console.log(
1397
+ event.payload.callId,
1398
+ event.payload.from.id,
1399
+ event.payload.hasVideo,
1400
+ );
1401
+ } else if (isEvent(event, "call.accepted")) {
1402
+ // answeredBy and exclusive say who answered and whether they claimed it.
1403
+ console.log(event.payload.answeredBy, event.payload.exclusive === true);
1404
+ } else if (isEvent(event, "message.failed")) {
1405
+ if (event.payload.error === "blocked_by_safety") {
1406
+ console.log(event.payload.code, event.payload.retryAfter);
1407
+ }
1408
+ } else if (isEvent(event, "bansafe.action")) {
1409
+ console.log(event.payload.rung, event.payload.requires);
1410
+ } else if (isEvent(event, "customer.pairing_link.connected")) {
1411
+ console.log(event.payload.customerId, event.payload.sessionId);
1412
+ }
1413
+ ```
1414
+
1415
+ The catalog also types Customer lifecycle events (`customer.*`), BanSafe events
1416
+ (`bansafe.health_threshold`, `bansafe.enforcement`, `bansafe.action`,
1417
+ `bansafe.incident`, and `bansafe.claim`), campaign progress events
1418
+ (`campaign.*`), `message.failed`, and `template.status`. `message.failed`
1419
+ reports `blocked_by_safety` when BanSafe stops a send, with an optional `code`
1420
+ and `retryAfter` in seconds. Unknown event names still parse as
1421
+ `UnknownWebhookEvent`.
1422
+
1423
+ `contact.sync` delivers a Meta Cloud API contact batch as
1424
+ `{ kind: "contacts", value }`. `message.echo` reports a message sent from the
1425
+ WhatsApp Business app on a connected Meta Cloud API number as
1426
+ `{ source: "whatsapp_business_app", value }`. Events for a session created by a
1427
+ QuickLink include its optional `externalId`.
1428
+
1429
+ Development builds also export `CallEndedPayload` and `CallTelemetryPayload`.
1430
+ For `call.ended`, check `from` before reading its identity: it is `null` when
1431
+ the media host disappeared before reporting the caller. The reason is
1432
+ `pod_lost` for those recovered terminal events. Telemetry fields `recvKbps`
1433
+ and `sendKbps` contain cumulative kilobits, not rates.
1434
+
1435
+ `webhooks.verifySignature()` performs the same production signature check and
1436
+ returns a boolean without parsing. `webhooks.createFixture()` creates an exact
1437
+ JSON byte sequence and matching production signature for local tests.
1438
+ `webhooks.verifyLocal()` verifies the timestamped signature used by local CLI
1439
+ forwarding. These helpers are credential-free. The older
1440
+ `constructWebhookEvent` and `verifyWebhookSignature` exports remain available
1441
+ through the first stable major. A later major can remove them with a migration
1442
+ release.
1443
+
1444
+ The management `Client` owns a separate durable developer API at both
1445
+ organization and project scope:
1446
+
1447
+ - `events.list`, `retrieve`, and `replay`
1448
+ - `webhooks.list`, `create`, `retrieve`, `update`, `delete`, `test`, and
1449
+ `rotateSecret`
1450
+ - `webhookDeliveries.list`, `retrieve`, `listAttempts`, `retrieveAttempt`, and
1451
+ `retry`
1452
+ - `operations.list`, `get`, `listTransitions`, `cancel`, and `wait`
1453
+
1454
+ ```ts
1455
+ const deliveries = await project.webhookDeliveries.list({
1456
+ webhookId: "wh_123",
1457
+ limit: 25,
1458
+ });
1459
+
1460
+ const delivery = deliveries.items[0];
1461
+ if (delivery) {
1462
+ const attempts = await project.webhookDeliveries.listAttempts(delivery.id);
1463
+ if (attempts.items[0]) {
1464
+ const attempt = await project.webhookDeliveries.retrieveAttempt(
1465
+ delivery.id,
1466
+ attempts.items[0].id,
1467
+ );
1468
+ // Failed HTTP responses carry a redacted excerpt of at most 8192 UTF-8 bytes.
1469
+ console.log(attempt.data.response?.excerpt);
1470
+ }
1471
+ }
1472
+
1473
+ const replay = await project.events.replay(
1474
+ "evt_123",
1475
+ { webhookId: "wh_123" },
1476
+ { idempotencyKey: crypto.randomUUID() },
1477
+ );
1478
+
1479
+ console.log(replay.data.operationId);
1480
+ ```
1481
+
1482
+ List methods return `CursorPage<T>`. Mutations return owner-specific typed
1483
+ receipts and preserve response metadata, request IDs, and idempotency receipts.
1484
+ The SDK has no operation inspection or cancellation methods.
1485
+
1486
+ Console and staff routes remain absent from the server client and its raw
1487
+ guidance. The CLI listener protocol stays private to the CLI.
1488
+
1489
+ ### Stream events in real time
1490
+
1491
+ `events.stream()` follows a project's server-sent event stream. It needs a
1492
+ credential with `events:listen` and a team enrolled in the Event streams beta;
1493
+ without enrollment the iterator throws `PolymorfaAuthorizationError` with code
1494
+ `feature_unavailable`. Organization clients pass `projectId`.
1495
+
1496
+ ```ts
1497
+ const controller = new AbortController();
1498
+ const stream = client.project(projectId).events.stream({
1499
+ types: ["message.*", "session.connected"],
1500
+ since: savedCursor, // optional: resume after this cursor
1501
+ signal: controller.signal,
1502
+ onGap: (gap) => console.warn(`${gap.missedEvents} events expired`),
1503
+ });
1504
+
1505
+ for await (const item of stream) {
1506
+ if (item.webhook) handle(item.webhook); // the exact webhook body
1507
+ await saveCursor(item.cursor);
1508
+ }
1509
+ ```
1510
+
1511
+ Each item carries the event metadata (`item.event`, the same fields as
1512
+ `events.retrieve`), the decoded webhook body (`item.webhook`, or `null` when
1513
+ hosted message storage did not keep it), and its `cursor`. The iterator
1514
+ reconnects with exponential backoff and jitter after a dropped connection, an
1515
+ `expiry`, a missed heartbeat, `429`, `5xx`, or a recoverable gap, resuming from
1516
+ the last delivered cursor. It ends with an error on an invalid or expired
1517
+ cursor, an authentication or authorization failure, or a `revoked` stream.
1518
+ Aborting `signal` or leaving the loop ends it without an error.
1519
+
1520
+ Pass `ack: "manual"` to have the server wait for your processing, and confirm
1521
+ progress with
1522
+ `events.acknowledgeStream(item.streamId, { cursor: item.cursor, sequence: item.sequence })`.
1523
+
1524
+ `events.liveSource()` returns a `LiveEventSource` for `@polymorfa/store`:
1525
+
1526
+ ```ts
1527
+ connectEventSource(
1528
+ store,
1529
+ client.project(projectId).events.liveSource({ types: ["message.*"] }),
1530
+ );
1531
+ ```
1532
+
1533
+ It passes webhook bodies to the store with their cursors and skips events whose
1534
+ body was not kept. Use it on a server or trusted worker; server credentials must
1535
+ not reach a browser.
1536
+
1537
+ ## Platform automation
1538
+
1539
+ Organization API keys can use handwritten campaign, audience, opt-out, and
1540
+ media resources:
1541
+
1542
+ ```ts
1543
+ const campaign = await platform.campaigns.create(
1544
+ {
1545
+ projectId: "project_123",
1546
+ name: "August launch",
1547
+ },
1548
+ {
1549
+ idempotencyKey: "campaign-august-2026",
1550
+ timeoutMs: 10_000,
1551
+ },
1552
+ );
1553
+
1554
+ console.log(campaign.data.data, campaign.metadata.requestId);
1555
+ ```
1556
+
1557
+ The pinned contract defines these operation payloads as open objects, exposed
1558
+ as `PlatformPayload`. Templates and Flows are not methods on `Client`:
1559
+ their endpoints require a dashboard bearer and reject the organization API key
1560
+ used by the server client.
1561
+
1562
+ ## Billing and usage
1563
+
1564
+ `Client.billing` exposes the complete organization-key billing family.
1565
+ Reads require `sessions:read`. Credit quantities, including fields ending in
1566
+ `Cents`, support up to six decimal places. They are not cash minor units.
1567
+ Team warnings follow the fixed one-day and two-hour insufficiency forecast;
1568
+ notification preferences are managed in the Console.
1569
+
1570
+ ```ts
1571
+ const [balance, usage, transactions, pricing] = await Promise.all([
1572
+ platform.billing.retrieve(),
1573
+ platform.billing.usage(),
1574
+ platform.billing.listTransactions(),
1575
+ platform.billing.listPricing(),
1576
+ ]);
1577
+
1578
+ console.log({
1579
+ balance: balance.data.data,
1580
+ usage: usage.data.data,
1581
+ transactions: transactions.data.data,
1582
+ pricing: pricing.data.data,
1583
+ requestId: usage.metadata.requestId,
1584
+ });
1585
+ ```
1586
+
1587
+ ### Change a number tier
1588
+
1589
+ Create a quote, show its credit charge and effective time, then confirm its ID
1590
+ only after the customer accepts. Upgrades buy a fresh 24-hour window and replace
1591
+ the remaining paid time. Downgrades apply when the paid window ends.
1592
+
1593
+ ```ts
1594
+ const reviewed = await platform.sessions.quoteTierChange(sessionId, {
1595
+ tierOverride: "pro",
1596
+ });
1597
+ const quote = reviewed.data.data;
1598
+ console.log(quote.quote.amountCents, quote.quote.effectiveAtMs);
1599
+
1600
+ // After the customer confirms this exact quote:
1601
+ await platform.sessions.setTierOverride(sessionId, { quoteId: quote.id });
1602
+ const result = await platform.sessions.retrieveTierChange(sessionId, quote.id);
1603
+ console.log(result.data.data.status);
1604
+ ```
1605
+
1606
+ A queued result has not granted the tier. Poll until it is applied or rejected.
1607
+ A quote expires after ten minutes and can become invalid if the number or price
1608
+ changes. Show a new quote for confirmation after a conflict; never silently
1609
+ purchase a replacement. Set `tierOverride: null` when quoting to restore project
1610
+ inheritance. The old `setTierOverride({tierOverride})` request and
1611
+ `billing.updateReminderSettings` method are removed.
1612
+
1613
+ ## Organization access and security
1614
+
1615
+ The organization view exposes key metadata, members, audit logs, session bans,
1616
+ security incidents, and project-token metadata:
1617
+
1618
+ ```ts
1619
+ const [keys, members, audit, bans, incidents, tokens] = await Promise.all([
1620
+ platform.apiKeys.list(),
1621
+ platform.members.list(),
1622
+ platform.auditLogs.list({
1623
+ action: "session.stop",
1624
+ resource: "session",
1625
+ limit: 100,
1626
+ }),
1627
+ platform.sessionBans.listActive(),
1628
+ platform.securityIncidents.list(),
1629
+ platform.projectTokens.list("018f0000-0000-7000-8000-000000000002"),
1630
+ ]);
1631
+
1632
+ await platform.securityIncidents.acknowledge(incidents.data.data[0]!.id, {
1633
+ idempotencyKey: "acknowledge-incident-1",
1634
+ });
1635
+ await platform.apiKeys.deactivate(keys.data.data[0]!.keyId, {
1636
+ idempotencyKey: "deactivate-key-1",
1637
+ });
1638
+ ```
1639
+
1640
+ These read operations require `sessions:read`. API-key deactivation and incident
1641
+ acknowledgement require `sessions:manage`. The two mutations are direct
1642
+ organization-scoped writes rather than asynchronous operations. The SDK retries
1643
+ them only when an idempotency key is supplied, but the pinned handlers do not
1644
+ persist that header. Incident acknowledgement is repeatable; an API-key
1645
+ deactivation retry after an unseen successful response can return `404` because
1646
+ the key is already inactive.
1647
+
1648
+ These list responses are complete arrays. The source exposes no cursor or
1649
+ page token. The live audit handler accepts exact `action` and `resource`
1650
+ filters plus a limit bounded to 1 through 500, although those query fields are
1651
+ missing from the pinned OpenAPI operation. Project-token metadata requires an
1652
+ explicit project ID for organization-key calls even though OpenAPI marks the
1653
+ query field optional. Neither token-list operation returns bearer secrets.
1654
+
1655
+ The API-key list handler currently reports the all-scopes mask for each row
1656
+ instead of the stored row-specific mask. The SDK preserves that numeric wire
1657
+ field without interpreting it as proof of the caller's live authorization.
1658
+ Incident acknowledgement records an empty acting-user value for API-key calls;
1659
+ the subsequent incident list can therefore expose an empty `acknowledgedBy`
1660
+ string rather than a dashboard user ID.
1661
+
1662
+ Organization updates, member role changes, member deletion, invitations,
1663
+ billing top-ups, and console usage insights require a dashboard session and
1664
+ are not exposed by the server SDK. Browser client tokens are rejected by the
1665
+ Management API. Project tokens are accepted only by a project-scoped `Client`;
1666
+ organization-only resources are absent from that view's public type.
1667
+
1668
+ ## QuickLink lifecycle and settings
1669
+
1670
+ `MessagingClient.quickLinks` owns the authenticated hosted pairing lifecycle:
1671
+
1672
+ ```ts
1673
+ const quickLink = await messaging.quickLinks.create(
1674
+ {
1675
+ projectId: "11111111-2222-4333-8444-555555555555",
1676
+ externalId: "crm-account-42",
1677
+ configuration: { methods: ["qr", "pairing"] },
1678
+ },
1679
+ { idempotencyKey: crypto.randomUUID() },
1680
+ );
1681
+
1682
+ const status = await messaging.quickLinks.retrieve(quickLink.data.data.id);
1683
+ console.log(quickLink.data.data.url, status.data.data.status);
1684
+ ```
1685
+
1686
+ The resource accepts organization API keys or project tokens with
1687
+ `quicklink:manage`. It rejects browser client tokens before transport. An
1688
+ organization key can select `projectId` when creating a link; a project token
1689
+ is bound by the server. `cancel()` invalidates a pending link and removes its
1690
+ pending session. Connected links cannot be cancelled. The source exposes no
1691
+ list, recover, or history operation.
1692
+
1693
+ `Client.quickLinkSettings.retrieve` and `update` map the management
1694
+ `GET /platform/quicklink` and `PUT /platform/quicklink` operations. Use them on the root
1695
+ organization client or an immutable project view:
1696
+
1697
+ ```ts
1698
+ const organizationSettings = await platform.quickLinkSettings.retrieve();
1699
+ const projectSettings = await platform
1700
+ .project("project_123")
1701
+ .quickLinkSettings.update(
1702
+ {
1703
+ theme: "dark",
1704
+ enabled: true,
1705
+ successCallbackUrl: "https://app.example.com/whatsapp/connected",
1706
+ failureCallbackUrl: "https://app.example.com/whatsapp/cancelled",
1707
+ allowPhoneChange: false,
1708
+ },
1709
+ { idempotencyKey: "quicklink-project-123-dark" },
1710
+ );
1711
+ ```
1712
+
1713
+ These methods manage saved settings only. Hosted lifecycle methods stay on
1714
+ `MessagingClient.quickLinks`, not `Client` or `client.project(...)`, because
1715
+ the `/messaging/quicklinks/{id}` routes do not carry an immutable project path for an
1716
+ organization-key project view. Console-only logo routes are outside the SDK.
1717
+
1718
+ `successCallbackUrl` and `failureCallbackUrl` are project-only HTTPS
1719
+ destinations; the API copies them into each link when it is issued, and link
1720
+ creation has no callback override. `allowPhoneChange` lets recipients replace a
1721
+ prefilled number and defaults to `false`. `hideWatermark: true` requires Premium
1722
+ team access. Saved settings have no redirect-URI allowlist. `externalId` on
1723
+ creation is an integrator correlation value copied to the resulting session; it
1724
+ can repeat across invitations and does not grant access.
1725
+
1726
+ ## Management session lifecycle
1727
+
1728
+ The organization client's `sessions.start` requests a start for one stopped or
1729
+ failed session. It accepts a session UUID or stable slug and an optional project
1730
+ context:
1731
+
1732
+ ```ts
1733
+ const start = await platform.sessions.start(
1734
+ "support",
1735
+ { projectId: "11111111-2222-4333-8444-555555555555" },
1736
+ { idempotencyKey: "start-support" },
1737
+ );
1738
+ ```
1739
+
1740
+ The returned `SessionStartResult` confirms that the start request was accepted;
1741
+ it does not claim that the session has connected. A paid start first reserves
1742
+ credit. An HTTP 402 response throws `PolymorfaPaymentRequiredError`, preserving
1743
+ the API's error code, message, and request ID. It is not automatically retried;
1744
+ resolve the funding or entitlement problem before submitting another start.
1745
+ The charge is committed on successful connection. `sessions.stopMany` and
1746
+ `deleteMany` cover the two bounded batch operations. All three require
1747
+ `sessions:manage`. Batch methods accept `sessionIds` plus an optional
1748
+ `projectId`:
1749
+
1750
+ ```ts
1751
+ const stop = await platform.sessions.stopMany(
1752
+ {
1753
+ projectId: "11111111-2222-4333-8444-555555555555",
1754
+ sessionIds: ["support", "sales"],
1755
+ },
1756
+ { idempotencyKey: "stop-support-sales" },
1757
+ );
1758
+
1759
+ const removal = await platform.sessions.deleteMany(
1760
+ { sessionIds: ["old-support", "old-sales"] },
1761
+ { idempotencyKey: "delete-old-support-sales" },
1762
+ );
1763
+ ```
1764
+
1765
+ The source accepts 1–100 UUIDs or stable slugs. It trims identifiers and the
1766
+ live handler deduplicates repeats, while OpenAPI declares the array unique.
1767
+ Only matching rows contribute to `{ stopping }` or `{ removed }`; the API does
1768
+ not return per-item results or errors for missing identifiers. Batch stop
1769
+ requires session-control publishing and queues fire-and-forget stop commands.
1770
+ Batch delete removes rows first, then best-effort queues kill commands for
1771
+ rows that were not disconnected. Neither route returns a durable operation ID,
1772
+ stream, watcher, or completion status.
1773
+
1774
+ The transport retries these mutations only when an idempotency key is
1775
+ provided. The pinned handlers do not persist that header. A repeated stop can
1776
+ enqueue another stop command; a repeated delete reports only rows still found.
1777
+ QuickLink settings updates are state upserts and can safely converge on the
1778
+ same supplied values.
1779
+
1780
+ ## Session creation and configuration
1781
+
1782
+ Create new sessions with `MessagingClient.quickLinks.create`. Direct
1783
+ `sessions.create` and Platform `sessions.createTesting` have been removed in this
1784
+ breaking contract update. Reconnect and delete still operate on existing sessions.
1785
+
1786
+ ```ts
1787
+ const link = await messaging.quickLinks.create({
1788
+ projectId,
1789
+ configuration: {
1790
+ connectionPreference: "linked",
1791
+ historySync: { consent: "ask" },
1792
+ },
1793
+ });
1794
+ ```
1795
+
1796
+ Page text, appearance, legal links, and callbacks belong in saved
1797
+ `Client.quickLinkSettings`, not individual invitations. Links report nullable
1798
+ `expiresAt`; new invitations remain usable until completion or cancellation.
1799
+ Free-tier real-account pairing is available only in the authenticated Console.
1800
+
1801
+ Use `Client.sessionConfiguration` for team defaults and
1802
+ `client.project(projectId).sessionConfiguration` for project defaults. Session
1803
+ updates take `{revision, configuration: {set, reset}}`; resets remove explicit
1804
+ overrides so later defaults continue to apply. Reads expose effective values,
1805
+ sources, consent restrictions, and pending runtime application.
1806
+
1807
+ For simulation, create a QuickLink with `configuration.testing`, including initial
1808
+ `configuration` and the explicit `editable` subset delegated to the recipient.
1809
+ Test access is checked independently; simulation cannot contact real accounts.
1810
+
1811
+ Test history content is uploaded separately from session configuration:
1812
+
1813
+ ```ts
1814
+ const fixture = await messaging.testing.createHistoryFixture(projectId, {
1815
+ messages: [
1816
+ {
1817
+ id: "example-1",
1818
+ senderPhone: testPhone,
1819
+ text: "Demo",
1820
+ timestamp: 1,
1821
+ fromMe: false,
1822
+ },
1823
+ ],
1824
+ });
1825
+ const invitation = await messaging.quickLinks.create({
1826
+ projectId,
1827
+ configuration: {
1828
+ testing: { configuration: { historyFixtureId: fixture.data.fixtureId } },
1829
+ },
1830
+ });
1831
+ ```
1832
+
1833
+ Fixture senders must be existing simulated numbers in that project. Test-number
1834
+ entitlements and history consent still apply; uploading a fixture does not enable
1835
+ hosted message storage.
1836
+
1837
+ ### Trigger test events
1838
+
1839
+ Fire a named, signed test event for a Test number. The event reaches your
1840
+ webhooks and event history with `source: "test"` and does not change the Test
1841
+ number. Real numbers are refused with a `PolymorfaValidationError`, and each
1842
+ project can trigger 30 test events per minute (`PolymorfaRateLimitError`).
1843
+
1844
+ ```ts
1845
+ import { TEST_EVENT_FIXTURES } from "@polymorfa/sdk";
1846
+
1847
+ const result = await messaging.testing.triggerEvent(projectId, {
1848
+ session: "my-test-number",
1849
+ event: "message.received", // one of TEST_EVENT_FIXTURES
1850
+ overrides: { text: "hi", from: "+15550100001" },
1851
+ });
1852
+ console.log(result.data.eventId);
1853
+
1854
+ // Rare events: failed delivery, ban warning, incoming call, template rejection.
1855
+ await messaging.testing.triggerEvent(projectId, {
1856
+ session: "my-test-number",
1857
+ event: "template.status",
1858
+ overrides: { templateStatus: "REJECTED", reason: "INVALID_FORMAT" },
1859
+ });
1860
+
1861
+ const { data } = await messaging.testing.listEventFixtures(projectId);
1862
+ ```
1863
+
1864
+ Set `fromSession` on a `message.received` request to send a simulated text
1865
+ from another connected Test number in the same project instead; the response
1866
+ has `delivery: "simulated"` and the event arrives as ordinary Test number
1867
+ activity. Both methods require an organization API key or project token with
1868
+ `sandbox:write` (trigger) or `sandbox:read` (list) and Test numbers access.
1869
+
1870
+ Pass `{ idempotencyKey }` as the third argument to `triggerEvent` to retry
1871
+ safely. Repeating the request with the same key and body reuses the same event
1872
+ ID, so a retry after an uncertain response never creates a second event or
1873
+ duplicate webhook deliveries. With a key, the SDK also retries network and
1874
+ 5xx failures.
1875
+
1876
+ Trusted servers continue an issued Meta Cloud API invitation with
1877
+ `messaging.cloudOnboarding.advance({ quicklinkId, projectId, result })`.
1878
+ `result` contains the Embedded Signup authorization code, selected WABA and phone
1879
+ IDs, and Coexistence/history choices. This method does not create a session or
1880
+ accept Meta app secrets. Its progress response is not proof that messaging is
1881
+ ready; inspect the QuickLink status.