ruby-whatsapp 0.4.0 → 0.6.1

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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +322 -617
  4. data/docs/README.md +76 -0
  5. data/docs/business_phone_number/README.md +243 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/contact_book.md +90 -0
  8. data/docs/business_phone_number/profile.md +220 -0
  9. data/docs/business_phone_number/username.md +157 -0
  10. data/docs/configuration.md +177 -0
  11. data/docs/errors.md +219 -0
  12. data/docs/media/README.md +161 -0
  13. data/docs/message_templates/README.md +206 -0
  14. data/docs/message_templates/authentication.md +107 -0
  15. data/docs/message_templates/carousel.md +103 -0
  16. data/docs/message_templates/components.md +215 -0
  17. data/docs/message_templates/library.md +98 -0
  18. data/docs/message_templates/limited_time_offer.md +94 -0
  19. data/docs/message_templates/responses.md +173 -0
  20. data/docs/message_templates/standard.md +138 -0
  21. data/docs/messages/README.md +212 -0
  22. data/docs/messages/address.md +86 -0
  23. data/docs/messages/audio.md +43 -0
  24. data/docs/messages/contacts.md +129 -0
  25. data/docs/messages/document.md +49 -0
  26. data/docs/messages/image.md +59 -0
  27. data/docs/messages/interactive.md +342 -0
  28. data/docs/messages/location.md +51 -0
  29. data/docs/messages/location_request.md +59 -0
  30. data/docs/messages/mark_message_as_read.md +84 -0
  31. data/docs/messages/reaction.md +59 -0
  32. data/docs/messages/sticker.md +40 -0
  33. data/docs/messages/template.md +193 -0
  34. data/docs/messages/text.md +55 -0
  35. data/docs/messages/video.md +41 -0
  36. data/docs/schedule/README.md +183 -0
  37. data/docs/subscribed_app/README.md +139 -0
  38. data/docs/webhooks/README.md +217 -0
  39. data/docs/webhooks/account_alerts.md +44 -0
  40. data/docs/webhooks/account_review_update.md +29 -0
  41. data/docs/webhooks/account_update.md +54 -0
  42. data/docs/webhooks/automatic_events.md +47 -0
  43. data/docs/webhooks/business_capability_update.md +40 -0
  44. data/docs/webhooks/business_username_updates.md +55 -0
  45. data/docs/webhooks/history.md +107 -0
  46. data/docs/webhooks/message_template_components_update.md +46 -0
  47. data/docs/webhooks/message_template_quality_update.md +49 -0
  48. data/docs/webhooks/message_template_status_update.md +55 -0
  49. data/docs/webhooks/messages.md +520 -0
  50. data/docs/webhooks/partner_solutions.md +35 -0
  51. data/docs/webhooks/payment_configuration_update.md +40 -0
  52. data/docs/webhooks/phone_number_name_update.md +44 -0
  53. data/docs/webhooks/phone_number_quality_update.md +40 -0
  54. data/docs/webhooks/security.md +43 -0
  55. data/docs/webhooks/smb_app_state_sync.md +91 -0
  56. data/docs/webhooks/smb_message_echoes.md +91 -0
  57. data/docs/webhooks/template_category_update.md +52 -0
  58. data/docs/webhooks/user_id_update.md +84 -0
  59. data/docs/webhooks/user_preferences.md +87 -0
  60. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  61. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  62. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  63. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  64. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  65. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  66. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  67. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  68. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  69. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  70. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  71. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  72. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  73. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  74. data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
  75. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  76. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  77. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  78. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  79. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  80. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  81. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  82. data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
  83. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  84. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  85. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  86. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  87. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  88. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  89. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  90. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  91. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  92. data/lib/ruby/whatsapp/path_building.rb +33 -0
  93. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  94. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  95. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  96. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  97. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  98. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  99. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  100. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  101. data/lib/ruby/whatsapp/schedule.rb +15 -0
  102. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  103. data/lib/ruby/whatsapp/version.rb +1 -1
  104. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  105. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  106. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  107. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  108. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  109. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  110. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  111. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  112. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  113. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  114. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  115. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  116. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  117. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  118. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  119. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  120. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  121. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  122. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  123. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  124. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  125. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  126. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  127. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  128. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  129. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  130. metadata +100 -1
