@whooshbang/sdk 5.0.0 → 6.0.0

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 (67) hide show
  1. package/README.md +232 -83
  2. package/dist/cli/commands.js +2 -0
  3. package/dist/cli/commands.js.map +1 -1
  4. package/dist/client.d.ts +209 -1
  5. package/dist/client.js +540 -5
  6. package/dist/client.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/interactions.js +18 -3
  10. package/dist/interactions.js.map +1 -1
  11. package/dist/recipient/element.js +641 -14
  12. package/dist/recipient/element.js.map +1 -1
  13. package/dist/recipient/i18n/ar.js +77 -0
  14. package/dist/recipient/i18n/ar.js.map +1 -1
  15. package/dist/recipient/i18n/catalog.d.ts +75 -0
  16. package/dist/recipient/i18n/catalog.js.map +1 -1
  17. package/dist/recipient/i18n/de.js +82 -0
  18. package/dist/recipient/i18n/de.js.map +1 -1
  19. package/dist/recipient/i18n/en.js +82 -0
  20. package/dist/recipient/i18n/en.js.map +1 -1
  21. package/dist/recipient/i18n/es.js +82 -0
  22. package/dist/recipient/i18n/es.js.map +1 -1
  23. package/dist/recipient/i18n/fa.js +77 -0
  24. package/dist/recipient/i18n/fa.js.map +1 -1
  25. package/dist/recipient/i18n/fr.js +82 -0
  26. package/dist/recipient/i18n/fr.js.map +1 -1
  27. package/dist/recipient/i18n/hi.js +82 -0
  28. package/dist/recipient/i18n/hi.js.map +1 -1
  29. package/dist/recipient/i18n/id.js +82 -0
  30. package/dist/recipient/i18n/id.js.map +1 -1
  31. package/dist/recipient/i18n/it.js +82 -0
  32. package/dist/recipient/i18n/it.js.map +1 -1
  33. package/dist/recipient/i18n/nl.js +82 -0
  34. package/dist/recipient/i18n/nl.js.map +1 -1
  35. package/dist/recipient/i18n/pl.js +82 -0
  36. package/dist/recipient/i18n/pl.js.map +1 -1
  37. package/dist/recipient/i18n/pt-BR.js +82 -0
  38. package/dist/recipient/i18n/pt-BR.js.map +1 -1
  39. package/dist/recipient/i18n/ru.js +82 -0
  40. package/dist/recipient/i18n/ru.js.map +1 -1
  41. package/dist/recipient/i18n/tr.js +82 -0
  42. package/dist/recipient/i18n/tr.js.map +1 -1
  43. package/dist/recipient/i18n/uk.js +82 -0
  44. package/dist/recipient/i18n/uk.js.map +1 -1
  45. package/dist/recipient/i18n/zh-Hans.js +77 -0
  46. package/dist/recipient/i18n/zh-Hans.js.map +1 -1
  47. package/dist/recipient/order-section.d.ts +41 -0
  48. package/dist/recipient/order-section.js +547 -0
  49. package/dist/recipient/order-section.js.map +1 -0
  50. package/dist/recipient/order-view.d.ts +123 -0
  51. package/dist/recipient/order-view.js +304 -0
  52. package/dist/recipient/order-view.js.map +1 -0
  53. package/dist/recipient/platform-mark.d.ts +1 -0
  54. package/dist/recipient/platform-mark.js +57 -0
  55. package/dist/recipient/platform-mark.js.map +1 -0
  56. package/dist/recipient/quiet-section.d.ts +47 -0
  57. package/dist/recipient/quiet-section.js +490 -0
  58. package/dist/recipient/quiet-section.js.map +1 -0
  59. package/dist/recipient/quiet-view.d.ts +84 -0
  60. package/dist/recipient/quiet-view.js +250 -0
  61. package/dist/recipient/quiet-view.js.map +1 -0
  62. package/dist/recipient/view.d.ts +2 -0
  63. package/dist/recipient/view.js.map +1 -1
  64. package/dist/webhooks.d.ts +30 -1
  65. package/dist/webhooks.js +57 -4
  66. package/dist/webhooks.js.map +1 -1
  67. package/package.json +12 -2
package/README.md CHANGED
@@ -42,17 +42,15 @@ const whooshBang = new WhooshBangClient({
42
42
  fetch,
43
43
  });
