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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +276 -634
- data/docs/README.md +73 -0
- data/docs/business_phone_number/README.md +232 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/configuration.md +172 -0
- data/docs/errors.md +208 -0
- data/docs/media/README.md +161 -0
- data/docs/message_templates/README.md +206 -0
- data/docs/message_templates/authentication.md +107 -0
- data/docs/message_templates/carousel.md +103 -0
- data/docs/message_templates/components.md +179 -0
- data/docs/message_templates/library.md +98 -0
- data/docs/message_templates/limited_time_offer.md +94 -0
- data/docs/message_templates/responses.md +173 -0
- data/docs/message_templates/standard.md +138 -0
- data/docs/messages/README.md +135 -0
- data/docs/messages/address.md +86 -0
- data/docs/messages/audio.md +43 -0
- data/docs/messages/contacts.md +129 -0
- data/docs/messages/document.md +49 -0
- data/docs/messages/image.md +59 -0
- data/docs/messages/interactive.md +281 -0
- data/docs/messages/location.md +51 -0
- data/docs/messages/location_request.md +59 -0
- data/docs/messages/mark_message_as_read.md +84 -0
- data/docs/messages/reaction.md +59 -0
- data/docs/messages/sticker.md +40 -0
- data/docs/messages/template.md +166 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +209 -0
- data/docs/webhooks/account_alerts.md +44 -0
- data/docs/webhooks/account_review_update.md +29 -0
- data/docs/webhooks/account_update.md +54 -0
- data/docs/webhooks/automatic_events.md +47 -0
- data/docs/webhooks/business_capability_update.md +40 -0
- data/docs/webhooks/history.md +44 -0
- data/docs/webhooks/message_template_components_update.md +46 -0
- data/docs/webhooks/message_template_quality_update.md +49 -0
- data/docs/webhooks/message_template_status_update.md +55 -0
- data/docs/webhooks/messages.md +385 -0
- data/docs/webhooks/partner_solutions.md +35 -0
- data/docs/webhooks/payment_configuration_update.md +40 -0
- data/docs/webhooks/phone_number_name_update.md +44 -0
- data/docs/webhooks/phone_number_quality_update.md +40 -0
- data/docs/webhooks/security.md +43 -0
- data/docs/webhooks/smb_app_state_sync.md +53 -0
- data/docs/webhooks/smb_message_echoes.md +54 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_preferences.md +48 -0
- data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
- data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
- data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
- data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
- data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
- data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +11 -12
- data/lib/ruby/whatsapp/business_phone_number.rb +11 -4
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- 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>
|