ruby-whatsapp 0.4.0 → 0.4.2

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +276 -634
  4. data/docs/README.md +73 -0
  5. data/docs/business_phone_number/README.md +232 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/profile.md +220 -0
  8. data/docs/configuration.md +172 -0
  9. data/docs/errors.md +208 -0
  10. data/docs/media/README.md +161 -0
  11. data/docs/message_templates/README.md +206 -0
  12. data/docs/message_templates/authentication.md +107 -0
  13. data/docs/message_templates/carousel.md +103 -0
  14. data/docs/message_templates/components.md +179 -0
  15. data/docs/message_templates/library.md +98 -0
  16. data/docs/message_templates/limited_time_offer.md +94 -0
  17. data/docs/message_templates/responses.md +173 -0
  18. data/docs/message_templates/standard.md +138 -0
  19. data/docs/messages/README.md +135 -0
  20. data/docs/messages/address.md +86 -0
  21. data/docs/messages/audio.md +43 -0
  22. data/docs/messages/contacts.md +129 -0
  23. data/docs/messages/document.md +49 -0
  24. data/docs/messages/image.md +59 -0
  25. data/docs/messages/interactive.md +281 -0
  26. data/docs/messages/location.md +51 -0
  27. data/docs/messages/location_request.md +59 -0
  28. data/docs/messages/mark_message_as_read.md +84 -0
  29. data/docs/messages/reaction.md +59 -0
  30. data/docs/messages/sticker.md +40 -0
  31. data/docs/messages/template.md +166 -0
  32. data/docs/messages/text.md +55 -0
  33. data/docs/messages/video.md +41 -0
  34. data/docs/subscribed_app/README.md +139 -0
  35. data/docs/webhooks/README.md +209 -0
  36. data/docs/webhooks/account_alerts.md +44 -0
  37. data/docs/webhooks/account_review_update.md +29 -0
  38. data/docs/webhooks/account_update.md +54 -0
  39. data/docs/webhooks/automatic_events.md +47 -0
  40. data/docs/webhooks/business_capability_update.md +40 -0
  41. data/docs/webhooks/history.md +44 -0
  42. data/docs/webhooks/message_template_components_update.md +46 -0
  43. data/docs/webhooks/message_template_quality_update.md +49 -0
  44. data/docs/webhooks/message_template_status_update.md +55 -0
  45. data/docs/webhooks/messages.md +385 -0
  46. data/docs/webhooks/partner_solutions.md +35 -0
  47. data/docs/webhooks/payment_configuration_update.md +40 -0
  48. data/docs/webhooks/phone_number_name_update.md +44 -0
  49. data/docs/webhooks/phone_number_quality_update.md +40 -0
  50. data/docs/webhooks/security.md +43 -0
  51. data/docs/webhooks/smb_app_state_sync.md +53 -0
  52. data/docs/webhooks/smb_message_echoes.md +54 -0
  53. data/docs/webhooks/template_category_update.md +52 -0
  54. data/docs/webhooks/user_preferences.md +48 -0
  55. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  56. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  57. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  58. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  59. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  60. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  61. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  62. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  63. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  64. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  65. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  66. data/lib/ruby/whatsapp/business_phone_number/transport.rb +11 -12
  67. data/lib/ruby/whatsapp/business_phone_number.rb +11 -4
  68. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  69. data/lib/ruby/whatsapp/path_building.rb +33 -0
  70. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  71. data/lib/ruby/whatsapp/version.rb +1 -1
  72. metadata +63 -1
