ruby-whatsapp 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +276 -634
  4. data/docs/README.md +73 -0
  5. data/docs/business_phone_number/README.md +232 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/profile.md +220 -0
  8. data/docs/configuration.md +172 -0
  9. data/docs/errors.md +208 -0
  10. data/docs/media/README.md +161 -0
  11. data/docs/message_templates/README.md +206 -0
  12. data/docs/message_templates/authentication.md +107 -0
  13. data/docs/message_templates/carousel.md +103 -0
  14. data/docs/message_templates/components.md +179 -0
  15. data/docs/message_templates/library.md +98 -0
  16. data/docs/message_templates/limited_time_offer.md +94 -0
  17. data/docs/message_templates/responses.md +173 -0
  18. data/docs/message_templates/standard.md +138 -0
  19. data/docs/messages/README.md +135 -0
  20. data/docs/messages/address.md +86 -0
  21. data/docs/messages/audio.md +43 -0
  22. data/docs/messages/contacts.md +129 -0
  23. data/docs/messages/document.md +49 -0
  24. data/docs/messages/image.md +59 -0
  25. data/docs/messages/interactive.md +281 -0
  26. data/docs/messages/location.md +51 -0
  27. data/docs/messages/location_request.md +59 -0
  28. data/docs/messages/mark_message_as_read.md +84 -0
  29. data/docs/messages/reaction.md +59 -0
  30. data/docs/messages/sticker.md +40 -0
  31. data/docs/messages/template.md +166 -0
  32. data/docs/messages/text.md +55 -0
  33. data/docs/messages/video.md +41 -0
  34. data/docs/subscribed_app/README.md +139 -0
  35. data/docs/webhooks/README.md +209 -0
  36. data/docs/webhooks/account_alerts.md +44 -0
  37. data/docs/webhooks/account_review_update.md +29 -0
  38. data/docs/webhooks/account_update.md +54 -0
  39. data/docs/webhooks/automatic_events.md +47 -0
  40. data/docs/webhooks/business_capability_update.md +40 -0
  41. data/docs/webhooks/history.md +44 -0
  42. data/docs/webhooks/message_template_components_update.md +46 -0
  43. data/docs/webhooks/message_template_quality_update.md +49 -0
  44. data/docs/webhooks/message_template_status_update.md +55 -0
  45. data/docs/webhooks/messages.md +385 -0
  46. data/docs/webhooks/partner_solutions.md +35 -0
  47. data/docs/webhooks/payment_configuration_update.md +40 -0
  48. data/docs/webhooks/phone_number_name_update.md +44 -0
  49. data/docs/webhooks/phone_number_quality_update.md +40 -0
  50. data/docs/webhooks/security.md +43 -0
  51. data/docs/webhooks/smb_app_state_sync.md +53 -0
  52. data/docs/webhooks/smb_message_echoes.md +54 -0
  53. data/docs/webhooks/template_category_update.md +52 -0
  54. data/docs/webhooks/user_preferences.md +48 -0
  55. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  56. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  57. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  58. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  59. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  60. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  61. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  62. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  63. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  64. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  65. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  66. data/lib/ruby/whatsapp/business_phone_number/transport.rb +11 -12
  67. data/lib/ruby/whatsapp/business_phone_number.rb +11 -4
  68. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  69. data/lib/ruby/whatsapp/path_building.rb +33 -0
  70. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  71. data/lib/ruby/whatsapp/version.rb +1 -1
  72. metadata +63 -1