44
44
 
45
- const sent = await whooshBang.createMessage(
46
- {
47
- to: "customer_123",
48
- interaction: {
49
- prompt: "Approve the synthetic deployment?",
50
- confirm_label: "Approve",
51
- deny_label: "Deny",
52
- expires_in_seconds: 900,
53
- },
45
+ const sent = await whooshBang.createMessage({
46
+ to: "customer_123",
47
+ interaction: {
48
+ prompt: "Approve the synthetic deployment?",
49
+ confirm_label: "Approve",
50
+ deny_label: "Deny",
51
+ expires_in_seconds: 900,
54
52
  },
55
- );
53
+ });
56
54
  switch (sent.outcome) {
57
55
  case "accepted":
58
56
  console.log(sent.message.id);
@@ -108,7 +106,10 @@ are recognized for at least seven days, and longer when the answer window is lon
108
106
  same contract (`expires_in_seconds` / `expires_at` on the interaction).
109
107
 
110
108
  ```ts
111
- const question = confirmInteraction({ prompt: "Approve this week's plan?", expiresInSeconds: 604800 });
109
+ const question = confirmInteraction({
110
+ prompt: "Approve this week's plan?",
111
+ expiresInSeconds: 604800,
112
+ });
112
113
  ```
113
114
 
114
115
  The message's outer `expires_at` is its separate delivery deadline (default 24 hours, extendable through year 9999), not the answer deadline. Delivery acceptance does not end answerability.
@@ -142,6 +143,127 @@ a correct caller cancels and learns which side won. `cancelled` carries the
142
143
  cancelled resource; `cancellation_too_late` carries the message's current
143
144
  state, or the link's Problem Details, and never claims provider-side deletion.
144
145
 
146
+ ## Escalation
147
+
148
+ A send that carries `escalation` keeps trying the person across their channel
149
+ order, then any further stages, until a genuine human response, your
150
+ cancellation, its expiry, or nobody is left to try. The accepted message is the
151
+ first attempt and carries `message.escalation.id`. Read the whole ladder with
152
+ `getEscalation(escalationId, { wait })`; `wait` holds the read until the
153
+ escalation closes, up to 30 seconds. Its `timeline` says what happened in plain
154
+ sentences, such as "Skipped Slack (step 2): this person hasn't connected Slack."
155
+ or "Acknowledged by email, using the link in the email.", so you can show a
156
+ person why the escalation did what it did. The `escalation.closed` event carries
157
+ the same timeline.
158
+
159
+ ```ts
160
+ const sent = await whooshBang.createMessage(request, {
161
+ idempotencyKey: "incident-2041-page",
162
+ });
163
+ // Later, when the matter is resolved some other way:
164
+ const stop = await whooshBang.cancelEscalationByKey({
165
+ idempotency_key: "incident-2041-page",
166
+ reason: "resolved",
167
+ });
168
+ ```
169
+
170
+ Wait on the escalation, not on that first message. A person may answer on any
171
+ attempt, including an earlier one, so `awaitInteraction` on the first message
172
+ can miss the response that closed the escalation. `escalation.response` says
173
+ which attempt was answered and how: `acknowledged`, `answered`, `declined`
174
+ (a "no" is a reached person refusing, not approval) or `replied`.
175
+
176
+ When nothing can be tried at all, because the person has no connected channel,
177
+ the send is refused with `subscriber_unbound` and no escalation exists. When
178
+ channels exist but every attempt is skipped at acceptance, for example because
179
+ consent was withdrawn, the escalation is created and closes as
180
+ `no_eligible_target`; its first message was never sent and carries
181
+ `escalation.skip_reason`.
182
+
183
+ `cancelEscalation(escalationId, { reason })` stops every future attempt.
184
+ `cancelEscalationByKey` does the same for a caller that kept only the send's
185
+ `Idempotency-Key`; name `send_operation: "sendOperatorMessage"` when the key
186
+ was used on an operator send. Both resolve with `cancelled` when this call
187
+ closed the escalation, or `already_closed` when it had already ended, so
188
+ cancelling after someone answered is safe and keeps their answer.
189
+
190
+ ### Escalation defaults
191
+
192
+ Each environment has a default channel order for every urgency tier (`low`,
193
+ `normal`, `high`, `critical`, the message category's level). A person without
194
+ an order of their own gets the tier's default. Until you save one, a tier uses
195
+ the built-in suggested order: the environment's connected channels in the
196
+ order Telegram, Slack, email, 5 minutes each.
197
+
198
+ ```ts
199
+ await whooshBang.updateEscalationPolicy(projectId, environmentId, "critical", {
200
+ steps: [
201
+ { channel: "telegram", wait_minutes: 5 },
202
+ { channel: "email", wait_minutes: 15 },
203
+ ],
204
+ });
205
+ const preview = await whooshBang.previewEscalationPath(
206
+ projectId,
207
+ environmentId,
208
+ "critical",
209
+ "customer-123",
210
+ );
211
+ ```
212
+
213
+ Every step's channel must be connected in that environment, or the save fails
214
+ with `escalation_policy_channel_unconnected`. Each save is a new version;
215
+ running escalations keep the order they started with. `listEscalationPolicies`
216
+ reads all four tiers, and `resetEscalationPolicy` returns a tier to the
217
+ suggested order. The preview shows which of the person's channels each step
218
+ reaches; a step without `binding_id` would be skipped.
219
+
220
+ ### A subscriber's own escalation order
221
+
222
+ A subscriber can set their own order for each urgency, naming their exact
223
+ bindings with whole-minute waits. Their order always wins over the project's;
224
+ an empty order means nothing is tried, and an escalation to them closes as
225
+ `no_eligible_target`. Only the subscriber can change it, through their
226
+ recipient session:
227
+
228
+ ```ts
229
+ const orders = await recipientSession.getEscalationOrders();
230
+ await recipientSession.saveEscalationOrder("critical", {
231
+ steps: [{ binding_id: bindingId, wait_minutes: 2 }],
232
+ version: orders.items[3]?.version,
233
+ });
234
+ await recipientSession.resetEscalationOrder("critical");
235
+ ```
236
+
237
+ Your application can read it with `getSubscriberEscalationOrders`, but cannot
238
+ change it. That differs on purpose from the preferred binding, which your
239
+ application can set.
240
+
241
+ ### Quiet hours
242
+
243
+ A subscriber can set quiet hours: the times they would rather not be
244
+ interrupted, and what each channel does then. A channel can deliver as usual,
245
+ deliver silently (Telegram only) or withhold the message. A withheld message
246
+ waits until quiet hours end, or goes straight to one channel they choose, such
247
+ as their email. Critical messages still arrive unless they turn that off. Only
248
+ the subscriber can change their quiet hours, through their recipient session:
249
+
250
+ ```ts
251
+ const quiet = await recipientSession.getQuietHours();
252
+ await recipientSession.saveQuietHours({
253
+ windows: [{ days: [1, 2, 3, 4, 5], start: "22:00", end: "07:00" }],
254
+ channels: { telegram: "silent", slack: "withhold", email: "deliver" },
255
+ critical_breakthrough: true,
256
+ when_withheld: "quiet_channel",
257
+ quiet_channel_binding_id: quiet.quiet_channel_options[0]?.binding_id,
258
+ time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
259
+ });
260
+ await recipientSession.resetQuietHours();
261
+ ```
262
+
263
+ `channel_options` lists the choices each channel offers. Your application can
264
+ read a subscriber's quiet hours with `getSubscriberQuietHours`, but cannot
265
+ change them.
266
+
145
267
  ## Awaiting an answer
146
268
 
147
269
  Use your configured customer-events webhook to receive answers without keeping
@@ -248,6 +370,11 @@ both the previous and the current secret so a rotation overlap has no gap. Use
248
370
  `verifyWhooshBangWebhook` (or `verifyWebhookSignature`) for signature-only
249
371
  verification of any signed body.
250
372
 
373
+ Events newer than the SDK still verify. A known type carrying a new field or
374
+ enum value keeps its type; an unknown type returns `recognized: false` with
375
+ only the shared envelope checked. Answer those with a 2xx: a 4xx stops the
376
+ event and holds the endpoint's later deliveries behind it.
377
+
251
378
  ## Channel connections
252
379
 
253
380
  `listConnectionProviders` reports what a project can connect to, the setup
@@ -384,7 +511,8 @@ function onReady(event) {
384
511
  event.source !== popup ||
385
512
  event.origin !== hostedOrigin ||
386
513
  event.data?.type !== "whooshbang.recipient-settings.ready"
387
- ) return;
514
+ )
515
+ return;
388
516
  window.removeEventListener("message", onReady);
389
517
  popup.postMessage(
390
518
  { type: "whooshbang.recipient-settings.session", session },
@@ -432,25 +560,27 @@ result type and no global outcome enum. An exhaustive `switch` over `outcome`
432
560
  compiles with no `default`, and a member added later is a compile error at
433
561
  every call site rather than a silent fallthrough.
434
562
 
435
- | Method | `outcome` | Carries | Means |
436
- | --------------------------------------------- | ----------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
437
- | `interactionAnswer` | `answered` | `answer`, `summary` | A person answered and the answer is retained. |
438
- | | `answer_not_retained` | `summary` | Answered; the answer is no longer in message history. `answered_at` survives. |
439
- | | `open` | `summary` | Nobody has answered yet. |
440
- | | `expired` | `summary` | The interaction reached its server-side expiry unanswered. |
441
- | | `cancelled` | `summary` | Cancelled before an answer. |
442
- | | `retired` | `summary` | The question was retired; later answers are ignored. |
443
- | `createMessage`, `createMessageInEnvironment` | `accepted` | `message` | The service accepted the send; `message` is its current state. |
444
- | | `subscriber_unbound` | `problem` | No active binding reaches the subscriber on this notifier. Nothing was sent; usually they have to subscribe again, sometimes your routing changed. |
445
- | | `preferred_binding_unavailable` | `problem` | The channel the subscriber chose as preferred is paused or revoked, and the service never picks a fallback. Nothing was sent; they have to choose again or subscribe again. |
446
- | | `funding_required` | `reason`, `problem` | Add credit or wait for a paid allowance to renew, then retry the refused request. |
447
- | `awaitInteraction` | as `interactionAnswer` | `summary`, `answer` on `answered` | The same members; `open` means the patience bound elapsed and the interaction can be awaited again. |
448
- | `ask` | as `awaitInteraction` | plus `message` | Every interaction member also carries the accepted message. |
449
- | | `subscriber_unbound`, `preferred_binding_unavailable`, `funding_required` | `problem` | The send's own refusals, exactly as `createMessage` returns them. Nothing was sent, so there is no message. |
450
- | `cancelMessage` | `cancelled` | `message` | Cancellation took effect before a worker owned the provider call. |
451
- | | `cancellation_too_late` | `message` | A worker already owned the provider call. `message` is the current state; no provider-side deletion is claimed. |
452
- | `cancelSubscriptionLink` | `cancelled` | `link` | The pending link is cancelled and can no longer create a binding. |
453
- | | `cancellation_too_late` | `problem` | The link had already left `pending` — activated, expired, failed, or revoked. Read the link to learn which. |
563
+ | Method | `outcome` | Carries | Means |
564
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
565
+ | `interactionAnswer` | `answered` | `answer`, `summary` | A person answered and the answer is retained. |
566
+ | | `answer_not_retained` | `summary` | Answered; the answer is no longer in message history. `answered_at` survives. |
567
+ | | `open` | `summary` | Nobody has answered yet. |
568
+ | | `expired` | `summary` | The interaction reached its server-side expiry unanswered. |
569
+ | | `cancelled` | `summary` | Cancelled before an answer. |
570
+ | | `retired` | `summary` | The question was retired; later answers are ignored. |
571
+ | `createMessage`, `createMessageInEnvironment` | `accepted` | `message` | The service accepted the send; `message` is its current state. |
572
+ | | `subscriber_unbound` | `problem` | No active binding reaches the subscriber on this notifier. Nothing was sent; usually they have to subscribe again, sometimes your routing changed. |
573
+ | | `preferred_binding_unavailable` | `problem` | The channel the subscriber chose as preferred is paused or revoked, and the service never picks a fallback. Nothing was sent; they have to choose again or subscribe again. |
574
+ | | `funding_required` | `reason`, `problem` | Add credit or wait for a paid allowance to renew, then retry the refused request. |
575
+ | `awaitInteraction` | as `interactionAnswer` | `summary`, `answer` on `answered` | The same members; `open` means the patience bound elapsed and the interaction can be awaited again. |
576
+ | `ask` | as `awaitInteraction` | plus `message` | Every interaction member also carries the accepted message. |
577
+ | | `subscriber_unbound`, `preferred_binding_unavailable`, `funding_required` | `problem` | The send's own refusals, exactly as `createMessage` returns them. Nothing was sent, so there is no message. |
578
+ | `cancelMessage` | `cancelled` | `message` | Cancellation took effect before a worker owned the provider call. |
579
+ | | `cancellation_too_late` | `message` | A worker already owned the provider call. `message` is the current state; no provider-side deletion is claimed. |
580
+ | `cancelSubscriptionLink` | `cancelled` | `link` | The pending link is cancelled and can no longer create a binding. |
581
+ | | `cancellation_too_late` | `problem` | The link had already left `pending` — activated, expired, failed, or revoked. Read the link to learn which. |
582
+ | `cancelEscalation`, `cancelEscalationByKey` and their `…InEnvironment` forms | `cancelled` | `escalation` | This call closed the escalation; no later attempt is sent. An attempt already handed to the provider is recorded, never claimed deleted. |
583
+ | | `already_closed` | `escalation` | The escalation had already ended — answered, expired, exhausted or cancelled earlier — and is returned unchanged; its `outcome` says which. Cancelling twice is safe. |
454
584
 
455
585
  Two outcomes the rule names are already values and need no union. An
456
586
  equivalent idempotent replay returns the original resource with the original
@@ -478,45 +608,52 @@ for a paid allowance to renew, then explicitly retry the same refused request.
478
608
  Bonus credit funds core sends only; shared email needs purchased or welcome
479
609
  credit. These ceilings do not schedule an automatic SDK retry.
480
610
 
481
- | Code | Side | Why |
482
- | ---------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
483
- | `cancellation_too_late` | Outcome | The race is inherent: a correct caller cancels and learns it was too late. `cancelSubscriptionLink` receives it as a problem and returns it; `cancelMessage` receives the same fact as `cancellation_effective: false` on a success, as the contract documents, so the problem code arriving there is outside the documented response and throws. |
484
- | `authentication_required` | Exception | No credential reached the service. The integration's configuration is wrong. |
485
- | `credential_invalid` | Exception | The credential is malformed, revoked, or past its rotation overlap. Only a replacement credential fixes it. |
486
- | `scope_forbidden` | Exception | The credential lacks a scope, or the organization is not yet invited (`pendingInvitation`). No branch at the call site grants either. |
487
- | `environment_mismatch` | Exception | A credential reached across the test/live boundary. |
488
- | `idempotency_key_required` | Exception | A supplied key was invalid. Omit it or use 8 to 255 visible ASCII characters. |
489
- | `idempotency_conflict` | Exception | Same key, different body — including the stale row-version, claimed-identity, revoked-client, and acknowledgement cursor or disposition refusals that reuse the code. The fix is a new request, never a retry. |
490
- | `project_slug_conflict` | Exception | The slug is already taken in this organization. Refused input, handled as `request_invalid` is: choose another. |
491
- | `request_invalid` | Exception | The request broke a documented bound. `fieldErrors` names each field. |
492
- | `replay_unavailable` | Exception | A customer-event replay was refused by the attempt's state. The caller waits for a terminal attempt or accepts that an aged-out payload is gone; there is nothing to branch on. |
493
- | `subscriber_unbound` | Outcome | On a send, the person's channel state: they revoked their channel or never finished subscribing, so the branch is to invite them again. It is also the answer when a send names a `connection_id` and the subscriber has no connected channel at all. `createMessage` and `createMessageInEnvironment` return it. `createMachineClient` still throws it: a machine client for an unbound subscriber is setup that is wrong. |
494
- | `subscriber_not_connected` | Exception | A send or a connection test named a connection the subscriber has not connected through, while they are connected through another. `problem.subscriber_id` and `problem.channel` say who and where; nothing was sent. Naming the connection was the caller's choice: omit `connection_id` to let the service choose one of their channels, pick a connection they connected through, or invite them through this one. |
495
- | `not_consented_for_marketing` | Exception | A `marketing` send or broadcast reached an email recipient enrolled only on a service basis. Nothing was sent to that recipient and nothing was charged. Send it as `service`, or obtain marketing consent first. |
496
- | `category_not_found` | Exception | The named category is not active in this project. Check its key before sending. |
497
- | `category_purpose_mismatch` | Exception | The stated purpose disagrees with the category's purpose. Nothing was sent or charged. |
498
- | `category_turned_off` | Exception | The subscriber switched this category off. Nothing was sent or charged. |
499
- | `category_conflict` | Exception | A category key is taken, the category is in use, or its settings prevent the requested change. |
500
- | `recipient_configuration_invalid` | Exception | The owner's recipient-experience configuration is not usable as submitted or as stored. |
501
- | `recipient_origin_forbidden` | Exception | The presenting browser origin is not registered for the session. |
502
- | `return_destination_not_found` | Exception | The return-destination key is not configured. |
503
- | `recipient_capability_invalid` | Exception | The session's capability is not live. Only the host backend can mint another; this is the browser surface's `credential_invalid`. |
504
- | `recipient_handoff_invalid` | Exception | The provider handoff is unavailable or belongs to another session. |
505
- | `preferred_binding_conflict` | Exception | A stale row version. Re-read the session and send a new request. |
506
- | `preferred_binding_unavailable` | Outcome | On a send, the subscriber's own channel state: the channel they chose as preferred is paused or revoked, and the service never picks a fallback. The same user event as `subscriber_unbound`, so `createMessage` and `createMessageInEnvironment` return it. The recipient-session client's `setPreferredBinding` still throws it: there it is a choice the service refused, and the panel offers the choice again. |
507
- | `subscriber_conflict` | Exception | A create for a subscriber that exists. Use the update and membership operations. |
508
- | `subscriber_group_conflict` | Exception | The group's state, revision, or a ceiling refused the operation. |
509
- | `subscriber_group_archived` | Exception | Deletion is terminal. The caller needs a different group. |
510
- | `resource_not_found` | Exception | The identifier is wrong or outside the credential's reach; absent and out-of-scope look identical on purpose. |
511
- | `resource_expired` | Exception | Published but not produced by the service today: a resource's expiry reaches the caller as its own `status` or `state`, which is already a value. The recipient-session element treats it, with `recipient_capability_invalid`, as a session that has ended. |
512
- | `credential_organization_mismatch` | Exception | An organization credential reached a project outside its organization. |
513
- | `endpoint_rotation_conflict` | Exception | A prior overlap is still active. The caller waits for `retryAt`, as for rate limiting. |
514
- | `rate_limited` | Exception | Wait until `retryAt`. The caller's response is to wait, not to branch. |
515
- | `privacy_operation_in_progress` | Exception | Export, erasure, or organization closure still has in-flight work to drain or a provider cleanup to retry. Wait until `retryAt`, then repeat the same operation. |
516
- | `provider_retryable` | Exception | The provider failed before acceptance. The service retries where a retry is scheduled; the caller waits. |
517
- | `provider_terminal` | Exception | The provider refused permanently. Fix the binding or the content and send a new message. |
518
- | `provider_outcome_unknown` | Exception | The provider may already have acted. Read the resource state before deciding anything. |
519
- | `internal_error` | Exception | WhooshBang failed on its own side; no provider or customer system was involved. Retried like any retryable problem. If it persists, report `diagnosticId` to support. |
611
+ | Code | Side | Why |
612
+ | --------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
613
+ | `cancellation_too_late` | Outcome | The race is inherent: a correct caller cancels and learns it was too late. `cancelSubscriptionLink` receives it as a problem and returns it; `cancelMessage` receives the same fact as `cancellation_effective: false` on a success, as the contract documents, so the problem code arriving there is outside the documented response and throws. |
614
+ | `authentication_required` | Exception | No credential reached the service. The integration's configuration is wrong. |
615
+ | `credential_invalid` | Exception | The credential is malformed, revoked, or past its rotation overlap. Only a replacement credential fixes it. |
616
+ | `scope_forbidden` | Exception | The credential lacks a scope, or the organization is not yet invited (`pendingInvitation`). No branch at the call site grants either. |
617
+ | `environment_mismatch` | Exception | A credential reached across the test/live boundary. |
618
+ | `idempotency_key_required` | Exception | A supplied key was invalid. Omit it or use 8 to 255 visible ASCII characters. |
619
+ | `idempotency_conflict` | Exception | Same key, different body — including the stale row-version, claimed-identity, revoked-client, and acknowledgement cursor or disposition refusals that reuse the code. The fix is a new request, never a retry. |
620
+ | `project_slug_conflict` | Exception | The slug is already taken in this organization. Refused input, handled as `request_invalid` is: choose another. |
621
+ | `request_invalid` | Exception | The request broke a documented bound. `fieldErrors` names each field. |
622
+ | `replay_unavailable` | Exception | A customer-event replay was refused by the attempt's state. The caller waits for a terminal attempt or accepts that an aged-out payload is gone; there is nothing to branch on. |
623
+ | `subscriber_unbound` | Outcome | On a send, the person's channel state: they revoked their channel or never finished subscribing, so the branch is to invite them again. It is also the answer when a send names a `connection_id` and the subscriber has no connected channel at all. `createMessage` and `createMessageInEnvironment` return it. `createMachineClient` still throws it: a machine client for an unbound subscriber is setup that is wrong. |
624
+ | `subscriber_not_connected` | Exception | A send or a connection test named a connection the subscriber has not connected through, while they are connected through another. `problem.subscriber_id` and `problem.channel` say who and where; nothing was sent. Naming the connection was the caller's choice: omit `connection_id` to let the service choose one of their channels, pick a connection they connected through, or invite them through this one. |
625
+ | `not_consented_for_marketing` | Exception | A `marketing` send or broadcast reached an email recipient enrolled only on a service basis. Nothing was sent to that recipient and nothing was charged. Send it as `service`, or obtain marketing consent first. |
626
+ | `category_not_found` | Exception | The named category is not active in this project. Check its key before sending. |
627
+ | `category_purpose_mismatch` | Exception | The stated purpose disagrees with the category's purpose. Nothing was sent or charged. |
628
+ | `category_turned_off` | Exception | The subscriber switched this category off. Nothing was sent or charged. |
629
+ | `category_conflict` | Exception | A category key is taken, the category is in use, or its settings prevent the requested change. |
630
+ | `escalation_unavailable` | Exception | Escalation is not available in this environment yet. Nothing was sent or charged. Send without `escalation`, or retry once escalation is enabled. |
631
+ | `escalation_shape_unsupported` | Exception | A stage names more than one person. Parallel escalation is reserved and not yet supported; give each person a stage of their own. Nothing was sent. |
632
+ | `escalation_capacity_reached` | Exception | The environment has its maximum number of open escalations. Nothing was sent. Wait until `retryAt`, or for one to close, as for rate limiting. |
633
+ | `escalation_policy_channel_unconnected` | Exception | An escalation default names a channel with no active connection in this environment; `fieldErrors` names each step. Nothing was saved. Connect the channel first or remove the step. |
634
+ | `escalation_order_step_ineligible` | Exception | A subscriber's escalation order names a binding that is not theirs, is paused or revoked, or is on a channel the project turned off; `fieldErrors` names each step. Nothing was saved. |
635
+ | `escalation_limits_conflict` | Exception | The project's escalation limits changed since they were read. Read them again before saving. |
636
+ | `escalation_order_conflict` | Exception | The subscriber's escalation order changed since it was read. Read it again before saving. |
637
+ | `recipient_configuration_invalid` | Exception | The owner's recipient-experience configuration is not usable as submitted or as stored. |
638
+ | `recipient_origin_forbidden` | Exception | The presenting browser origin is not registered for the session. |
639
+ | `return_destination_not_found` | Exception | The return-destination key is not configured. |
640
+ | `recipient_capability_invalid` | Exception | The session's capability is not live. Only the host backend can mint another; this is the browser surface's `credential_invalid`. |
641
+ | `recipient_handoff_invalid` | Exception | The provider handoff is unavailable or belongs to another session. |
642
+ | `preferred_binding_conflict` | Exception | A stale row version. Re-read the session and send a new request. |
643
+ | `preferred_binding_unavailable` | Outcome | On a send, the subscriber's own channel state: the channel they chose as preferred is paused or revoked, and the service never picks a fallback. The same user event as `subscriber_unbound`, so `createMessage` and `createMessageInEnvironment` return it. The recipient-session client's `setPreferredBinding` still throws it: there it is a choice the service refused, and the panel offers the choice again. |
644
+ | `subscriber_conflict` | Exception | A create for a subscriber that exists. Use the update and membership operations. |
645
+ | `subscriber_group_conflict` | Exception | The group's state, revision, or a ceiling refused the operation. |
646
+ | `subscriber_group_archived` | Exception | Deletion is terminal. The caller needs a different group. |
647
+ | `resource_not_found` | Exception | The identifier is wrong or outside the credential's reach; absent and out-of-scope look identical on purpose. |
648
+ | `resource_expired` | Exception | Published but not produced by the service today: a resource's expiry reaches the caller as its own `status` or `state`, which is already a value. The recipient-session element treats it, with `recipient_capability_invalid`, as a session that has ended. |
649
+ | `credential_organization_mismatch` | Exception | An organization credential reached a project outside its organization. |
650
+ | `endpoint_rotation_conflict` | Exception | A prior overlap is still active. The caller waits for `retryAt`, as for rate limiting. |
651
+ | `rate_limited` | Exception | Wait until `retryAt`. The caller's response is to wait, not to branch. |
652
+ | `privacy_operation_in_progress` | Exception | Export, erasure, or organization closure still has in-flight work to drain or a provider cleanup to retry. Wait until `retryAt`, then repeat the same operation. |
653
+ | `provider_retryable` | Exception | The provider failed before acceptance. The service retries where a retry is scheduled; the caller waits. |
654
+ | `provider_terminal` | Exception | The provider refused permanently. Fix the binding or the content and send a new message. |
655
+ | `provider_outcome_unknown` | Exception | The provider may already have acted. Read the resource state before deciding anything. |
656
+ | `internal_error` | Exception | WhooshBang failed on its own side; no provider or customer system was involved. Retried like any retryable problem. If it persists, report `diagnosticId` to support. |
520
657
 
521
658
  ## Errors
522
659
 
@@ -603,14 +740,12 @@ sole customer-owned email connection supplies its domain; specify `connection_id
603
740
  when more than one exists. No verification message is sent.
604
741
 
605
742
  ```ts
606
- const binding = await client.assertEmailConsent(
607
- {
608
- subscriber_id: "customer_123",
609
- address: "Case.Sensitive+tag@example.com",
610
- consented: true,
611
- consented_at: "2026-09-10T12:00:00Z",
612
- },
613
- );
743
+ const binding = await client.assertEmailConsent({
744
+ subscriber_id: "customer_123",
745
+ address: "Case.Sensitive+tag@example.com",
746
+ consented: true,
747
+ consented_at: "2026-09-10T12:00:00Z",
748
+ });
614
749
  ```
615
750
 
616
751
  Use a canonical mailbox: preserve the unquoted ASCII local part exactly,
@@ -660,11 +795,25 @@ Interaction builders now return send-time inputs. Relative durations start when
660
795
  The production API URL is the default. Override `baseUrl` for a local mock or proxy.
661
796
 
662
797
  ```ts
663
- const wb = new WhooshBangClient({ credential: process.env.WHOOSHBANG_CREDENTIAL! });
664
- await wb.createMessage({ to: "customer_123", content: "Order 4821 has shipped." });
665
- await wb.ask({ to: "customer_123", interaction: { prompt: "Ship it?" } }, { patienceMs: 60_000 });
666
- await wb.ask({ to: "customer_123", interaction: { prompt: "Choose", options: ["A", "B"] } });
667
- await wb.ask({ to: "customer_123", interaction: { type: "input", prompt: "What should change?" } });
798
+ const wb = new WhooshBangClient({
799
+ credential: process.env.WHOOSHBANG_CREDENTIAL!,
800
+ });
801
+ await wb.createMessage({
802
+ to: "customer_123",
803
+ content: "Order 4821 has shipped.",
804
+ });
805
+ await wb.ask(
806
+ { to: "customer_123", interaction: { prompt: "Ship it?" } },
807
+ { patienceMs: 60_000 },
808
+ );
809
+ await wb.ask({
810
+ to: "customer_123",
811
+ interaction: { prompt: "Choose", options: ["A", "B"] },
812
+ });
813
+ await wb.ask({
814
+ to: "customer_123",
815
+ interaction: { type: "input", prompt: "What should change?" },
816
+ });
668
817
  ```
669
818
 
670
819
  `{ text }` and `{ blocks }` infer their content type. An image needs only its
@@ -289,6 +289,8 @@ function answerText(summary) {
289
289
  if (read.outcome !== "answered")
290
290
  return undefined;
291
291
  const { answer } = read;
292
+ if (answer.type === "acknowledge")
293
+ return "acknowledged";
292
294
  return answer.type === "confirm"
293
295
  ? answer.value
294
296
  ? "yes"