@@ -0,0 +1,206 @@
1
+ # Managing Message Templates
2
+
3
+ [Sending a template](../messages/template.md) requires one that already exists and has
4
+ been approved by Meta. `Whatsapp::MessageTemplates` creates and manages those
5
+ templates, so they can live in your codebase and ship from CI instead of being clicked
6
+ together in WhatsApp Manager.
7
+
8
+ Addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`) and needs the
9
+ `whatsapp_business_management` permission.
10
+
11
+ ```ruby
12
+ Whatsapp.configure do |config|
13
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
14
+ config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
15
+ end
16
+
17
+ templates = Whatsapp::MessageTemplates.new # or .new(client: my_client)
18
+ ```
19
+
20
+ Omitting `waba_id` raises `TemplateError` before any request is made.
21
+
22
+ ## The one thing to internalise
23
+
24
+ "Template" means two unrelated payload schemas depending on direction:
25
+
26
+ | | **Management** (this directory) | **Sending** ([`../messages/template.md`](../messages/template.md)) |
27
+ | --- | --- | --- |
28
+ | Endpoint | `POST /{WABA_ID}/message_templates` | `POST /{PHONE_ID}/messages` |
29
+ | ID | `client.waba_id` | `client.phone_id` |
30
+ | Permission | `whatsapp_business_management` | `whatsapp_business_messaging` |
31
+ | Defines | the template *shape*, with placeholders | the *values* for those placeholders |
32
+ | Component key | `text` + `example` | `parameters` |
33
+ | Component types | `HEADER` `BODY` `FOOTER` `BUTTONS` (uppercase) | `header` `body` `button` (lowercase) |
34
+ | Buttons | one `BUTTONS` component holding N buttons | N `button` components with `sub_type` + `index` |
35
+
36
+ The same template, both sides:
37
+
38
+ ```jsonc
39
+ // MANAGEMENT: define the placeholder and give Meta a sample value for review
40
+ { "type": "BODY",
41
+ "text": "Thank you, {{1}}! Your order number is {{2}}.",
42
+ "example": { "body_text": [["Pablo", "860198-230332"]] } }
43
+
44
+ // SENDING: supply real values. No text. No example.
45
+ { "type": "body",
46
+ "parameters": [{ "type": "text", "text": "Pablo" },
47
+ { "type": "text", "text": "860198-230332" }] }
48
+ ```
49
+
50
+ ## API
51
+
52
+ | Method | Request | Returns |
53
+ | --- | --- | --- |
54
+ | `create(**attrs)` | `POST /{waba_id}/message_templates` | [`Response::Created`](responses.md#created) |
55
+ | `create_from_library(**attrs)` | same edge, [library payload](library.md) | [`Response::Created`](responses.md#created) |
56
+ | `upsert(**attrs)` | `POST /{waba_id}/upsert_message_templates` | [`Response::Created`](responses.md#created) |
57
+ | `list(**filters)` | `GET /{waba_id}/message_templates` | [`Response::Collection`](responses.md#collection) |
58
+ | `find(template_id:, fields:)` | `GET /{template_id}` | [`Response::Node`](responses.md#node) |
59
+ | `update(template_id:, **attrs)` | `POST /{template_id}` | `Boolean` |
60
+ | `delete(name:/hsm_id:/hsm_ids:)` | `DELETE /{waba_id}/message_templates` | `Boolean` |
61
+
62
+ `update` is a **POST to the template's own ID** — `PUT`/`PATCH` are unsupported on
63
+ this edge.
64
+
65
+ ## Template kinds
66
+
67
+ | Kind | Page |
68
+ | --- | --- |
69
+ | Standard utility / marketing | [standard.md](standard.md) |
70
+ | Authentication (OTP) | [authentication.md](authentication.md) |
71
+ | Marketing carousel | [carousel.md](carousel.md) |
72
+ | Limited-time offer | [limited_time_offer.md](limited_time_offer.md) |
73
+ | Library (pre-written by Meta) | [library.md](library.md) |
74
+
75
+ See [components.md](components.md) for the full component and button reference, and
76
+ [responses.md](responses.md) for what comes back.
77
+
78
+ ## Template anatomy
79
+
80
+ | Field | Rules |
81
+ | --- | --- |
82
+ | `name` | `/\A[a-z0-9_]+\z/`, max **512**. Not unique — one per `(name, language)` pair |
83
+ | `language` **XOR** `languages` | Singular for `create`, the array for `upsert` |
84
+ | `category` | `AUTHENTICATION` \| `MARKETING` \| `UTILITY` |
85
+ | `parameter_format` | `POSITIONAL` (default) \| `NAMED` |
86
+ | `sub_category` | `ORDER_DETAILS` \| `ORDER_STATUS` \| `RICH_ORDER_STATUS` |
87
+ | `message_send_ttl_seconds` | Integer |
88
+ | `allow_category_change` | A no-op since 2025-04-09 |
89
+ | `cta_url_link_tracking_opted_out` | Boolean |
90
+
91
+ Enum values accept either casing and are emitted uppercase — `category: "utility"`
92
+ and `category: "UTILITY"` both work.
93
+
94
+ ## Reading and paging
95
+
96
+ ```ruby
97
+ page = templates.list(status: %w[APPROVED], fields: %w[name category status], limit: 25)
98
+
99
+ page.select(&:approved?).map(&:name) # Collection is Enumerable
100
+ page.remaining # headroom against your account's template cap
101
+ templates.list(after: page.next_cursor) if page.next_cursor
102
+ ```
103
+
104
+ Documented filters: `name`, `name_or_content`, `content`, `language`, `category`,
105
+ `status`, `quality_score`, `since`, `until`, `fields`, `limit`, `after`, `before`.
106
+ Array values are JSON-encoded (`status: %w[APPROVED PAUSED]` → `status=["APPROVED","PAUSED"]`)
107
+ **except** `fields`, which is comma-joined.
108
+
109
+ ```ruby
110
+ template = templates.find(template_id: "1259544702043867")
111
+
112
+ template.status # => "APPROVED"
113
+ template.editable? # => true
114
+ template.components # => raw hashes — Meta's echo, deliberately not re-validated
115
+ ```
116
+
117
+ ## Editing
118
+
119
+ Components are a **full replacement** — there is no partial component edit.
120
+
121
+ ```ruby
122
+ templates.update(template_id: "564750795574598", category: "MARKETING") # => true
123
+ templates.update(template_id: "564750795574598", components: [
124
+ { type: :header, format: "TEXT", text: "Our {{1}} is on!", example: ["Spring Sale"] },
125
+ { type: :body, text: "Shop now through {{1}}.", example: ["the end of April"] },
126
+ ]) # => true
127
+ ```
128
+
129
+ Only `APPROVED`, `REJECTED`, and `PAUSED` templates are editable (`Node#editable?`).
130
+ Editing an approved template re-submits it for review, but it keeps working meanwhile.
131
+
132
+ > **Edit rate limits.** Approved templates allow 10 edits per 30 days and 1 per 24
133
+ > hours. Neither is checkable locally — they surface as API errors.
134
+
135
+ ## Deleting
136
+
137
+ Three mutually exclusive modes:
138
+
139
+ ```ruby
140
+ templates.delete(name: "order_confirmation") # ALL language variants
141
+ templates.delete(hsm_id: "1407680676729941", name: "order_confirmation")
142
+ templates.delete(hsm_ids: %w[1387372356726668 1304694804498707]) # up to 100
143
+ ```
144
+
145
+ > Deleting an approved template blocks reuse of its name for **30 days**. `DISABLED`
146
+ > templates cannot be deleted at all.
147
+
148
+ ## Lifecycle
149
+
150
+ Only `APPROVED` templates can be sent. Review is asynchronous and can take 24 hours.
151
+ The outcome arrives by **webhook**, not by polling — `#find` is the fallback, not the
152
+ intended path:
153
+
154
+ | Webhook field | Page |
155
+ | --- | --- |
156
+ | `message_template_status_update` | [../webhooks/message_template_status_update.md](../webhooks/message_template_status_update.md) |
157
+ | `message_template_quality_update` | [../webhooks/message_template_quality_update.md](../webhooks/message_template_quality_update.md) |
158
+ | `message_template_components_update` | [../webhooks/message_template_components_update.md](../webhooks/message_template_components_update.md) |
159
+ | `template_category_update` | [../webhooks/template_category_update.md](../webhooks/template_category_update.md) |
160
+
161
+ Statuses: `APPROVED` `PENDING` `REJECTED` `PAUSED` `DISABLED` `IN_APPEAL`
162
+ `PENDING_DELETION` `DELETED` `LIMIT_EXCEEDED` `ARCHIVED`.
163
+
164
+ ## Errors
165
+
166
+ Every failure raises `Whatsapp::MessageTemplates::TemplateError`, except local
167
+ validation failures, which raise `ActiveModel::ValidationError`.
168
+
169
+ ```ruby
170
+ templates.create(name: "Order Confirmation", ...)
171
+ # => ActiveModel::ValidationError: Name must contain only lowercase alphanumeric
172
+ # characters and underscores
173
+
174
+ templates.create(..., components: [{ type: :body, text: "Hi {{1}} and {{2}}", example: ["Pablo"] }])
175
+ # => ActiveModel::ValidationError: Example does not match the body text:
176
+ # 2 placeholders but 1 example
177
+
178
+ templates.upsert(name: "x", language: "en_US", ...)
179
+ # => Whatsapp::MessageTemplates::TemplateError: #upsert requires `languages:`
180
+ # (an array of locale codes); use #create for a single one
181
+
182
+ templates.delete(hsm_ids: [], name: "x")
183
+ # => Whatsapp::MessageTemplates::TemplateError: hsm_ids cannot be combined with name or hsm_id
184
+ ```
185
+
186
+ ## Not wrapped
187
+
188
+ - **Media upload for template headers.** A media header takes a `header_handle` you
189
+ already hold. Producing one needs Meta's **Resumable Upload API**, which this gem
190
+ does not wrap — and it is a *different flow* from
191
+ [`Media#upload`](../media/README.md), whose media IDs are for *sending*, not
192
+ template creation.
193
+ - **Undocumented button types:** `FLOW`, `MPM`, `CATALOG`, `VOICE_CALL`, `VIDEO_CALL`,
194
+ `POSTBACK`, `BOOKING_STATUS`, `PAYMENT_REQUEST`, `REQUEST_CONTACT_INFO`. Named in
195
+ Meta's Graph enum with no published field reference.
196
+ [`LibraryTemplate`](library.md) does accept most of them, because Meta documents them
197
+ by name in *that* context.
198
+ - **Undocumented component types:** `GREETING`, `ALBUM`, `CALL_PERMISSION_REQUEST`,
199
+ `TAP_TARGET_CONFIGURATION`, `ATTACHMENT`.
200
+ - The `compare` edge, `message_template_previews`, library browsing,
201
+ archive/unarchive, auto-pagination on `#list`, and the Marketing Messages API fields
202
+ (`bid_spec`, `optimization_spec`, `degrees_of_freedom_spec`, `product_set_id`).
203
+
204
+ ---
205
+
206
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview>
@@ -0,0 +1,107 @@
1
+ # Authentication Templates
2
+
3
+ One-time-password templates invert the usual shape: **Meta supplies and localises the
4
+ wording**, so you pass flags rather than text. That's also why they're normally created
5
+ for every language at once with `upsert` instead of `create`.
6
+
7
+ ```ruby
8
+ templates = Whatsapp::MessageTemplates.new
9
+
10
+ templates.upsert(
11
+ name: "authentication_code", languages: %w[en_US es_ES fr], category: "AUTHENTICATION",
12
+ components: [
13
+ { type: :body, add_security_recommendation: true },
14
+ { type: :footer, code_expiration_minutes: 15 },
15
+ { type: :buttons, buttons: [{ type: :otp, otp_type: "COPY_CODE" }] },
16
+ ]
17
+ )
18
+ ```
19
+
20
+ `upsert` requires `languages:` (an array). Passing `language:` raises:
21
+
22
+ ```ruby
23
+ templates.upsert(name: "x", language: "en_US", category: "AUTHENTICATION", components: [...])
24
+ # => Whatsapp::MessageTemplates::TemplateError: #upsert requires `languages:`
25
+ # (an array of locale codes); use #create for a single one
26
+ ```
27
+
28
+ ## The flag-based components
29
+
30
+ | Component | Field | Meaning |
31
+ | --- | --- | --- |
32
+ | `body` | `add_security_recommendation: true` | Appends Meta's "don't share this code" line |
33
+ | `footer` | `code_expiration_minutes: 1..90` | Renders "This code expires in N minutes" |
34
+ | `buttons` | one `otp` button | How the user gets the code out of the message |
35
+
36
+ Body and footer are **XOR** with their text forms — an authentication `body` takes
37
+ `add_security_recommendation` *or* `text`, never both:
38
+
39
+ ```ruby
40
+ { type: :body, text: "Your code is {{1}}", add_security_recommendation: true }
41
+ # => ActiveModel::ValidationError: Text cannot be combined with add_security_recommendation
42
+ ```
43
+
44
+ ## OTP button types
45
+
46
+ | `otp_type` | Behaviour | Extra requirements |
47
+ | --- | --- | --- |
48
+ | `COPY_CODE` | Shows a "copy code" button | — |
49
+ | `ONE_TAP` | Autofills your app on tap | `supported_apps` required |
50
+ | `ZERO_TAP` | Autofills with no tap at all | `supported_apps` **and** `zero_tap_terms_accepted: true` |
51
+ | `NO_BUTTONS` | Plain text, user copies manually | — |
52
+
53
+ ```ruby
54
+ templates.upsert(
55
+ name: "authentication_code_autofill_button", languages: %w[en_US es_ES fr],
56
+ category: "AUTHENTICATION",
57
+ components: [
58
+ { type: :body, add_security_recommendation: true },
59
+ { type: :footer, code_expiration_minutes: 15 },
60
+ { type: :buttons, buttons: [
61
+ { type: :otp, otp_type: "ONE_TAP",
62
+ supported_apps: [{ package_name: "com.example.luckyshrub",
63
+ signature_hash: "K8a/AINcGX7" }] },
64
+ ] },
65
+ ]
66
+ )
67
+ # components => [{ type: "BODY", add_security_recommendation: true },
68
+ # { type: "FOOTER", code_expiration_minutes: 15 },
69
+ # { type: "BUTTONS", buttons: [{ type: "OTP", otp_type: "ONE_TAP",
70
+ # supported_apps: [{ package_name: "...", signature_hash: "..." }] }] }]
71
+ ```
72
+
73
+ > **`text` and `autofill_text` are rejected** on an OTP button — Meta localises those
74
+ > labels itself:
75
+ >
76
+ > ```ruby
77
+ > { type: :otp, otp_type: "COPY_CODE", text: "Copy it" }
78
+ > # => ActiveModel::ValidationError: Text cannot be set on an OTP button;
79
+ > # Meta localises the label
80
+ > ```
81
+
82
+ ## Validation errors
83
+
84
+ ```ruby
85
+ { type: :otp, otp_type: "ONE_TAP" }
86
+ # => ActiveModel::ValidationError: Supported apps can't be blank for the ONE_TAP OTP type
87
+
88
+ { type: :otp, otp_type: "ZERO_TAP", supported_apps: [...] }
89
+ # => ActiveModel::ValidationError: Zero tap terms accepted must be accepted
90
+ # for the ZERO_TAP OTP type
91
+
92
+ { type: :footer, code_expiration_minutes: 120 }
93
+ # => ActiveModel::ValidationError: Code expiration minutes must be in 1..90
94
+ ```
95
+
96
+ Note that unlike other categories, `OTP` buttons are **not** capped per type — Meta
97
+ publishes no limit, so the gem invents none.
98
+
99
+ ## Sending one
100
+
101
+ Once approved, an authentication template is sent like any other — see
102
+ [../messages/template.md](../messages/template.md). The code itself goes in the body
103
+ parameter, and (for `COPY_CODE`/`ONE_TAP`) also as the button's `payload`.
104
+
105
+ ---
106
+
107
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-templates>
@@ -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,179 @@
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
+
112
+ ```ruby
113
+ { type: :quick_reply, text: "More like this" }
114
+ { type: :url, text: "Track order", url: "https://example.com/o/{{1}}", example: "1234" }
115
+ { type: :phone_number, text: "Call", phone_number: "15550051310" }
116
+ { type: :copy_code, example: "SPRING25" }
117
+ { type: :otp, otp_type: "COPY_CODE" }
118
+ ```
119
+
120
+ ### The URL button's single trailing variable
121
+
122
+ A URL button may contain **at most one variable, and it must be at the end**:
123
+
124
+ ```ruby
125
+ { type: :url, text: "Track", url: "https://example.com/{{1}}/details" }
126
+ # => ActiveModel::ValidationError: Url variable must be at the end of the URL
127
+ ```
128
+
129
+ > The URL variable is counted by **occurrence**, not by unique name, so
130
+ > `.../{{1}}/details/{{1}}` is rejected: it ends in a placeholder and names one
131
+ > parameter, but the leading one still sits mid-URL. Everywhere else — body examples
132
+ > above all — the unique count is the correct one, since Meta treats a repeated
133
+ > `{{1}}` as a single parameter needing a single example.
134
+
135
+ Its `example` is a **flat array on the button**, unlike header and body examples,
136
+ which are nested under an `example` key. A bare String is wrapped for you.
137
+
138
+ ### Buttons Meta supplies the label for
139
+
140
+ `COPY_CODE` and `OTP` reject `text` (and `autofill_text`) — Meta writes and localises
141
+ those labels:
142
+
143
+ ```ruby
144
+ { type: :copy_code, example: "SPRING25", text: "Copy" }
145
+ # => ActiveModel::ValidationError: Text cannot be set on a copy-code button;
146
+ # Meta supplies the label
147
+ ```
148
+
149
+ ## Cross-component rules
150
+
151
+ Owned by `ComponentSet`, because it is the only object that can see the siblings:
152
+
153
+ - **Exactly one** `BODY`.
154
+ - **At most one** each of `HEADER`, `FOOTER`, `BUTTONS`, `CAROUSEL`,
155
+ `LIMITED_TIME_OFFER`.
156
+ - `CAROUSEL` or `LIMITED_TIME_OFFER` → category must be `MARKETING`.
157
+ - A `LOCATION` header → category must be `UTILITY` or `MARKETING`.
158
+ - `LIMITED_TIME_OFFER` present → `FOOTER` forbidden, `BODY` ≤ **600**, header
159
+ `IMAGE`/`VIDEO` only, copy-code `example` ≤ **15** and that button must be **first**.
160
+
161
+ The category-dependent rules are **skipped when `category` is nil** — which is exactly
162
+ what `#update` needs, since editing replaces components without re-supplying a
163
+ category.
164
+
165
+ ## Not registered
166
+
167
+ Only types with a published Meta field reference get a class. Named in Meta's Graph
168
+ enum but deliberately absent:
169
+
170
+ - **Components:** `GREETING`, `ALBUM`, `CALL_PERMISSION_REQUEST`,
171
+ `TAP_TARGET_CONFIGURATION`, `ATTACHMENT`.
172
+ - **Buttons:** `FLOW`, `MPM`, `CATALOG`, `VOICE_CALL`, `VIDEO_CALL`, `POSTBACK`,
173
+ `BOOKING_STATUS`, `PAYMENT_REQUEST`, `REQUEST_CONTACT_INFO`. Most of these *are*
174
+ accepted by [`LibraryTemplate`](library.md), because Meta documents them by name in
175
+ that context.
176
+
177
+ ---
178
+
179
+ **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>