@@ -0,0 +1,193 @@
1
+ # Template Messages
2
+
3
+ The **only** way to message a user outside the 24-hour customer-service window.
4
+ Marketing, utility, and authentication templates must be created and approved by Meta
5
+ before they can be sent.
6
+
7
+ > **Two different APIs.** This page covers *sending* an already-approved template
8
+ > (`POST /{PHONE_ID}/messages`). Creating, editing, and deleting the templates
9
+ > themselves is a separate API addressing `waba_id` — see
10
+ > [../message_templates/](../message_templates/README.md). The component schemas are
11
+ > **not** interchangeable: management uses `text` + `example` with uppercase
12
+ > `HEADER`/`BODY`/`FOOTER`, sending uses `parameters` with lowercase singular types.
13
+
14
+ ```ruby
15
+ Whatsapp::Messages.send_template!(
16
+ to: "+15551234567",
17
+ name: "order_confirmation",
18
+ language: { code: "en_US" }
19
+ )
20
+ ```
21
+
22
+ ## Fields
23
+
24
+ | Field | Required | Rules |
25
+ | --- | --- | --- |
26
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
27
+ | `name` | yes | The approved template's name |
28
+ | `language` | yes | `{ code: "en_US" }` — validated against the supported list |
29
+ | `components` | no | Array of components supplying placeholder values. Defaults to `[]` |
30
+
31
+ `language.code` is checked against `Whatsapp::Utils::LanguageCodes` (71 codes), so a
32
+ typo raises locally instead of coming back as a Meta error:
33
+
34
+ ```ruby
35
+ Whatsapp::Utils::LanguageCodes.valid?("en_US") # => true
36
+ Whatsapp::Utils::LanguageCodes.all.size # => 71
37
+ ```
38
+
39
+ ### Authentication templates cannot go to a BSUID
40
+
41
+ One-tap, zero-tap, and copy-code
42
+ [authentication templates](../message_templates/authentication.md) require the
43
+ recipient's **phone number**. Address one to a
44
+ [BSUID](README.md#business-scoped-user-ids-bsuid) via `recipient:` and Meta answers
45
+ with error `131062`:
46
+
47
+ ```
48
+ Business-scoped User ID (BSUID) recipients are not supported for this message.
49
+ ```
50
+
51
+ The gem cannot catch this locally, and deliberately does not try. A template is
52
+ addressed at send time by `name` and `language` only — `Messages::Template` never
53
+ learns its own category, and the OTP configuration lives in the WABA. Guessing from
54
+ `components` would be worse than not guessing: a `copy_code` sub-type appears both on
55
+ copy-code authentication templates *and* on limited-time-offer marketing templates,
56
+ which have no such restriction, so any local rule would reject valid sends.
57
+
58
+ If you need to reach a username-only user with an authentication template, ask for
59
+ their phone number first — see the `REQUEST_CONTACT_INFO` button in
60
+ [components.md](../message_templates/components.md).
61
+
62
+ > Meta names one-tap, zero-tap, and copy-code specifically. Whether a plain
63
+ > `NO_BUTTONS` authentication template is also refused is **not documented** — treat it
64
+ > as untested rather than assuming either way.
65
+
66
+ ## Components
67
+
68
+ A component supplies the *values* for the placeholders the template defines. It has a
69
+ `type`, and for buttons a `sub_type` and `index`.
70
+
71
+ | `type` | Purpose |
72
+ | --- | --- |
73
+ | `"header"` | Values for the header's placeholder |
74
+ | `"body"` | Values for the body's placeholders |
75
+ | `"button"` | Values for a dynamic button — also needs `sub_type` and `index` |
76
+
77
+ Each component holds `parameters`, and each parameter has a `type` selecting exactly
78
+ one value key:
79
+
80
+ | Parameter `type` | Value key | Shape |
81
+ | --- | --- | --- |
82
+ | `"text"` | `text` | String |
83
+ | `"currency"` | `currency` | `{ fallback_value:, code:, amount_1000: }` |
84
+ | `"date_time"` | `date_time` | `{ fallback_value: }` |
85
+ | `"image"` | `image` | `{ link: }` or `{ id: }` |
86
+ | `"document"` | `document` | `{ link: }` or `{ id: }` |
87
+ | `"video"` | `video` | `{ link: }` or `{ id: }` |
88
+ | `"location"` | `location` | `{ latitude:, longitude:, name:, address: }` |
89
+ | `"payload"` | `payload` | String — a quick-reply button's payload |
90
+
91
+ > Only `type` is validated. There is no cross-field check that the matching value key
92
+ > is populated, so `{ type: "text" }` with no `text` serializes to a bare
93
+ > `{ type: "text" }` and is rejected by Meta rather than locally.
94
+
95
+ ## Examples
96
+
97
+ **Body placeholders:**
98
+
99
+ ```ruby
100
+ Whatsapp::Messages.send_template!(
101
+ to: "+15551234567",
102
+ name: "order_confirmation",
103
+ language: { code: "en_US" },
104
+ components: [
105
+ { type: "body", parameters: [{ type: "text", text: "Jane" },
106
+ { type: "text", text: "860198-230332" }] },
107
+ ]
108
+ )
109
+ ```
110
+
111
+ **Media header plus body:**
112
+
113
+ ```ruby
114
+ Whatsapp::Messages.send_template!(
115
+ to: "+15551234567",
116
+ name: "shipping_update",
117
+ language: { code: "en_US" },
118
+ components: [
119
+ { type: "header", parameters: [{ type: "image", image: { link: "https://example.com/box.jpg" } }] },
120
+ { type: "body", parameters: [{ type: "text", text: "Jane" }] },
121
+ ]
122
+ )
123
+ ```
124
+
125
+ **A dynamic URL button** — `sub_type` and `index` identify which button in the
126
+ template's button row is being filled:
127
+
128
+ ```ruby
129
+ Whatsapp::Messages.send_template!(
130
+ to: "+15551234567",
131
+ name: "order_confirmation",
132
+ language: { code: "en_US" },
133
+ components: [
134
+ { type: "body", parameters: [{ type: "text", text: "Jane" }] },
135
+ { type: "button", sub_type: "url", index: 0,
136
+ parameters: [{ type: "text", text: "1234" }] },
137
+ ]
138
+ )
139
+ ```
140
+
141
+ **A currency and a date:**
142
+
143
+ ```ruby
144
+ components: [
145
+ { type: "body", parameters: [
146
+ { type: "currency", currency: { fallback_value: "$100.99", code: "USD", amount_1000: 100_990 } },
147
+ { type: "date_time", date_time: { fallback_value: "February 25, 1977" } },
148
+ ] },
149
+ ]
150
+ ```
151
+
152
+ ## Serialized payload
153
+
154
+ ```ruby
155
+ Whatsapp::Messages::Template.new(
156
+ to: "+15551234567",
157
+ name: "order_confirmation",
158
+ language: { code: "en_US" },
159
+ components: [
160
+ { type: "body", parameters: [{ type: "text", text: "Jane" }, { type: "text", text: "#1234" }] },
161
+ ]
162
+ ).serialize
163
+ # => {
164
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "template",
165
+ # template: {
166
+ # name: "order_confirmation",
167
+ # language: { code: "en_US" },
168
+ # components: [{ type: "body", parameters: [{ type: "text", text: "Jane" },
169
+ # { type: "text", text: "#1234" }] }]
170
+ # }
171
+ # }
172
+ ```
173
+
174
+ With no components, the `components` key is dropped entirely rather than sent empty.
175
+
176
+ ## Validation errors
177
+
178
+ ```ruby
179
+ Whatsapp::Messages.send_template!(to: "+15551234567", name: "x", language: { code: "klingon" })
180
+ # => ActiveModel::ValidationError: Code is not a valid WhatsApp language code.
181
+ # Supported codes: af, sq, ar, az, bn, bg, ca, zh_CN, ...
182
+
183
+ Whatsapp::Messages.send_template!(to: "+15551234567", name: "", language: { code: "en_US" })
184
+ # => ActiveModel::ValidationError: Name can't be blank
185
+ ```
186
+
187
+ `name` is checked for presence only — an unapproved or misspelled template name comes
188
+ back from Meta, not from the validator.
189
+
190
+ ---
191
+
192
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/template-messages>
193
+ · [supported languages](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/supported-languages)
@@ -0,0 +1,55 @@
1
+ # Text Messages
2
+
3
+ The default way to carry a conversational reply or notice. Most other kinds exist
4
+ because they need to express something plain text can't — media, buttons, structured
5
+ data.
6
+
7
+ ```ruby
8
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
9
+ ```
10
+
11
+ ## Fields
12
+
13
+ | Field | Required | Rules |
14
+ | --- | --- | --- |
15
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
16
+ | `body` | yes | Max **4096** characters |
17
+ | `preview_url` | no | `true` or `false`. **Defaults to `true`** |
18
+
19
+ `preview_url` controls whether WhatsApp renders a link preview card for the first URL
20
+ in the body. Turn it off for transactional messages where a preview would be noise:
21
+
22
+ ```ruby
23
+ Whatsapp::Messages.send_text!(
24
+ to: "+15551234567",
25
+ body: "Track your order: https://example.com/orders/1234",
26
+ preview_url: false
27
+ )
28
+ ```
29
+
30
+ ## Serialized payload
31
+
32
+ ```ruby
33
+ Whatsapp::Messages::Text.new(to: "+15551234567", body: "Hello from ruby-whatsapp!").serialize
34
+ # => {
35
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "text",
36
+ # text: { body: "Hello from ruby-whatsapp!", preview_url: true }
37
+ # }
38
+ ```
39
+
40
+ The `text` hash is deliberately **not** compacted, so `preview_url` is always present
41
+ on the wire even when it holds its default.
42
+
43
+ ## Validation errors
44
+
45
+ ```ruby
46
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "")
47
+ # => ActiveModel::ValidationError: Body can't be blank
48
+
49
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "x" * 4097)
50
+ # => ActiveModel::ValidationError: Body is too long (maximum is 4096 characters)
51
+ ```
52
+
53
+ ---
54
+
55
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/text-messages>
@@ -0,0 +1,41 @@
1
+ # Video Messages
2
+
3
+ Shares a video — an instructional clip, a product demo, an unboxing.
4
+
5
+ ```ruby
6
+ Whatsapp::Messages.send_video!(to: "+15551234567", id: "1234567890", caption: "Demo video")
7
+ ```
8
+
9
+ ## Fields
10
+
11
+ | Field | Required | Rules |
12
+ | --- | --- | --- |
13
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
14
+ | `id` | one of | A media ID from [`Media#upload`](../media/README.md) |
15
+ | `link` | one of | A publicly reachable HTTPS URL |
16
+ | `caption` | no | Max **1024** characters |
17
+
18
+ **Either `id` or `link` is required.** Videos are the kind where uploading once and
19
+ reusing the `id` pays off most — a `link` makes Meta re-download the whole file on
20
+ every single send.
21
+
22
+ ## Serialized payload
23
+
24
+ ```ruby
25
+ Whatsapp::Messages::Video.new(to: "+15551234567", id: "1234567890", caption: "Demo video").serialize
26
+ # => {
27
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "video",
28
+ # video: { id: "1234567890", caption: "Demo video" }
29
+ # }
30
+ ```
31
+
32
+ ## Validation errors
33
+
34
+ ```ruby
35
+ Whatsapp::Messages.send_video!(to: "+15551234567")
36
+ # => ActiveModel::ValidationError: Either id or link must be present
37
+ ```
38
+
39
+ ---
40
+
41
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/video-messages>
@@ -0,0 +1,183 @@
1
+ # Scheduling Campaigns
2
+
3
+ A **campaign schedule** sends an approved [template](../message_templates/README.md) to an
4
+ audience at a time you choose. `Whatsapp::Schedule` wraps the `schedules` edge, which
5
+ lists an account's schedules and creates new ones.
6
+
7
+ > **Schedules send templates; they don't create them.** The template behind `hsm_id`
8
+ > must already exist and be approved. Create and manage it with
9
+ > [`Whatsapp::MessageTemplates`](../message_templates/README.md).
10
+
11
+ Addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`), the same as
12
+ [template management](../message_templates/README.md) and
13
+ [subscribed apps](../subscribed_app/README.md).
14
+
15
+ ```ruby
16
+ Whatsapp.configure do |config|
17
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
18
+ config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
19
+ end
20
+
21
+ Whatsapp::Schedule::Create.call(
22
+ hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
23
+ name: "Black Friday", description: "Black Friday promo to VIP customers",
24
+ delivery_time: Time.now.to_i + 3600
25
+ ).id # => "9876543210"
26
+
27
+ Whatsapp::Schedule::List.call.map(&:name) # => ["Black Friday"]
28
+ ```
29
+
30
+ | Method | Request | Returns |
31
+ | --- | --- | --- |
32
+ | `List.call(client:, fields:, limit:, after:, before:)` | `GET /{waba_id}/schedules` | `Response::Collection` |
33
+ | `Create.call(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:, client:)` | `POST /{waba_id}/schedules` | `Response::Created` |
34
+
35
+ ## Creating
36
+
37
+ Every field is required:
38
+
39
+ | Field | Type | Meaning |
40
+ | --- | --- | --- |
41
+ | `hsm_id` | String | The approved template to send |
42
+ | `audience_id` | String | The audience to send it to |
43
+ | `waba_cs_id` | String | Meta: "WABA customer status ID (phone number)" |
44
+ | `name` | String | The schedule's name |
45
+ | `description` | String | The schedule's description |
46
+ | `delivery_time` | Integer | When to send, as a Unix timestamp |
47
+
48
+ The examples below share this hash:
49
+
50
+ ```ruby
51
+ attrs = {
52
+ hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
53
+ name: "Black Friday", description: "Black Friday promo to VIP customers",
54
+ delivery_time: 1_764_000_000,
55
+ }
56
+ ```
57
+
58
+ `delivery_time` must be an integer. For a `Time`, pass `time.to_i`:
59
+
60
+ ```ruby
61
+ Whatsapp::Schedule::Create.call(**attrs, delivery_time: Time.utc(2026, 11, 27, 9).to_i)
62
+ ```
63
+
64
+ All six are checked before the request, so a missing one costs nothing:
65
+
66
+ ```ruby
67
+ Whatsapp::Schedule::Create.call(**attrs, name: "")
68
+ # => ActiveModel::ValidationError: Validation failed: Name can't be blank
69
+
70
+ Whatsapp::Schedule::Create.call(**attrs, delivery_time: 1_764_000_000.5)
71
+ # => ActiveModel::ValidationError: Validation failed: Delivery time must be an integer
72
+ ```
73
+
74
+ Nothing beyond that is checked. Meta publishes no length limits and no rule that
75
+ `delivery_time` must be in the future; anything it rejects comes back as a
76
+ `Whatsapp::Schedule::Error`.
77
+
78
+ > `waba_cs_id` is passed through exactly as given. Meta's description doesn't say
79
+ > whether it means a phone number ID or the number itself.
80
+
81
+ To inspect the payload without sending it, build the object yourself:
82
+
83
+ ```ruby
84
+ Whatsapp::Schedule::Create.new(**attrs).serialize
85
+ # => { hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
86
+ # name: "Black Friday", description: "Black Friday promo to VIP customers",
87
+ # delivery_time: 1764000000 }
88
+ ```
89
+
90
+ ## Listing
91
+
92
+ ```ruby
93
+ schedules = Whatsapp::Schedule::List.call
94
+
95
+ schedules.map(&:name) # Collection is Enumerable
96
+ schedules.first.delivery_time # => 1764000000
97
+ schedules.first.status # => "SCHEDULED"
98
+ schedules.first.id # => nil (see below)
99
+ ```
100
+
101
+ ### Ask for `id` explicitly
102
+
103
+ **Meta's default fields leave out `id`.** The defaults are `name`, `description`,
104
+ `delivery_time`, and `status`. Request `id` with `fields:`, either as an Array or a
105
+ comma-joined String:
106
+
107
+ ```ruby
108
+ Whatsapp::Schedule::List.call(fields: Whatsapp::Schedule::List::Fields::ALL)
109
+ Whatsapp::Schedule::List.call(fields: %w[id name status])
110
+ Whatsapp::Schedule::List.call(fields: "id,name,status")
111
+ ```
112
+
113
+ `List::Fields::ALL` is a convenience, not a whitelist. A field Meta adds later works
114
+ without a gem release.
115
+
116
+ ### Paging
117
+
118
+ `limit:` sets the page size (Meta accepts 1–100). `after:` and `before:` take cursors.
119
+ Paging is exposed rather than followed automatically, so fetching every page is your
120
+ choice:
121
+
122
+ ```ruby
123
+ page = Whatsapp::Schedule::List.call(limit: 50)
124
+
125
+ while page.next_cursor
126
+ page = Whatsapp::Schedule::List.call(limit: 50, after: page.next_cursor)
127
+ end
128
+ ```
129
+
130
+ `Collection#paging` exposes both cursors (`paging.before`, `paging.after`). It is the
131
+ same Graph API paging object [template listing](../message_templates/README.md) returns.
132
+
133
+ ### Statuses
134
+
135
+ | Status | Predicate |
136
+ | --- | --- |
137
+ | `SCHEDULED` | `scheduled?` |
138
+ | `SENDING` | `sending?` |
139
+ | `COMPLETED` | `completed?` |
140
+ | `FAILED` | `failed?` |
141
+
142
+ ```ruby
143
+ Whatsapp::Schedule::List.call.select(&:failed?).map(&:name)
144
+ schedule.status == Whatsapp::Schedule::Statuses::SCHEDULED
145
+ ```
146
+
147
+ `status` is the raw string Meta sent and is never validated, so a status Meta adds later
148
+ still comes through. Every predicate just answers `false` for it.
149
+
150
+ ## Response shapes
151
+
152
+ | Class | Holds | From |
153
+ | --- | --- | --- |
154
+ | `Response::Collection` | `data: [Node]`, `paging`, `next_cursor`, `Enumerable` | `List` |
155
+ | `Response::Node` | `id`, `name`, `description`, `delivery_time`, `status` | each element of `List` |
156
+ | `Response::Created` | `id` | `Create` |
157
+
158
+ Every attribute tolerates being absent, since Meta returns only the fields you asked for.
159
+
160
+ ## Errors
161
+
162
+ ```ruby
163
+ Whatsapp::Schedule::List.call
164
+ # => Whatsapp::Schedule::Error: waba_id is required for schedules;
165
+ # set it via Whatsapp.configure or Client.new(waba_id:)
166
+
167
+ # non-2xx response
168
+ # => Whatsapp::Schedule::Error: Failed to create schedule: 403 Forbidden - {...}
169
+ ```
170
+
171
+ Every API failure raises `Whatsapp::Schedule::Error`, never the generic
172
+ `Whatsapp::RequestError`. Local validation raises `ActiveModel::ValidationError` before
173
+ any request. See [../errors.md](../errors.md).
174
+
175
+ ## Targeting a different account
176
+
177
+ ```ruby
178
+ Whatsapp::Schedule::List.call(client: Whatsapp::Client.new(waba_id: "OTHER_WABA_ID"))
179
+ ```
180
+
181
+ ---
182
+
183
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api>
@@ -0,0 +1,139 @@
1
+ # Managing Subscribed Apps
2
+
3
+ Before your app receives any [webhook](../webhooks/README.md) notifications for a
4
+ WhatsApp Business Account, it has to be **subscribed** to it.
5
+ `Whatsapp::SubscribedApp` wraps the `subscribed_apps` edge — the switch that turns
6
+ webhook delivery on or off.
7
+
8
+ > **Two halves of one thing.** This module makes Meta start sending;
9
+ > [`Whatsapp::Webhook`](../webhooks/README.md) deserializes what arrives. Neither is
10
+ > useful without the other.
11
+
12
+ Addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`) and needs the
13
+ `whatsapp_business_management` permission — same as
14
+ [template management](../message_templates/README.md).
15
+
16
+ ```ruby
17
+ Whatsapp.configure do |config|
18
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
19
+ config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
20
+ end
21
+
22
+ Whatsapp::SubscribedApp::Subscribe.call # start webhook delivery
23
+ Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
24
+ Whatsapp::SubscribedApp::Unsubscribe.call # stop it
25
+ ```
26
+
27
+ | Method | Request | Returns |
28
+ | --- | --- | --- |
29
+ | `List.call(client:, fields:)` | `GET /{waba_id}/subscribed_apps` | `Response::Collection` |
30
+ | `Subscribe.call(client:, override_callback_uri:, verify_token:)` | `POST /{waba_id}/subscribed_apps` | `Response::Subscription` |
31
+ | `Unsubscribe.call(client:)` | `DELETE /{waba_id}/subscribed_apps` | `Response::Unsubscription` |
32
+
33
+ Three independent actions, so three classes rather than one class with three methods —
34
+ unlike [templates](../message_templates/README.md), subscribing and listing share no
35
+ identity or validation rules that would justify combining them.
36
+
37
+ ## Subscribing
38
+
39
+ ```ruby
40
+ result = Whatsapp::SubscribedApp::Subscribe.call
41
+
42
+ result.success # => true
43
+ result.map(&:name) # => ["My App"] — every app now subscribed, Meta's own echo
44
+ ```
45
+
46
+ `Response::Subscription` is `Enumerable` over the same `Response::App` objects `List`
47
+ returns, so you can check the result without a second request.
48
+
49
+ ### Per-WABA callback overrides
50
+
51
+ Tech Providers routing several WABAs' notifications to different callback URLs pass an
52
+ override instead of relying on the single callback URL configured on the app itself:
53
+
54
+ ```ruby
55
+ Whatsapp::SubscribedApp::Subscribe.call(
56
+ override_callback_uri: "https://example.com/webhooks/acme_corp",
57
+ verify_token: "a-per-account-secret"
58
+ )
59
+ ```
60
+
61
+ Both are optional and compacted out of the request body when absent. The
62
+ `verify_token` you pass here is the one Meta will send in the
63
+ [GET handshake](../webhooks/README.md#verification-the-get-handshake) for that WABA —
64
+ pass the matching value to `Verification.call(verify_token:)`.
65
+
66
+ > No client-side format check is applied to `override_callback_uri`. Meta enforces it
67
+ > server-side, and this gem validates only what Meta documents as a client-side rule.
68
+
69
+ ## Listing
70
+
71
+ ```ruby
72
+ apps = Whatsapp::SubscribedApp::List.call
73
+
74
+ apps.map(&:name) # Collection is Enumerable
75
+ apps.first.id # => "123456789"
76
+ apps.first.link # => "https://www.facebook.com/games/?app_id=..."
77
+ apps.first.override_callback_uri # => nil, or the per-WABA override
78
+ ```
79
+
80
+ Restrict what comes back with `fields:` — an Array or a String, comma-joined for you:
81
+
82
+ ```ruby
83
+ Whatsapp::SubscribedApp::List.call(fields: %w[id name])
84
+ Whatsapp::SubscribedApp::List.call(fields: "id,name")
85
+ ```
86
+
87
+ `Response::App` flattens Meta's `whatsapp_business_api_data` wrapper, so `id`, `name`,
88
+ and `link` sit directly on the object rather than a nested hash.
89
+
90
+ > No pagination — Meta documents none for this edge, and an account typically has very
91
+ > few subscribed apps.
92
+
93
+ ## Unsubscribing
94
+
95
+ ```ruby
96
+ Whatsapp::SubscribedApp::Unsubscribe.call.success # => true
97
+ ```
98
+
99
+ Stops **all** webhook deliveries for this WABA immediately.
100
+
101
+ ## Response shapes
102
+
103
+ Three actions, three genuinely different shapes — hence three classes:
104
+
105
+ | Class | Holds | From |
106
+ | --- | --- | --- |
107
+ | `Response::Collection` | `data: [App]`, `Enumerable` | `List` |
108
+ | `Response::Subscription` | `success`, `data: [App]`, `Enumerable` | `Subscribe` |
109
+ | `Response::Unsubscription` | `success` | `Unsubscribe` |
110
+
111
+ `Response::App` — `id`, `name`, `link`, `override_callback_uri` — is composed by both
112
+ `Collection` and `Subscription` rather than duplicated.
113
+
114
+ `success` is a strict `response["success"] == true`, so it is never a truthy string
115
+ you have to second-guess.
116
+
117
+ ## Errors
118
+
119
+ ```ruby
120
+ Whatsapp::SubscribedApp::Subscribe.call
121
+ # => Whatsapp::SubscribedApp::Error: waba_id is required for subscribed apps;
122
+ # set it via Whatsapp.configure or Client.new(waba_id:)
123
+
124
+ # non-2xx response
125
+ # => Whatsapp::SubscribedApp::Error: Failed to subscribe app: 400 Bad Request - {...}
126
+ ```
127
+
128
+ Every action raises `Whatsapp::SubscribedApp::Error` — never the generic
129
+ `Whatsapp::RequestError`. See [../errors.md](../errors.md).
130
+
131
+ ## Targeting a different account
132
+
133
+ ```ruby
134
+ Whatsapp::SubscribedApp::List.call(client: Whatsapp::Client.new(waba_id: "OTHER_WABA_ID"))
135
+ ```
136
+
137
+ ---
138
+
139
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/subscribed-apps-api>