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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -0
- data/README.md +322 -617
- data/docs/README.md +76 -0
- data/docs/business_phone_number/README.md +243 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +177 -0
- data/docs/errors.md +219 -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 +215 -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 +212 -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 +342 -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 +193 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/schedule/README.md +183 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +217 -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/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +107 -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 +520 -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 +91 -0
- data/docs/webhooks/smb_message_echoes.md +91 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +87 -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/contact_book/delete.rb +87 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -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 +12 -12
- data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
- data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
- data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
- data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
- data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
- data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/messages/base.rb +47 -5
- data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
- data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/schedule/create.rb +101 -0
- data/lib/ruby/whatsapp/schedule/list.rb +50 -0
- data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
- data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
- data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
- data/lib/ruby/whatsapp/schedule/response.rb +18 -0
- data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
- data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
- data/lib/ruby/whatsapp/schedule.rb +15 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
- data/lib/ruby/whatsapp/webhook/change.rb +3 -1
- data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
- data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
- data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
- data/lib/ruby/whatsapp/webhook/history.rb +73 -34
- data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
- data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
- data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
- data/lib/ruby/whatsapp/webhook/message.rb +3 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
- data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
- data/lib/ruby/whatsapp/webhook/status.rb +23 -2
- data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
- data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
- data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
- data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
- metadata +100 -1
data/docs/errors.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
Every exception this gem raises descends from `Whatsapp::Error`, so one rescue catches
|
|
4
|
+
the lot:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
begin
|
|
8
|
+
Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi")
|
|
9
|
+
rescue Whatsapp::Error => e
|
|
10
|
+
Rails.logger.error("WhatsApp call failed: #{e.message}")
|
|
11
|
+
end
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The hierarchy
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
StandardError
|
|
18
|
+
└── Whatsapp::Error
|
|
19
|
+
├── Whatsapp::RequestError a message send failed
|
|
20
|
+
├── Whatsapp::Messages::PayloadError unknown message kind
|
|
21
|
+
├── Whatsapp::Messages::Interactive::Header::HeaderError malformed interactive header
|
|
22
|
+
├── Whatsapp::Media::MediaError any Media failure
|
|
23
|
+
├── Whatsapp::MessageTemplates::TemplateError any template-management failure
|
|
24
|
+
├── Whatsapp::SubscribedApp::Error any subscribed-apps failure
|
|
25
|
+
├── Whatsapp::Schedule::Error any campaign-schedule failure
|
|
26
|
+
└── Whatsapp::BusinessPhoneNumber::Error any phone-number-onboarding or account failure
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Each module raises **its own** error class rather than the generic
|
|
30
|
+
`Whatsapp::RequestError`, so you can rescue narrowly:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
rescue Whatsapp::MessageTemplates::TemplateError => e
|
|
34
|
+
# a template problem specifically — retry, or surface to the operator
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **`Whatsapp::Webhook::Error` is not an exception.** Despite the name, it is a value
|
|
39
|
+
> object modelling Meta's error payloads on `Status#errors` and
|
|
40
|
+
> `Message::Unknown#errors`. See
|
|
41
|
+
> [webhooks/messages.md](webhooks/messages.md#errors).
|
|
42
|
+
|
|
43
|
+
## Validation errors are different
|
|
44
|
+
|
|
45
|
+
Local validation failures raise **`ActiveModel::ValidationError`**, not a gem class.
|
|
46
|
+
That is deliberate: a validation failure means *you* built something wrong, before any
|
|
47
|
+
network call happened, and ActiveModel's error object carries the per-attribute
|
|
48
|
+
detail.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
Whatsapp::Messages.send_text!(to: "+15551234567", body: "x" * 5000)
|
|
52
|
+
# => ActiveModel::ValidationError: Validation failed:
|
|
53
|
+
# Body is too long (maximum is 4096 characters)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Reach the structured detail through the exception's model:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
begin
|
|
60
|
+
Whatsapp::Messages.send_image!(to: "+15551234567", caption: "x" * 2000)
|
|
61
|
+
rescue ActiveModel::ValidationError => e
|
|
62
|
+
e.model.errors.full_messages
|
|
63
|
+
# => ["Either id or link must be present", "Caption is too long (maximum is 1024 characters)"]
|
|
64
|
+
e.model.errors[:caption]
|
|
65
|
+
# => ["is too long (maximum is 1024 characters)"]
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Because every class runs `validate!` at the end of `initialize`, this raises at
|
|
70
|
+
**construction** time — the client is never touched.
|
|
71
|
+
|
|
72
|
+
Rescuing both kinds:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
begin
|
|
76
|
+
Whatsapp::Messages.send_template!(to:, name:, language: { code: })
|
|
77
|
+
rescue ActiveModel::ValidationError => e
|
|
78
|
+
# our payload is wrong — a bug, or bad user input
|
|
79
|
+
rescue Whatsapp::Error => e
|
|
80
|
+
# Meta said no, or the network did
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Missing keywords
|
|
85
|
+
|
|
86
|
+
A required Ruby keyword that isn't supplied raises `ArgumentError`, not a validation
|
|
87
|
+
error — it never reaches the validator:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Whatsapp::Messages.send_text!(to: "+15551234567")
|
|
91
|
+
# => ArgumentError: missing keyword: :body
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The recipient is the exception. Since a message can be addressed by phone number
|
|
95
|
+
(`to:`) *or* by [BSUID](messages/README.md#addressing-to-recipient-or-both)
|
|
96
|
+
(`recipient:`), neither keyword is individually required, so omitting both is a
|
|
97
|
+
validation failure rather than an arity one:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Whatsapp::Messages.send_text!(body: "Hi")
|
|
101
|
+
# => ActiveModel::ValidationError: at least one of to or recipient must be provided
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## API failures
|
|
105
|
+
|
|
106
|
+
Any non-2xx response raises the owning module's error class with a consistent message
|
|
107
|
+
shape:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
Failed to <action>: <status> - <body>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# => Whatsapp::RequestError: Failed to send message: 400 Bad Request -
|
|
115
|
+
# {"error":{"message":"(#131009) Parameter value is not valid","code":131009}}
|
|
116
|
+
|
|
117
|
+
# => Whatsapp::Media::MediaError: Failed to upload media: 401 Unauthorized - {...}
|
|
118
|
+
|
|
119
|
+
# => Whatsapp::MessageTemplates::TemplateError: Failed to create template:
|
|
120
|
+
# 400 Bad Request - {"error":{"message":"Invalid parameter","code":100}}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The response body is truncated at **500** characters, with `… (truncated)` appended —
|
|
124
|
+
Meta's error bodies can be long, and a full one in an exception message tends to end up
|
|
125
|
+
somewhere it shouldn't.
|
|
126
|
+
|
|
127
|
+
Actions used in these messages: `send message`, `mark message as read`,
|
|
128
|
+
`upload media`, `get media URL`, `download media`, `delete media`,
|
|
129
|
+
`list subscribed apps`, `subscribe app`, `unsubscribe app`,
|
|
130
|
+
`request verification code`, `verify code`, `register business phone number`,
|
|
131
|
+
`deregister business phone number`, plus the template CRUD verbs.
|
|
132
|
+
|
|
133
|
+
## Missing configuration
|
|
134
|
+
|
|
135
|
+
The `waba_id` and `phone_id` modules check for their ID **before** building a request,
|
|
136
|
+
and say exactly how to supply it:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
Whatsapp::MessageTemplates.new.list
|
|
140
|
+
# => Whatsapp::MessageTemplates::TemplateError: waba_id is required for template
|
|
141
|
+
# management; set it via Whatsapp.configure or Client.new(waba_id:)
|
|
142
|
+
|
|
143
|
+
Whatsapp::SubscribedApp::List.call
|
|
144
|
+
# => Whatsapp::SubscribedApp::Error: waba_id is required for subscribed apps;
|
|
145
|
+
# set it via Whatsapp.configure or Client.new(waba_id:)
|
|
146
|
+
|
|
147
|
+
Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
|
|
148
|
+
# => Whatsapp::BusinessPhoneNumber::Error: phone_id is required for the register edge;
|
|
149
|
+
# set it via Whatsapp.configure or Client.new(phone_id:)
|
|
150
|
+
|
|
151
|
+
Whatsapp::BusinessPhoneNumber::Account::Get.call
|
|
152
|
+
# => Whatsapp::BusinessPhoneNumber::Error: waba_id is required for business account
|
|
153
|
+
# details; set it via Whatsapp.configure or Client.new(waba_id:)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Note the last two: `BusinessPhoneNumber` spans both IDs. Its onboarding actions need
|
|
157
|
+
`phone_id`; its [`Account`](business_phone_number/account.md) actions need `waba_id`.
|
|
158
|
+
|
|
159
|
+
See [configuration.md](configuration.md#which-id-addresses-what).
|
|
160
|
+
|
|
161
|
+
## Argument errors from a module
|
|
162
|
+
|
|
163
|
+
Some misuse is caught before validation and raises the module's own error rather than
|
|
164
|
+
`ActiveModel::ValidationError` — these are wrong *calls*, not wrong *data*:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
templates.upsert(name: "x", language: "en_US", ...)
|
|
168
|
+
# => TemplateError: #upsert requires `languages:` (an array of locale codes);
|
|
169
|
+
# use #create for a single one
|
|
170
|
+
|
|
171
|
+
templates.update(template_id: "123")
|
|
172
|
+
# => TemplateError: nothing to update: pass category, components or message_send_ttl_seconds
|
|
173
|
+
|
|
174
|
+
templates.delete(hsm_ids: %w[1 2], name: "x")
|
|
175
|
+
# => TemplateError: hsm_ids cannot be combined with name or hsm_id
|
|
176
|
+
|
|
177
|
+
Whatsapp::Messages.new(kind: :telepathy, payload: { to: "+1" })
|
|
178
|
+
# => Whatsapp::Messages::PayloadError: Unknown message kind: :telepathy.
|
|
179
|
+
# Known kinds: text, image, audio, video, document, sticker, contacts, reaction,
|
|
180
|
+
# location, address, location_request, template, interactive
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Interactive header errors
|
|
184
|
+
|
|
185
|
+
`Interactive::Header` raises `HeaderError` rather than adding a validation error,
|
|
186
|
+
because a header missing its content cannot be serialized at all:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
Whatsapp::Messages.send_interactive!(
|
|
190
|
+
to: "+15551234567", type: :reply_buttons, body: "Pick one",
|
|
191
|
+
header: { type: "image" },
|
|
192
|
+
action: { buttons: [{ id: "a", title: "A" }] }
|
|
193
|
+
)
|
|
194
|
+
# => Whatsapp::Messages::Interactive::Header::HeaderError:
|
|
195
|
+
# Image link is required for image header
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Retrying
|
|
199
|
+
|
|
200
|
+
Meta's transient failures are ordinary HTTP: 429 for rate limiting, 5xx for outages.
|
|
201
|
+
The gem does not retry — that policy belongs to your job queue:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class SendWhatsappJob < ApplicationJob
|
|
205
|
+
retry_on Whatsapp::RequestError, wait: :polynomially_longer, attempts: 5
|
|
206
|
+
discard_on ActiveModel::ValidationError # our payload is wrong; retrying won't fix it
|
|
207
|
+
|
|
208
|
+
def perform(to:, body:)
|
|
209
|
+
Whatsapp::Messages.send_text!(to:, body:)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Discarding on `ActiveModel::ValidationError` is the important half: a locally invalid
|
|
215
|
+
payload will fail identically on every attempt.
|
|
216
|
+
|
|
217
|
+
> **Rate limits worth knowing.** Template edits are capped at 10 per 30 days and 1 per
|
|
218
|
+
> 24 hours; `Register`/`Deregister` at 10 per number per 72-hour window (error
|
|
219
|
+
> `133016`). None is observable client-side — they arrive as API errors.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Media
|
|
2
|
+
|
|
3
|
+
Upload, locate, download, and delete media assets on the Cloud API. An uploaded asset
|
|
4
|
+
gets a **media ID** you can pass to [image](../messages/image.md),
|
|
5
|
+
[video](../messages/video.md), [audio](../messages/audio.md),
|
|
6
|
+
[document](../messages/document.md), and [sticker](../messages/sticker.md) messages
|
|
7
|
+
instead of a public URL.
|
|
8
|
+
|
|
9
|
+
Addresses your **phone number** (`phone_id`), like
|
|
10
|
+
[messages](../messages/README.md).
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
media = Whatsapp::Media.new # or .new(client: my_client)
|
|
14
|
+
|
|
15
|
+
media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
|
|
16
|
+
info = media.get_url(media_id: media_id)
|
|
17
|
+
media.download(url: info["url"], save_to: "photo.jpg")
|
|
18
|
+
media.delete(media_id: media_id)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Method | Request | Returns |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `upload(file_path:, type:)` | `POST /{phone_id}/media` (multipart) | `String` media ID |
|
|
24
|
+
| `get_url(media_id:)` | `GET /{media_id}?phone_number_id=` | `Hash` |
|
|
25
|
+
| `download(url:, save_to:)` | `GET <url>` with the bearer token | `String` — the path written |
|
|
26
|
+
| `delete(media_id:)` | `DELETE /{media_id}?phone_number_id=` | `Boolean` |
|
|
27
|
+
|
|
28
|
+
## Uploading
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
media_id = Whatsapp::Media.new.upload(file_path: "photo.jpg", type: "image/jpeg")
|
|
32
|
+
# => "1234567890123456"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The file must exist — checked **before** the request, so a typo costs nothing:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Whatsapp::Media.new.upload(file_path: "nope.jpg", type: "image/jpeg")
|
|
39
|
+
# => Whatsapp::Media::MediaError: File not found: nope.jpg
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Why prefer an ID over a link
|
|
43
|
+
|
|
44
|
+
Sending by `link:` makes Meta re-download the file on **every send**. Uploading once
|
|
45
|
+
and reusing the ID is faster, cheaper, and avoids your CDN being hammered:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
media_id = media.upload(file_path: "catalogue.pdf", type: "application/pdf")
|
|
49
|
+
|
|
50
|
+
recipients.each do |to|
|
|
51
|
+
Whatsapp::Messages.send_document!(to: to, id: media_id, filename: "catalogue.pdf")
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> **Not for template headers.** A media header on a *template* takes a
|
|
56
|
+
> `header_handle` from Meta's Resumable Upload API, which this gem does not wrap.
|
|
57
|
+
> These media IDs are for **sending**, not template creation — see
|
|
58
|
+
> [../message_templates/README.md](../message_templates/README.md#not-wrapped).
|
|
59
|
+
|
|
60
|
+
## Getting a download URL
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
info = Whatsapp::Media.new.get_url(media_id: "1234567890123456")
|
|
64
|
+
# => { "url" => "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...",
|
|
65
|
+
# "mime_type" => "image/jpeg", "sha256" => "...", "file_size" => 12345,
|
|
66
|
+
# "id" => "1234567890123456" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The URL is short-lived and requires the bearer token — you cannot hand it to a browser.
|
|
70
|
+
Fetch it with `#download`.
|
|
71
|
+
|
|
72
|
+
This is the step after receiving an inbound media message:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
message.media_id # from a webhook Message::Image / Video / Audio / Document / Sticker
|
|
76
|
+
info = media.get_url(media_id: message.media_id)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Downloading
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Whatsapp::Media.new.download(url: info["url"], save_to: "photo.jpg")
|
|
83
|
+
# => "photo.jpg"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The response body is streamed to disk chunk by chunk rather than buffered, so a large
|
|
87
|
+
video doesn't have to fit in memory.
|
|
88
|
+
|
|
89
|
+
### The token never leaves the allowlist
|
|
90
|
+
|
|
91
|
+
`#download` attaches your API token to the request, which makes the destination URL
|
|
92
|
+
security-relevant. Two guards run before anything is sent:
|
|
93
|
+
|
|
94
|
+
1. **HTTPS only.** A plain-HTTP URL is refused outright.
|
|
95
|
+
2. **Allowlisted hosts only.** The host must be on
|
|
96
|
+
`Configuration#media_host_allowlist`, which defaults to
|
|
97
|
+
`lookaside.fbsbx.com`, `mmg.whatsapp.net`, and `graph.facebook.com`.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
media.download(url: "http://lookaside.fbsbx.com/x", save_to: "f")
|
|
101
|
+
# => Whatsapp::Media::MediaError: Refusing to download from non-HTTPS URL: http://...
|
|
102
|
+
|
|
103
|
+
media.download(url: "https://evil.example.com/x", save_to: "f")
|
|
104
|
+
# => Whatsapp::Media::MediaError: Refusing to send credentials to non-allowlisted host:
|
|
105
|
+
# evil.example.com
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Matching is exact host or a dotted subdomain, so `evil-fbsbx.com` and
|
|
109
|
+
`fbsbx.com.attacker.com` are both rejected — a substring check would have let both
|
|
110
|
+
through.
|
|
111
|
+
|
|
112
|
+
Override the list if Meta serves you from a different host:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Whatsapp.configure do |config|
|
|
116
|
+
config.media_host_allowlist = %w[lookaside.fbsbx.com mmg.whatsapp.net my-proxy.internal]
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Deleting
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Whatsapp::Media.new.delete(media_id: "1234567890123456") # => true
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Returns a strict boolean from Meta's `{"success": true}`. A non-2xx response raises
|
|
127
|
+
instead of returning `false`, so `false` genuinely means Meta declined.
|
|
128
|
+
|
|
129
|
+
## Errors
|
|
130
|
+
|
|
131
|
+
Every failure raises `Whatsapp::Media::MediaError`:
|
|
132
|
+
|
|
133
|
+
| Message | Cause |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `File not found: <path>` | `upload` — checked locally before any request |
|
|
136
|
+
| `Invalid media URL: <detail>` | `download` — the URL didn't parse |
|
|
137
|
+
| `Refusing to download from non-HTTPS URL: <url>` | `download` — HTTP scheme |
|
|
138
|
+
| `Refusing to send credentials to non-allowlisted host: <host>` | `download` — host not on the allowlist |
|
|
139
|
+
| `Failed to upload media: 400 ...` | Non-2xx from the API |
|
|
140
|
+
| `Failed to get media URL: ...` / `Failed to download media: ...` / `Failed to delete media: ...` | Non-2xx from the API |
|
|
141
|
+
|
|
142
|
+
Error bodies are truncated at 500 characters. See [../errors.md](../errors.md).
|
|
143
|
+
|
|
144
|
+
## Round trip: receive, download, reply
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
media = Whatsapp::Media.new
|
|
148
|
+
|
|
149
|
+
change.value.messages.each do |message|
|
|
150
|
+
next unless message.respond_to?(:media_id)
|
|
151
|
+
|
|
152
|
+
info = media.get_url(media_id: message.media_id)
|
|
153
|
+
path = media.download(url: info["url"], save_to: Rails.root.join("tmp", message.id))
|
|
154
|
+
|
|
155
|
+
Whatsapp::Messages.send_text!(to: message.from, body: "Got your #{message.mime_type}, thanks!")
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
**Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media>
|
|
@@ -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`. 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>
|