@@ -0,0 +1,172 @@
1
+ # Configuration
2
+
3
+ `Whatsapp.configure` sets the global defaults every `Whatsapp::Client` is built from.
4
+ Call it once at boot.
5
+
6
+ ```ruby
7
+ Whatsapp.configure do |config|
8
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
9
+ config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
10
+ end
11
+ ```
12
+
13
+ ## Options
14
+
15
+ | Option | Default | Needed for |
16
+ | --- | --- | --- |
17
+ | `api_key` | `nil` | Everything — a Meta system-user or app access token |
18
+ | `phone_id` | `nil` | [Messages](messages/README.md), [media](media/README.md), [phone-number onboarding](business_phone_number/README.md) |
19
+ | `waba_id` | `nil` | [Template management](message_templates/README.md), [subscribed apps](subscribed_app/README.md), [the business account](business_phone_number/account.md) |
20
+ | `verify_token` | `nil` | The [webhook GET handshake](webhooks/README.md#verification-the-get-handshake) |
21
+ | `app_secret` | `nil` | [Webhook signature verification](webhooks/README.md#signature-the-post-check) |
22
+ | `host` | `"https://graph.facebook.com"` | Overriding the API host |
23
+ | `version` | `"v24.0"` | Pinning a Graph API version |
24
+ | `media_host_allowlist` | `["lookaside.fbsbx.com", "mmg.whatsapp.net", "graph.facebook.com"]` | [Media download safety](media/README.md#the-token-never-leaves-the-allowlist) |
25
+
26
+ A full configuration:
27
+
28
+ ```ruby
29
+ Whatsapp.configure do |config|
30
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
31
+ config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
32
+ config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
33
+ config.verify_token = ENV.fetch("WHATSAPP_VERIFY_TOKEN")
34
+ config.app_secret = ENV.fetch("WHATSAPP_APP_SECRET")
35
+ end
36
+ ```
37
+
38
+ In Rails, put that in `config/initializers/whatsapp.rb` and read from
39
+ `Rails.application.credentials` rather than `ENV`.
40
+
41
+ ## Which ID addresses what
42
+
43
+ The most common source of confusion. Two different Meta objects, two different
44
+ permissions:
45
+
46
+ ```
47
+ phone_id ──► Messages send_text!, send_template!, send_interactive!, ...
48
+ Media upload, get_url, download, delete
49
+ BusinessPhoneNumber RequestCode, VerifyCode, Register, Deregister
50
+ (permission: whatsapp_business_messaging)
51
+
52
+ waba_id ──► MessageTemplates create, list, find, update, delete, upsert
53
+ SubscribedApp List, Subscribe, Unsubscribe
54
+ BusinessPhoneNumber::Account Get, Update
55
+ (permission: whatsapp_business_management)
56
+ ```
57
+
58
+ `BusinessPhoneNumber` is the one module that appears on both sides: its onboarding
59
+ actions address a phone number, while its
60
+ [`Account`](business_phone_number/account.md) actions address the account that number
61
+ belongs to.
62
+
63
+ Calling a `waba_id` API without one raises before any request is made:
64
+
65
+ ```ruby
66
+ Whatsapp::MessageTemplates.new.list
67
+ # => Whatsapp::MessageTemplates::TemplateError: waba_id is required for template
68
+ # management; set it via Whatsapp.configure or Client.new(waba_id:)
69
+ ```
70
+
71
+ ## Secrets are redacted
72
+
73
+ `api_key`, `app_secret`, and `verify_token` never appear in `#inspect` output — not on
74
+ `Configuration`, not on `Client`, and therefore not on anything holding a client
75
+ (`Media`, `MessageTemplates`). This matters because error trackers routinely capture
76
+ local variables.
77
+
78
+ ```ruby
79
+ Whatsapp.configuration.inspect
80
+ # => "#<Whatsapp::Configuration host=\"https://graph.facebook.com\" version=\"v24.0\"
81
+ # api_key=[REDACTED] phone_id=\"106540352242922\" waba_id=nil
82
+ # verify_token=[REDACTED] app_secret=[REDACTED]>"
83
+
84
+ Whatsapp::Client.new.inspect
85
+ # => "#<Whatsapp::Client host=\"https://graph.facebook.com\" version=\"v24.0\"
86
+ # api_key=[REDACTED] phone_id=\"106540352242922\" waba_id=nil>"
87
+ ```
88
+
89
+ The same treatment applies to credentials in transit:
90
+ [`Register#inspect`](business_phone_number/README.md#registering) redacts the PIN and
91
+ `VerifyCode#inspect` redacts the OTP.
92
+
93
+ ## The client
94
+
95
+ Every API call goes through a `Whatsapp::Client`, which wraps a persistent HTTP
96
+ connection. All its constructor keywords default to `Whatsapp.configuration`:
97
+
98
+ ```ruby
99
+ Whatsapp::Client.new(
100
+ host: Whatsapp.configuration.host,
101
+ version: Whatsapp.configuration.version,
102
+ api_key: Whatsapp.configuration.api_key,
103
+ phone_id: Whatsapp.configuration.phone_id,
104
+ waba_id: Whatsapp.configuration.waba_id,
105
+ timeout: 30, # Client::DEFAULT_TIMEOUT, seconds
106
+ logger: nil
107
+ )
108
+ ```
109
+
110
+ Every entry point in the gem accepts an optional `client:`, which is how you address a
111
+ second phone number or business account without touching global state:
112
+
113
+ ```ruby
114
+ Whatsapp::Messages.send_text!(
115
+ to: "+15551234567", body: "Hi",
116
+ client: Whatsapp::Client.new(phone_id: "OTHER_PHONE_ID")
117
+ )
118
+
119
+ Whatsapp::MessageTemplates.new(client: Whatsapp::Client.new(waba_id: "OTHER_WABA_ID")).list
120
+ ```
121
+
122
+ ### Connection reuse
123
+
124
+ `Client#connection` memoizes `HTTP.persistent(host)`, so successive calls on the same
125
+ client reuse one TCP connection. Build a client once and hold it for a batch of sends
126
+ rather than constructing one per message:
127
+
128
+ ```ruby
129
+ client = Whatsapp::Client.new
130
+
131
+ recipients.each { |to| Whatsapp::Messages.send_text!(to:, body: "Hi", client:) }
132
+ ```
133
+
134
+ ### Timeouts
135
+
136
+ ```ruby
137
+ Whatsapp::Client.new(timeout: 10)
138
+ ```
139
+
140
+ Applies to the whole request. The default is 30 seconds.
141
+
142
+ ## Instrumentation
143
+
144
+ Pass a `Logger` to log every request and response:
145
+
146
+ ```ruby
147
+ client = Whatsapp::Client.new(logger: Logger.new($stdout))
148
+
149
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi", client: client)
150
+ # I, [...] INFO -- : POST https://graph.facebook.com/v24.0/106540352242922/messages
151
+ # I, [...] INFO -- : 200 OK
152
+ ```
153
+
154
+ | Event | Logged |
155
+ | --- | --- |
156
+ | Request start | `<VERB> <url>` at `info` |
157
+ | Response | `<status> <reason>` at `info` |
158
+ | Error | `<name>: <message>` at `error` |
159
+
160
+ > **The query string is deliberately dropped.** Media and template endpoints put IDs
161
+ > in query parameters; logging them whole is how identifiers end up in log aggregators
162
+ > forever. Only the path is logged.
163
+
164
+ In Rails, pass `Rails.logger`:
165
+
166
+ ```ruby
167
+ Whatsapp::Client.new(logger: Rails.logger)
168
+ ```
169
+
170
+ ---
171
+
172
+ See also [errors.md](errors.md) for what the client raises.
data/docs/errors.md ADDED
@@ -0,0 +1,208 @@
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::BusinessPhoneNumber::Error any phone-number-onboarding or account failure
26
+ ```
27
+
28
+ Each module raises **its own** error class rather than the generic
29
+ `Whatsapp::RequestError`, so you can rescue narrowly:
30
+
31
+ ```ruby
32
+ rescue Whatsapp::MessageTemplates::TemplateError => e
33
+ # a template problem specifically — retry, or surface to the operator
34
+ end
35
+ ```
36
+
37
+ > **`Whatsapp::Webhook::Error` is not an exception.** Despite the name, it is a value
38
+ > object modelling Meta's error payloads on `Status#errors` and
39
+ > `Message::Unknown#errors`. See
40
+ > [webhooks/messages.md](webhooks/messages.md#errors).
41
+
42
+ ## Validation errors are different
43
+
44
+ Local validation failures raise **`ActiveModel::ValidationError`**, not a gem class.
45
+ That is deliberate: a validation failure means *you* built something wrong, before any
46
+ network call happened, and ActiveModel's error object carries the per-attribute
47
+ detail.
48
+
49
+ ```ruby
50
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "x" * 5000)
51
+ # => ActiveModel::ValidationError: Validation failed:
52
+ # Body is too long (maximum is 4096 characters)
53
+ ```
54
+
55
+ Reach the structured detail through the exception's model:
56
+
57
+ ```ruby
58
+ begin
59
+ Whatsapp::Messages.send_image!(to: "+15551234567", caption: "x" * 2000)
60
+ rescue ActiveModel::ValidationError => e
61
+ e.model.errors.full_messages
62
+ # => ["Either id or link must be present", "Caption is too long (maximum is 1024 characters)"]
63
+ e.model.errors[:caption]
64
+ # => ["is too long (maximum is 1024 characters)"]
65
+ end
66
+ ```
67
+
68
+ Because every class runs `validate!` at the end of `initialize`, this raises at
69
+ **construction** time — the client is never touched.
70
+
71
+ Rescuing both kinds:
72
+
73
+ ```ruby
74
+ begin
75
+ Whatsapp::Messages.send_template!(to:, name:, language: { code: })
76
+ rescue ActiveModel::ValidationError => e
77
+ # our payload is wrong — a bug, or bad user input
78
+ rescue Whatsapp::Error => e
79
+ # Meta said no, or the network did
80
+ end
81
+ ```
82
+
83
+ ## Missing keywords
84
+
85
+ A required Ruby keyword that isn't supplied raises `ArgumentError`, not a validation
86
+ error — it never reaches the validator:
87
+
88
+ ```ruby
89
+ Whatsapp::Messages.send_text!(body: "Hi")
90
+ # => ArgumentError: missing keyword: :to
91
+ ```
92
+
93
+ ## API failures
94
+
95
+ Any non-2xx response raises the owning module's error class with a consistent message
96
+ shape:
97
+
98
+ ```
99
+ Failed to <action>: <status> - <body>
100
+ ```
101
+
102
+ ```ruby
103
+ # => Whatsapp::RequestError: Failed to send message: 400 Bad Request -
104
+ # {"error":{"message":"(#131009) Parameter value is not valid","code":131009}}
105
+
106
+ # => Whatsapp::Media::MediaError: Failed to upload media: 401 Unauthorized - {...}
107
+
108
+ # => Whatsapp::MessageTemplates::TemplateError: Failed to create template:
109
+ # 400 Bad Request - {"error":{"message":"Invalid parameter","code":100}}
110
+ ```
111
+
112
+ The response body is truncated at **500** characters, with `… (truncated)` appended —
113
+ Meta's error bodies can be long, and a full one in an exception message tends to end up
114
+ somewhere it shouldn't.
115
+
116
+ Actions used in these messages: `send message`, `mark message as read`,
117
+ `upload media`, `get media URL`, `download media`, `delete media`,
118
+ `list subscribed apps`, `subscribe app`, `unsubscribe app`,
119
+ `request verification code`, `verify code`, `register business phone number`,
120
+ `deregister business phone number`, plus the template CRUD verbs.
121
+
122
+ ## Missing configuration
123
+
124
+ The `waba_id` and `phone_id` modules check for their ID **before** building a request,
125
+ and say exactly how to supply it:
126
+
127
+ ```ruby
128
+ Whatsapp::MessageTemplates.new.list
129
+ # => Whatsapp::MessageTemplates::TemplateError: waba_id is required for template
130
+ # management; set it via Whatsapp.configure or Client.new(waba_id:)
131
+
132
+ Whatsapp::SubscribedApp::List.call
133
+ # => Whatsapp::SubscribedApp::Error: waba_id is required for subscribed apps;
134
+ # set it via Whatsapp.configure or Client.new(waba_id:)
135
+
136
+ Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
137
+ # => Whatsapp::BusinessPhoneNumber::Error: phone_id is required for the register edge;
138
+ # set it via Whatsapp.configure or Client.new(phone_id:)
139
+
140
+ Whatsapp::BusinessPhoneNumber::Account::Get.call
141
+ # => Whatsapp::BusinessPhoneNumber::Error: waba_id is required for business account
142
+ # details; set it via Whatsapp.configure or Client.new(waba_id:)
143
+ ```
144
+
145
+ Note the last two: `BusinessPhoneNumber` spans both IDs. Its onboarding actions need
146
+ `phone_id`; its [`Account`](business_phone_number/account.md) actions need `waba_id`.
147
+
148
+ See [configuration.md](configuration.md#which-id-addresses-what).
149
+
150
+ ## Argument errors from a module
151
+
152
+ Some misuse is caught before validation and raises the module's own error rather than
153
+ `ActiveModel::ValidationError` — these are wrong *calls*, not wrong *data*:
154
+
155
+ ```ruby
156
+ templates.upsert(name: "x", language: "en_US", ...)
157
+ # => TemplateError: #upsert requires `languages:` (an array of locale codes);
158
+ # use #create for a single one
159
+
160
+ templates.update(template_id: "123")
161
+ # => TemplateError: nothing to update: pass category, components or message_send_ttl_seconds
162
+
163
+ templates.delete(hsm_ids: %w[1 2], name: "x")
164
+ # => TemplateError: hsm_ids cannot be combined with name or hsm_id
165
+
166
+ Whatsapp::Messages.new(kind: :telepathy, payload: { to: "+1" })
167
+ # => Whatsapp::Messages::PayloadError: Unknown message kind: :telepathy.
168
+ # Known kinds: text, image, audio, video, document, sticker, contacts, reaction,
169
+ # location, address, location_request, template, interactive
170
+ ```
171
+
172
+ ## Interactive header errors
173
+
174
+ `Interactive::Header` raises `HeaderError` rather than adding a validation error,
175
+ because a header missing its content cannot be serialized at all:
176
+
177
+ ```ruby
178
+ Whatsapp::Messages.send_interactive!(
179
+ to: "+15551234567", type: :reply_buttons, body: "Pick one",
180
+ header: { type: "image" },
181
+ action: { buttons: [{ id: "a", title: "A" }] }
182
+ )
183
+ # => Whatsapp::Messages::Interactive::Header::HeaderError:
184
+ # Image link is required for image header
185
+ ```
186
+
187
+ ## Retrying
188
+
189
+ Meta's transient failures are ordinary HTTP: 429 for rate limiting, 5xx for outages.
190
+ The gem does not retry — that policy belongs to your job queue:
191
+
192
+ ```ruby
193
+ class SendWhatsappJob < ApplicationJob
194
+ retry_on Whatsapp::RequestError, wait: :polynomially_longer, attempts: 5
195
+ discard_on ActiveModel::ValidationError # our payload is wrong; retrying won't fix it
196
+
197
+ def perform(to:, body:)
198
+ Whatsapp::Messages.send_text!(to:, body:)
199
+ end
200
+ end
201
+ ```
202
+
203
+ Discarding on `ActiveModel::ValidationError` is the important half: a locally invalid
204
+ payload will fail identically on every attempt.
205
+
206
+ > **Rate limits worth knowing.** Template edits are capped at 10 per 30 days and 1 per
207
+ > 24 hours; `Register`/`Deregister` at 10 per number per 72-hour window (error
208
+ > `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>