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,103 @@
1
+ # Carousel Templates
2
+
3
+ Two to ten swipeable cards under a shared body message — a product row, a set of
4
+ listings, a group of offers.
5
+
6
+ > **MARKETING only.** A carousel on a `UTILITY` or `AUTHENTICATION` template is
7
+ > rejected locally:
8
+ >
9
+ > ```ruby
10
+ > # => ActiveModel::ValidationError: Components CAROUSEL is only supported on
11
+ > # MARKETING templates, not UTILITY
12
+ > ```
13
+
14
+ ```ruby
15
+ card = {
16
+ header: { format: "IMAGE", header_handle: "4::aW..." },
17
+ body: { text: "Rare {{1}} in stock!", example: ["Tulips"] },
18
+ buttons: [{ type: :quick_reply, text: "More like this" }],
19
+ }
20
+
21
+ Whatsapp::MessageTemplates.new.create(
22
+ name: "summer_carousel", language: "en_US", category: "MARKETING",
23
+ components: [
24
+ { type: :body, text: "Summer is here, {{1}}!", example: ["Pablo"] },
25
+ { type: :carousel, cards: [card, card] },
26
+ ]
27
+ )
28
+ ```
29
+
30
+ The template still needs its own top-level `BODY` — the carousel sits *below* it.
31
+
32
+ ## Card structure
33
+
34
+ | Field | Required | Rules |
35
+ | --- | --- | --- |
36
+ | `header` | yes | `format` must be `IMAGE` or `VIDEO` |
37
+ | `body` | no | Same rules as a normal body |
38
+ | `buttons` | no | **0–2** buttons per card |
39
+
40
+ > **All cards must be structurally identical.** Same header format, same button types
41
+ > in the same order, and body text on either every card or none. The gem computes a
42
+ > signature per card and compares them:
43
+ >
44
+ > ```ruby
45
+ > # => ActiveModel::ValidationError: Cards must all have an identical structure:
46
+ > # same header format, same button types, and body text on either every card or none
47
+ > ```
48
+
49
+ Meta says "up to 2" buttons and never states a minimum, so **zero buttons is valid**.
50
+
51
+ ## Serialized payload
52
+
53
+ Each card serializes to a bare `{components: [...]}` — note there is **no
54
+ `card_index`** here. That is send-side only:
55
+
56
+ ```ruby
57
+ Whatsapp::MessageTemplates::Template.new(
58
+ name: "summer_carousel", language: "en_US", category: "marketing",
59
+ components: [
60
+ { type: :body, text: "Summer is here, {{1}}!", example: ["Pablo"] },
61
+ { type: :carousel, cards: [card, card] },
62
+ ]
63
+ ).serialize
64
+ # => {
65
+ # name: "summer_carousel", language: "en_US", category: "MARKETING",
66
+ # parameter_format: "POSITIONAL",
67
+ # components: [
68
+ # { type: "BODY", text: "Summer is here, {{1}}!", example: { body_text: [["Pablo"]] } },
69
+ # { type: "CAROUSEL", cards: [
70
+ # { components: [
71
+ # { type: "HEADER", format: "IMAGE", example: { header_handle: ["4::aW..."] } },
72
+ # { type: "BODY", text: "Rare {{1}} in stock!", example: { body_text: [["Tulips"]] } },
73
+ # { type: "BUTTONS", buttons: [{ type: "QUICK_REPLY", text: "More like this" }] },
74
+ # ] },
75
+ # { components: [...] },
76
+ # ] },
77
+ # ]
78
+ # }
79
+ ```
80
+
81
+ ## Media handles
82
+
83
+ `header_handle` is **not** a media ID from [`Media#upload`](../media/README.md). It
84
+ comes from Meta's Resumable Upload API, which this gem does not wrap — see
85
+ [README.md § Not wrapped](README.md#not-wrapped).
86
+
87
+ ## Validation errors
88
+
89
+ ```ruby
90
+ { type: :carousel, cards: [card] }
91
+ # => ActiveModel::ValidationError: Cards is too short (minimum is 2 characters)
92
+
93
+ { type: :carousel, cards: [card_with_image_header, card_with_video_header] }
94
+ # => ActiveModel::ValidationError: Cards must all have an identical structure: ...
95
+
96
+ { header: { format: "TEXT", text: "Hi" }, body: ..., buttons: [...] }
97
+ # => ActiveModel::ValidationError: Header format must be IMAGE or VIDEO
98
+ # for a carousel card, got TEXT
99
+ ```
100
+
101
+ ---
102
+
103
+ **Meta docs:** <https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/carousel-templates>
@@ -0,0 +1,215 @@
1
+ # Components and Buttons Reference
2
+
3
+ The complete registry of what a template can be built from. A component validates
4
+ itself; `ComponentSet` validates how components relate to each other.
5
+
6
+ ## Component types
7
+
8
+ Resolved through the frozen `Component::TYPES` registry — never `const_get` on caller
9
+ input.
10
+
11
+ | `type:` | Wire type | Purpose |
12
+ | --- | --- | --- |
13
+ | `:header` | `HEADER` | Text, media, or location above the body |
14
+ | `:body` | `BODY` | **Required, exactly one** |
15
+ | `:footer` | `FOOTER` | Small print below the body |
16
+ | `:buttons` | `BUTTONS` | One component holding 1–10 buttons |
17
+ | `:carousel` | `CAROUSEL` | [2–10 identical cards](carousel.md) |
18
+ | `:limited_time_offer` | `LIMITED_TIME_OFFER` | [Countdown + coupon](limited_time_offer.md) |
19
+
20
+ An unknown type raises before any request:
21
+
22
+ ```ruby
23
+ # => Whatsapp::MessageTemplates::TemplateError: Unknown component type: :greeting.
24
+ # Known types: header, body, footer, buttons, carousel, limited_time_offer
25
+ ```
26
+
27
+ ### Header
28
+
29
+ | `format` | Fields | Rules |
30
+ | --- | --- | --- |
31
+ | `TEXT` | `text`, `example` | ≤ **60** chars, **at most one** placeholder, no Markdown, no `header_handle` |
32
+ | `IMAGE` `VIDEO` `DOCUMENT` `GIF` | `header_handle` | No `text` |
33
+ | `LOCATION` | — | Nothing else; coordinates are supplied when sending |
34
+
35
+ Max **one** header per template.
36
+
37
+ The Markdown check rejects `*` `_` `~` `` ` `` in the literal text but **ignores
38
+ placeholder contents** — named placeholders are lowercase-and-underscores by
39
+ definition, so scanning raw text would reject `{{sale_start_date}}` for its underscore
40
+ and make named parameters unusable in a text header.
41
+
42
+ ```ruby
43
+ { type: :header, format: "TEXT", text: "Order {{1}} confirmed", example: ["#1234"] }
44
+ { type: :header, format: "IMAGE", header_handle: "4::aW..." }
45
+ { type: :header, format: "LOCATION" }
46
+ ```
47
+
48
+ A `LOCATION` header requires the category to be `UTILITY` or `MARKETING`.
49
+
50
+ ### Body
51
+
52
+ **Required, exactly one per template.** Two mutually exclusive shapes:
53
+
54
+ | Shape | Fields | Rules |
55
+ | --- | --- | --- |
56
+ | Text | `text`, `example` | ≤ **1024** chars, any number of placeholders, example count must match |
57
+ | Authentication | `add_security_recommendation` | See [authentication.md](authentication.md) |
58
+
59
+ ```ruby
60
+ { type: :body, text: "Thank you, {{1}}!", example: ["Pablo"] }
61
+ { type: :body, add_security_recommendation: true }
62
+ ```
63
+
64
+ Example count is checked against the **unique** placeholder count, so a repeated
65
+ `{{1}}` needs one example, not two.
66
+
67
+ ### Footer
68
+
69
+ Max **one**. Two mutually exclusive shapes:
70
+
71
+ | Shape | Fields | Rules |
72
+ | --- | --- | --- |
73
+ | Text | `text` | ≤ **60** chars, **no placeholders at all** |
74
+ | Authentication | `code_expiration_minutes` | Integer in **1..90** |
75
+
76
+ ```ruby
77
+ { type: :footer, text: "Thanks for shopping with us" }
78
+ { type: :footer, code_expiration_minutes: 15 }
79
+ ```
80
+
81
+ Forbidden entirely alongside a [limited-time offer](limited_time_offer.md).
82
+
83
+ ### Buttons
84
+
85
+ Max **one** `BUTTONS` component, holding **1–10** buttons.
86
+
87
+ | Button type | Cap per template |
88
+ | --- | --- |
89
+ | `QUICK_REPLY` | 10 |
90
+ | `URL` | 2 |
91
+ | `PHONE_NUMBER` | 1 |
92
+ | `COPY_CODE` | 1 |
93
+ | `OTP` | uncapped — Meta publishes no limit |
94
+
95
+ > **Quick replies must be contiguous.** They may sit at the start or the end, but not
96
+ > with another type between them. `[QR, QR, URL]` and `[URL, QR, QR]` are fine;
97
+ > `[QR, URL, QR]` raises `quick reply buttons must be grouped together, without other
98
+ > types between them`.
99
+
100
+ ## Button types
101
+
102
+ Resolved through the frozen `Button::TYPES` registry.
103
+
104
+ | `type:` | Wire type | Fields |
105
+ | --- | --- | --- |
106
+ | `:quick_reply` | `QUICK_REPLY` | `text` ≤ **25** |
107
+ | `:url` | `URL` | `text` ≤ 25, `url` ≤ **2000**, `example` |
108
+ | `:phone_number` | `PHONE_NUMBER` | `text` ≤ 25, `phone_number` ≤ **20** |
109
+ | `:copy_code` | `COPY_CODE` | `example` ≤ **20** (a bare String), **no `text`** |
110
+ | `:otp` | `OTP` | `otp_type`, `supported_apps`, `zero_tap_terms_accepted` |
111
+ | `:request_contact_info` | `REQUEST_CONTACT_INFO` | **no fields** — `text` optional and only ever `"Share Contact Info"` |
112
+
113
+ ```ruby
114
+ { type: :quick_reply, text: "More like this" }
115
+ { type: :url, text: "Track order", url: "https://example.com/o/{{1}}", example: "1234" }
116
+ { type: :phone_number, text: "Call", phone_number: "15550051310" }
117
+ { type: :copy_code, example: "SPRING25" }
118
+ { type: :otp, otp_type: "COPY_CODE" }
119
+ { type: :request_contact_info }
120
+ ```
121
+
122
+ ### The URL button's single trailing variable
123
+
124
+ A URL button may contain **at most one variable, and it must be at the end**:
125
+
126
+ ```ruby
127
+ { type: :url, text: "Track", url: "https://example.com/{{1}}/details" }
128
+ # => ActiveModel::ValidationError: Url variable must be at the end of the URL
129
+ ```
130
+
131
+ > The URL variable is counted by **occurrence**, not by unique name, so
132
+ > `.../{{1}}/details/{{1}}` is rejected: it ends in a placeholder and names one
133
+ > parameter, but the leading one still sits mid-URL. Everywhere else — body examples
134
+ > above all — the unique count is the correct one, since Meta treats a repeated
135
+ > `{{1}}` as a single parameter needing a single example.
136
+
137
+ Its `example` is a **flat array on the button**, unlike header and body examples,
138
+ which are nested under an `example` key. A bare String is wrapped for you.
139
+
140
+ ### Buttons Meta supplies the label for
141
+
142
+ `COPY_CODE` and `OTP` reject `text` (and `autofill_text`) — Meta writes and localises
143
+ those labels:
144
+
145
+ ```ruby
146
+ { type: :copy_code, example: "SPRING25", text: "Copy" }
147
+ # => ActiveModel::ValidationError: Text cannot be set on a copy-code button;
148
+ # Meta supplies the label
149
+ ```
150
+
151
+ ### The request-contact-info button
152
+
153
+ Asks the recipient to share their phone number, which they hand over with one tap. It
154
+ takes nothing:
155
+
156
+ ```ruby
157
+ { type: :buttons, buttons: [{ type: :request_contact_info }] }
158
+ ```
159
+
160
+ This is the counterpart to Meta's usernames rollout: once a user adopts a username their
161
+ phone number may stop reaching you, leaving only their
162
+ [BSUID](../webhooks/messages.md#business-scoped-user-ids-bsuid). It is also the only way
163
+ to reach such a user with a one-tap, zero-tap, or copy-code
164
+ [authentication template](authentication.md), since those require a phone number.
165
+
166
+ Three things about it:
167
+
168
+ - **The label is not customizable.** Meta renders it in the recipient's own app language.
169
+ A `text` field is accepted but must be exactly `"Share Contact Info"`, so the simplest
170
+ correct thing is to omit it.
171
+ - **`UTILITY` and `MARKETING` only** — a cross-component rule, listed below.
172
+ - **Nothing to fill in when sending.** Unlike a URL or copy-code button, it takes no
173
+ parameter values, so a template carrying one needs no `button` component in the
174
+ [send payload](../messages/template.md) at all.
175
+
176
+ A tap arrives as an inbound [`contacts` message](../webhooks/messages.md#contacts) whose
177
+ `origin` is `"contact_request"`. There is also an
178
+ [interactive-message form](../messages/interactive.md) of the same button, for use inside
179
+ the 24-hour service window.
180
+
181
+ No per-type cap is enforced: Meta publishes none, and inventing one would reject valid
182
+ payloads — the same reason `OTP` is uncapped. The overall 10-button limit still applies.
183
+
184
+ ## Cross-component rules
185
+
186
+ Owned by `ComponentSet`, because it is the only object that can see the siblings:
187
+
188
+ - **Exactly one** `BODY`.
189
+ - **At most one** each of `HEADER`, `FOOTER`, `BUTTONS`, `CAROUSEL`,
190
+ `LIMITED_TIME_OFFER`.
191
+ - `CAROUSEL` or `LIMITED_TIME_OFFER` → category must be `MARKETING`.
192
+ - A `LOCATION` header → category must be `UTILITY` or `MARKETING`.
193
+ - A `REQUEST_CONTACT_INFO` button → category must be `UTILITY` or `MARKETING`.
194
+ - `LIMITED_TIME_OFFER` present → `FOOTER` forbidden, `BODY` ≤ **600**, header
195
+ `IMAGE`/`VIDEO` only, copy-code `example` ≤ **15** and that button must be **first**.
196
+
197
+ The category-dependent rules are **skipped when `category` is nil** — which is exactly
198
+ what `#update` needs, since editing replaces components without re-supplying a
199
+ category.
200
+
201
+ ## Not registered
202
+
203
+ Only types with a published Meta field reference get a class. Named in Meta's Graph
204
+ enum but deliberately absent:
205
+
206
+ - **Components:** `GREETING`, `ALBUM`, `CALL_PERMISSION_REQUEST`,
207
+ `TAP_TARGET_CONFIGURATION`, `ATTACHMENT`.
208
+ - **Buttons:** `FLOW`, `MPM`, `CATALOG`, `VOICE_CALL`, `VIDEO_CALL`, `POSTBACK`,
209
+ `BOOKING_STATUS`, `PAYMENT_REQUEST`. Most of these *are*
210
+ accepted by [`LibraryTemplate`](library.md), because Meta documents them by name in
211
+ that context.
212
+
213
+ ---
214
+
215
+ **Meta docs:** <https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/>
@@ -0,0 +1,98 @@
1
+ # Library Templates
2
+
3
+ Meta maintains a library of pre-written, pre-approved templates for common cases —
4
+ delivery updates, appointment reminders, payment confirmations. Cloning one usually
5
+ comes back **`APPROVED` immediately**, skipping the 24-hour review entirely.
6
+
7
+ ```ruby
8
+ Whatsapp::MessageTemplates.new.create_from_library(
9
+ name: "my_delivery_update", language: "en_US", category: "UTILITY",
10
+ library_template_name: "delivery_update_1",
11
+ library_template_button_inputs: [
12
+ { type: "URL", url: { base_url: "https://example.com/{{1}}",
13
+ url_suffix_example: "https://example.com/order_update" } },
14
+ ]
15
+ )
16
+ # => #<Response::Created id="1" status="APPROVED" category="UTILITY">
17
+ ```
18
+
19
+ ## Fields
20
+
21
+ | Field | Required | Rules |
22
+ | --- | --- | --- |
23
+ | `name` | yes | Your name for the clone. `/\A[a-z0-9_]+\z/`, max 512 |
24
+ | `language` | yes | Validated against the supported list |
25
+ | `category` | yes | `UTILITY` \| `MARKETING` \| `AUTHENTICATION` |
26
+ | `library_template_name` | yes | Meta's name for the source template |
27
+ | `library_template_body_inputs` | no | Body toggles — see below |
28
+ | `library_template_button_inputs` | no | Button configuration — see below |
29
+
30
+ > **No `components:` at all.** The wording is Meta's; you only supply the identity and
31
+ > the few configurable inputs. That is the whole point of the library.
32
+
33
+ ## Body inputs
34
+
35
+ | Field | Type |
36
+ | --- | --- |
37
+ | `add_contact_number` | Boolean |
38
+ | `add_learn_more_link` | Boolean |
39
+ | `add_security_recommendation` | Boolean |
40
+ | `add_track_package_link` | Boolean |
41
+ | `code_expiration_minutes` | Integer, **1–90** |
42
+
43
+ ```ruby
44
+ library_template_body_inputs: {
45
+ add_contact_number: true,
46
+ add_track_package_link: true,
47
+ }
48
+ ```
49
+
50
+ ## Button inputs
51
+
52
+ `type` is one of `QUICK_REPLY` `URL` `PHONE_NUMBER` `OTP` `MPM` `CATALOG` `FLOW`
53
+ `VOICE_CALL` `APP` — deliberately a **wider set** than the button types available to
54
+ hand-built templates, because Meta documents these by name in the library context.
55
+
56
+ | `type` | Required extra |
57
+ | --- | --- |
58
+ | `URL` | `url: { base_url:, url_suffix_example: }` |
59
+ | `PHONE_NUMBER` | `phone_number:` |
60
+ | `APP` | `supported_apps:` |
61
+ | `OTP` | `otp_type:`, plus `zero_tap_terms_accepted:` for zero-tap |
62
+
63
+ ```ruby
64
+ library_template_button_inputs: [
65
+ { type: "URL", url: { base_url: "https://example.com/{{1}}",
66
+ url_suffix_example: "https://example.com/order_update" } },
67
+ { type: "PHONE_NUMBER", phone_number: "15550051310" },
68
+ ]
69
+ ```
70
+
71
+ ## Validation errors
72
+
73
+ ```ruby
74
+ { type: "URL", url: {} }
75
+ # => ActiveModel::ValidationError: Url requires a base_url for a URL button input
76
+
77
+ { type: "PHONE_NUMBER" }
78
+ # => ActiveModel::ValidationError: Phone number can't be blank for a PHONE_NUMBER button input
79
+
80
+ { type: "APP" }
81
+ # => ActiveModel::ValidationError: Supported apps can't be blank for an APP button input
82
+ ```
83
+
84
+ ## Unverified detail
85
+
86
+ `library_template_button_inputs` is emitted as a **real JSON array**. Meta's guide
87
+ shows a JSON-*stringified* array while the Graph reference types it
88
+ `array<JSON object>`. This has not been settled against a live WABA — if Meta rejects
89
+ the array form, it is a one-line change in `LibraryTemplate#serialize`.
90
+
91
+ ## Not wrapped
92
+
93
+ Browsing the library itself (`message_template_library`) is not wrapped — find the
94
+ `library_template_name` you want in WhatsApp Manager or Meta's docs.
95
+
96
+ ---
97
+
98
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-library>
@@ -0,0 +1,94 @@
1
+ # Limited-Time Offer Templates
2
+
3
+ Adds a live countdown and a copyable coupon code to a marketing message. The countdown
4
+ is rendered by WhatsApp itself, so the urgency stays accurate no matter when the
5
+ recipient opens the thread.
6
+
7
+ > **MARKETING only**, and the presence of a `LIMITED_TIME_OFFER` component tightens
8
+ > four other rules at once. See [The knock-on rules](#the-knock-on-rules).
9
+
10
+ ```ruby
11
+ Whatsapp::MessageTemplates.new.create(
12
+ name: "spring_offer", language: "en_US", category: "MARKETING",
13
+ components: [
14
+ { type: :header, format: "IMAGE", header_handle: "4::aW..." },
15
+ { type: :limited_time_offer, text: "Expiring offer!", has_expiration: true },
16
+ { type: :body, text: "Good news, {{1}}! Use code {{2}} for 25% off.",
17
+ example: ["Pablo", "SPRING25"] },
18
+ { type: :buttons, buttons: [
19
+ { type: :copy_code, example: "SPRING25" },
20
+ { type: :url, text: "Book now!", url: "https://example.com/o?c={{1}}", example: "n3mtql" },
21
+ ] },
22
+ ]
23
+ )
24
+ ```
25
+
26
+ ## The component
27
+
28
+ | Field | Required | Rules |
29
+ | --- | --- | --- |
30
+ | `text` | yes | Max **16** characters — the offer label |
31
+ | `has_expiration` | no | `true` shows the countdown |
32
+
33
+ It serializes nested, unlike every other component:
34
+
35
+ ```ruby
36
+ { type: "LIMITED_TIME_OFFER", limited_time_offer: { text: "Expiring offer!", has_expiration: true } }
37
+ ```
38
+
39
+ ## The knock-on rules
40
+
41
+ Adding a limited-time offer changes what the rest of the template may contain. These
42
+ are enforced by `ComponentSet`, which is the only object that can see all the
43
+ components at once:
44
+
45
+ | Rule | Error if violated |
46
+ | --- | --- |
47
+ | **No `FOOTER`** at all | `a FOOTER is not allowed in a LIMITED_TIME_OFFER template` |
48
+ | `BODY` drops to **600** chars (from 1024) | `the BODY of a LIMITED_TIME_OFFER template is limited to 600 characters` |
49
+ | Header must be **`IMAGE` or `VIDEO`** | `the header must be IMAGE or VIDEO in a LIMITED_TIME_OFFER template, got TEXT` |
50
+ | Copy-code button must be **first** | `the copy-code button must be first in a LIMITED_TIME_OFFER template` |
51
+ | Copy-code `example` ≤ **15** chars (from 20) | `the copy-code example is limited to 15 characters in a LIMITED_TIME_OFFER template` |
52
+
53
+ ## Serialized payload
54
+
55
+ ```ruby
56
+ Whatsapp::MessageTemplates::Template.new(
57
+ name: "limited_time_offer_caribbean_pkg_2023", language: "en_US", category: "marketing",
58
+ components: [
59
+ { type: :header, format: "image", header_handle: "4::aW" },
60
+ { type: :limited_time_offer, text: "Expiring offer!", has_expiration: true },
61
+ { type: :body, text: "Good news, {{1}}! Use code {{2}} to get 25% off!",
62
+ example: ["Pablo", "CARIBE25"] },
63
+ { type: :buttons, buttons: [
64
+ { type: :copy_code, example: "CARIBE25" },
65
+ { type: :url, text: "Book now!", url: "https://x.test/offers?code={{1}}", example: "n3mtql" },
66
+ ] },
67
+ ]
68
+ ).serialize
69
+ # => {
70
+ # name: "limited_time_offer_caribbean_pkg_2023", language: "en_US", category: "MARKETING",
71
+ # parameter_format: "POSITIONAL",
72
+ # components: [
73
+ # { type: "HEADER", format: "IMAGE", example: { header_handle: ["4::aW"] } },
74
+ # { type: "LIMITED_TIME_OFFER",
75
+ # limited_time_offer: { text: "Expiring offer!", has_expiration: true } },
76
+ # { type: "BODY", text: "Good news, {{1}}! Use code {{2}} to get 25% off!",
77
+ # example: { body_text: [["Pablo", "CARIBE25"]] } },
78
+ # { type: "BUTTONS", buttons: [
79
+ # { type: "COPY_CODE", example: "CARIBE25" },
80
+ # { type: "URL", text: "Book now!", url: "https://x.test/offers?code={{1}}",
81
+ # example: ["n3mtql"] }] },
82
+ # ]
83
+ # }
84
+ ```
85
+
86
+ ## Media handles
87
+
88
+ `header_handle` comes from Meta's Resumable Upload API, not
89
+ [`Media#upload`](../media/README.md) — see
90
+ [README.md § Not wrapped](README.md#not-wrapped).
91
+
92
+ ---
93
+
94
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/marketing-templates/limited-time-offer-templates>
@@ -0,0 +1,173 @@
1
+ # Template Responses
2
+
3
+ Four endpoints, four response shapes — so four classes rather than one that is mostly
4
+ `nil`.
5
+
6
+ | Method | Returns |
7
+ | --- | --- |
8
+ | `create`, `create_from_library`, `upsert` | [`Response::Created`](#created) |
9
+ | `find` | [`Response::Node`](#node) |
10
+ | `list` | [`Response::Collection`](#collection) |
11
+ | `update`, `delete` | plain `Boolean` |
12
+
13
+ ## Created
14
+
15
+ `{id, status, category}` — what you get back from a submission.
16
+
17
+ ```ruby
18
+ created = templates.create(name: "order_confirmation", language: "en_US",
19
+ category: "UTILITY", components: [...])
20
+
21
+ created.id # => "1259544702043867"
22
+ created.status # => "PENDING"
23
+ created.category # => "UTILITY"
24
+
25
+ created.pending? # => true
26
+ created.approved? # => false
27
+ created.rejected? # => false
28
+ ```
29
+
30
+ A [library template](library.md) usually comes back `APPROVED` immediately; everything
31
+ else starts `PENDING` and is reviewed asynchronously.
32
+
33
+ ## Node
34
+
35
+ The full template object, from `find`.
36
+
37
+ ```ruby
38
+ template = templates.find(template_id: "564750795574598")
39
+ ```
40
+
41
+ | Accessor | Example |
42
+ | --- | --- |
43
+ | `id` | `"564750795574598"` |
44
+ | `name` | `"order_confirmation"` |
45
+ | `status` | `"APPROVED"` |
46
+ | `category` | `"UTILITY"` |
47
+ | `language` | `"en_US"` |
48
+ | `components` | **raw hashes** — see below |
49
+ | `parameter_format` | `"POSITIONAL"` |
50
+ | `sub_category` | `"ORDER_DETAILS"` |
51
+ | `rejected_reason` | `"NONE"` |
52
+ | `quality_score` | `#<QualityScore score="GREEN" date=1700000000 reasons=[]>` |
53
+ | `previous_category` | `"MARKETING"` |
54
+ | `correct_category` | `"UTILITY"` |
55
+ | `message_send_ttl_seconds` | `3600` |
56
+ | `cta_url_link_tracking_opted_out` | `false` |
57
+ | `library_template_name` | `"delivery_update_1"` |
58
+
59
+ Predicates: `#approved?`, `#rejected?`, `#paused?`, and `#editable?` (true for
60
+ `APPROVED`, `REJECTED`, and `PAUSED`).
61
+
62
+ ```ruby
63
+ template.status # => "APPROVED"
64
+ template.editable? # => true
65
+ ```
66
+
67
+ > **`#components` stays raw hashes on purpose.** It is not rebuilt into the
68
+ > `Component` classes. Those validate a payload being *written*; this is Meta's echo of
69
+ > one, carrying fields the write side does not model. Running write-side rules over
70
+ > data we did not author would raise on perfectly valid responses. Read it as data;
71
+ > build a new `Template` when you want to change something.
72
+
73
+ ```ruby
74
+ template.components
75
+ # => [{ "type" => "BODY", "text" => "Thank you, {{1}}!",
76
+ # "example" => { "body_text" => [["Pablo"]] } }]
77
+ ```
78
+
79
+ ## Collection
80
+
81
+ `{data, paging, summary}` from `list`. It is `Enumerable`, so it behaves like the array
82
+ of `Node`s it wraps.
83
+
84
+ ```ruby
85
+ page = templates.list(status: %w[APPROVED], limit: 25)
86
+
87
+ page.map(&:name) # => ["order_confirmation", ...]
88
+ page.select(&:approved?) # Enumerable
89
+ page.next_cursor # => "AFTER" — nil on the last page
90
+ page.remaining # => 249
91
+ ```
92
+
93
+ | Accessor | Holds |
94
+ | --- | --- |
95
+ | `data` | `Array<Response::Node>` |
96
+ | `paging` | `Response::Paging` — `before`, `after` (flattened from `paging.cursors`) |
97
+ | `summary` | `Response::Summary` |
98
+
99
+ `#next_cursor` is `paging&.after`; `#remaining` is `summary&.remaining`.
100
+
101
+ ### Summary
102
+
103
+ | Accessor | Meaning |
104
+ | --- | --- |
105
+ | `total_count` | Templates matching the filter |
106
+ | `message_template_count` | Templates on the account |
107
+ | `message_template_limit` | The account's cap |
108
+ | `are_translations_complete` | Boolean |
109
+ | `remaining` | `limit - count`, or `nil` when either is missing |
110
+
111
+ ```ruby
112
+ page.summary.message_template_count # => 12
113
+ page.summary.message_template_limit # => 250
114
+ page.remaining # => 238
115
+ ```
116
+
117
+ ### Paging
118
+
119
+ Manual — there is no auto-pagination:
120
+
121
+ ```ruby
122
+ page = templates.list(limit: 25)
123
+ all = page.to_a
124
+
125
+ while (cursor = page.next_cursor)
126
+ page = templates.list(limit: 25, after: cursor)
127
+ all.concat(page.to_a)
128
+ end
129
+ ```
130
+
131
+ ### QualityScore
132
+
133
+ ```ruby
134
+ template.quality_score.score # => "GREEN" — GREEN | YELLOW | RED | UNKNOWN
135
+ template.quality_score.date # => 1_700_000_000
136
+ template.quality_score.reasons # => []
137
+ ```
138
+
139
+ Best-effort schema on `reasons` — Meta publishes no sub-fields for it.
140
+
141
+ ## Booleans
142
+
143
+ `update` and `delete` return a plain `true`/`false` derived from `{"success": true}`:
144
+
145
+ ```ruby
146
+ templates.update(template_id: "564750795574598", category: "MARKETING") # => true
147
+ templates.delete(name: "order_confirmation") # => true
148
+ ```
149
+
150
+ A non-2xx response raises `Whatsapp::MessageTemplates::TemplateError` instead of
151
+ returning `false`, so `false` genuinely means "Meta said no", not "the request failed".
152
+
153
+ ## Statuses
154
+
155
+ | Status | Meaning |
156
+ | --- | --- |
157
+ | `PENDING` | Under review |
158
+ | `APPROVED` | Sendable |
159
+ | `REJECTED` | Refused — see `rejected_reason` |
160
+ | `PAUSED` | Temporarily halted for quality |
161
+ | `DISABLED` | Permanently disabled; cannot be deleted |
162
+ | `IN_APPEAL` | Rejection under appeal |
163
+ | `PENDING_DELETION` | Deletion queued |
164
+ | `DELETED` | Gone |
165
+ | `LIMIT_EXCEEDED` | Account template cap hit |
166
+ | `ARCHIVED` | Archived |
167
+
168
+ `rejected_reason` is one of `NONE`, `ABUSIVE_CONTENT`, `INVALID_FORMAT`,
169
+ `PROMOTIONAL`, `TAG_CONTENT_MISMATCH`, `SCAM`.
170
+
171
+ ---
172
+
173
+ **Meta docs:** <https://developers.facebook.com/docs/graph-api/reference/whats-app-business-hsm/>