ruby-whatsapp 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +322 -617
  4. data/docs/README.md +76 -0
  5. data/docs/business_phone_number/README.md +243 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/contact_book.md +90 -0
  8. data/docs/business_phone_number/profile.md +220 -0
  9. data/docs/business_phone_number/username.md +157 -0
  10. data/docs/configuration.md +177 -0
  11. data/docs/errors.md +219 -0
  12. data/docs/media/README.md +161 -0
  13. data/docs/message_templates/README.md +206 -0
  14. data/docs/message_templates/authentication.md +107 -0
  15. data/docs/message_templates/carousel.md +103 -0
  16. data/docs/message_templates/components.md +215 -0
  17. data/docs/message_templates/library.md +98 -0
  18. data/docs/message_templates/limited_time_offer.md +94 -0
  19. data/docs/message_templates/responses.md +173 -0
  20. data/docs/message_templates/standard.md +138 -0
  21. data/docs/messages/README.md +212 -0
  22. data/docs/messages/address.md +86 -0
  23. data/docs/messages/audio.md +43 -0
  24. data/docs/messages/contacts.md +129 -0
  25. data/docs/messages/document.md +49 -0
  26. data/docs/messages/image.md +59 -0
  27. data/docs/messages/interactive.md +342 -0
  28. data/docs/messages/location.md +51 -0
  29. data/docs/messages/location_request.md +59 -0
  30. data/docs/messages/mark_message_as_read.md +84 -0
  31. data/docs/messages/reaction.md +59 -0
  32. data/docs/messages/sticker.md +40 -0
  33. data/docs/messages/template.md +193 -0
  34. data/docs/messages/text.md +55 -0
  35. data/docs/messages/video.md +41 -0
  36. data/docs/schedule/README.md +183 -0
  37. data/docs/subscribed_app/README.md +139 -0
  38. data/docs/webhooks/README.md +217 -0
  39. data/docs/webhooks/account_alerts.md +44 -0
  40. data/docs/webhooks/account_review_update.md +29 -0
  41. data/docs/webhooks/account_update.md +54 -0
  42. data/docs/webhooks/automatic_events.md +47 -0
  43. data/docs/webhooks/business_capability_update.md +40 -0
  44. data/docs/webhooks/business_username_updates.md +55 -0
  45. data/docs/webhooks/history.md +107 -0
  46. data/docs/webhooks/message_template_components_update.md +46 -0
  47. data/docs/webhooks/message_template_quality_update.md +49 -0
  48. data/docs/webhooks/message_template_status_update.md +55 -0
  49. data/docs/webhooks/messages.md +520 -0
  50. data/docs/webhooks/partner_solutions.md +35 -0
  51. data/docs/webhooks/payment_configuration_update.md +40 -0
  52. data/docs/webhooks/phone_number_name_update.md +44 -0
  53. data/docs/webhooks/phone_number_quality_update.md +40 -0
  54. data/docs/webhooks/security.md +43 -0
  55. data/docs/webhooks/smb_app_state_sync.md +91 -0
  56. data/docs/webhooks/smb_message_echoes.md +91 -0
  57. data/docs/webhooks/template_category_update.md +52 -0
  58. data/docs/webhooks/user_id_update.md +84 -0
  59. data/docs/webhooks/user_preferences.md +87 -0
  60. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  61. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  62. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  63. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  64. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  65. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  66. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  67. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  68. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  69. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  70. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  71. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  72. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  73. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  74. data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
  75. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  76. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  77. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  78. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  79. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  80. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  81. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  82. data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
  83. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  84. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  85. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  86. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  87. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  88. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  89. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  90. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  91. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  92. data/lib/ruby/whatsapp/path_building.rb +33 -0
  93. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  94. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  95. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  96. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  97. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  98. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  99. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  100. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  101. data/lib/ruby/whatsapp/schedule.rb +15 -0
  102. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  103. data/lib/ruby/whatsapp/version.rb +1 -1
  104. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  105. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  106. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  107. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  108. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  109. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  110. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  111. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  112. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  113. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  114. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  115. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  116. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  117. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  118. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  119. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  120. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  121. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  122. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  123. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  124. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  125. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  126. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  127. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  128. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  129. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  130. metadata +100 -1
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>