sendly 4.3.0 → 4.5.0
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 +17 -0
- data/Gemfile.lock +5 -5
- data/README.md +103 -34
- data/lib/sendly/calls_resource.rb +1 -0
- data/lib/sendly/errors.rb +35 -20
- data/lib/sendly/messages.rb +23 -3
- data/lib/sendly/types.rb +13 -1
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/voice_resource.rb +3 -3
- data/lib/sendly/webhooks.rb +5 -0
- data/lib/sendly/whatsapp_resource.rb +92 -41
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 511e36e32d4475552b4000a026856bde62c06bd7f43a52b59646c347d7ff4b0c
|
|
4
|
+
data.tar.gz: 92531f77aa366320d2887a3b9a0ac9793c6562d507f3863577352830097f4d32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 000020f0a21c87fb3f4b33ca84fad93e36a73abaa936ca602b22c3065e7fbc3d09b2c0fe62b02f2080dbab1f2b4d37d8ad60648d08e0a0d022a71bbcd64b1ee6
|
|
7
|
+
data.tar.gz: fbc1718f201afe7895d433d989bf41e7c0938fac6c029dca8380b2d88620ef56d65851a258b9d3e39ad683652023e8468ef534c8a459ce930fcf35cc6d07ddba
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# sendly (Ruby)
|
|
2
2
|
|
|
3
|
+
## 4.5.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- **`Sendly::ValidationError#status_code` is the response's real status.** It was always 400, so a 422 (such as `whatsapp_window_closed` or `sms_fallback_not_supported`) reported 400. Code that compared `status_code` with 400 to spot a validation error should check the class (or `code`) instead. `ValidationError.new` takes an optional `status_code:`, 400 by default.
|
|
8
|
+
- Docs give 503, the status the API sends, instead of 502 for the WhatsApp and voice errors raised when WhatsApp or the carrier couldn't be reached: `whatsapp_send_failed` when the message never reached WhatsApp (not sent, and safe to send again with the same idempotency key), the WhatsApp signup, profile, conversational-components and calling errors, `voice_attach_failed` and `carrier_refused`. The API answers every 502 as a 503 with a `Sendly-Upstream-Status: 502` header. Nothing changes at runtime.
|
|
9
|
+
|
|
10
|
+
## 4.4.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- **WhatsApp to SMS fallback.** `messages.send(channel: "whatsapp", fallback_to_sms: true)` sends `fallbackToSms`; it was dropped before. When WhatsApp can't deliver the message (the 24-hour window is closed, WhatsApp refuses it, or reports it undeliverable later), it goes as an SMS from the same number, billed as an SMS. The new `Sendly::WhatsAppSmsFallback` (`channel`, `status`, `reason`, `message_id`, `credits_used`, `error`, `content_dropped`, with `pending?` and `sent?`, and `STATUSES` and `REASONS`) says what happened: it is `WhatsAppMessage#fallback`, and `WhatsAppMessage#fell_back?` is true when the SMS went. When the SMS went in place of the WhatsApp message, `status` is `"failed"` and `fallback.status` is `"sent"`. `Message` gains `fallback` (on a WhatsApp message from `messages.get` or `messages.list`) and `fallback_of` (on the SMS, the id of the WhatsApp message it replaced), and `to_h` includes them when present. The number must already be able to send SMS to the recipient (US and Canada), or the API answers 403 `sms_fallback_unavailable`; marketing and authentication templates, and media without a caption, get 422 `sms_fallback_not_supported`.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- **Errors carry the API's error code.** `e.code` is the code the API sent in `error`, such as `"whatsapp_window_closed"`, `"sms_fallback_unavailable"` or `"invalid_api_key"`, on every error class, so refusals with the same status can be told apart. Before, only an unrecognised status read a code, and from a `code` field the API doesn't send, so `e.code` was the class's own code (`"VALIDATION_ERROR"`, `"API_ERROR"` and so on). That code is still used when the body names none (an `error` that is a sentence, or no `error` at all); a body's `code` field is read when `error` isn't a code. Code that compared `e.code` with a class code for an API error should rescue the class instead, or compare with the API's code.
|
|
19
|
+
|
|
3
20
|
## 4.3.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
data/Gemfile.lock
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
sendly (4.
|
|
4
|
+
sendly (4.5.0)
|
|
5
5
|
faraday (~> 2.0)
|
|
6
6
|
faraday-retry (~> 2.0)
|
|
7
7
|
|
|
8
8
|
GEM
|
|
9
9
|
remote: https://rubygems.org/
|
|
10
10
|
specs:
|
|
11
|
-
addressable (2.
|
|
11
|
+
addressable (2.8.8)
|
|
12
12
|
public_suffix (>= 2.0.2, < 8.0)
|
|
13
13
|
ast (2.4.3)
|
|
14
14
|
bigdecimal (3.3.1)
|
|
@@ -36,7 +36,7 @@ GEM
|
|
|
36
36
|
ast (~> 2.4.1)
|
|
37
37
|
racc
|
|
38
38
|
prism (1.6.0)
|
|
39
|
-
public_suffix (
|
|
39
|
+
public_suffix (7.0.0)
|
|
40
40
|
racc (1.8.1)
|
|
41
41
|
rainbow (3.1.1)
|
|
42
42
|
rake (13.3.1)
|
|
@@ -74,7 +74,7 @@ GEM
|
|
|
74
74
|
unicode-emoji (~> 4.1)
|
|
75
75
|
unicode-emoji (4.1.0)
|
|
76
76
|
uri (1.1.1)
|
|
77
|
-
webmock (3.26.
|
|
77
|
+
webmock (3.26.1)
|
|
78
78
|
addressable (>= 2.8.0)
|
|
79
79
|
crack (>= 0.3.2)
|
|
80
80
|
hashdiff (>= 0.4.0, < 2.0.0)
|
|
@@ -92,4 +92,4 @@ DEPENDENCIES
|
|
|
92
92
|
webmock (~> 3.0)
|
|
93
93
|
|
|
94
94
|
BUNDLED WITH
|
|
95
|
-
2.
|
|
95
|
+
2.6.9
|
data/README.md
CHANGED
|
@@ -1153,11 +1153,11 @@ puts status.disabled?
|
|
|
1153
1153
|
|
|
1154
1154
|
Connect a number you own to WhatsApp, create Meta-reviewed message
|
|
1155
1155
|
templates, and send via `client.messages.send(channel: "whatsapp", ...)`.
|
|
1156
|
-
Connecting is
|
|
1156
|
+
Connecting is free (no monthly fee either). The first number always
|
|
1157
1157
|
ends with a human step: the signup returns a connect URL a person must open in
|
|
1158
1158
|
a browser and log in with Facebook to link their WhatsApp Business Account.
|
|
1159
1159
|
More numbers can then be added to that account with a 6-digit code Meta sends
|
|
1160
|
-
to the number, with no Facebook step (
|
|
1160
|
+
to the number, with no Facebook step (also free).
|
|
1161
1161
|
|
|
1162
1162
|
Sends go through `messages.send(channel: "whatsapp")` and need `sms:send`,
|
|
1163
1163
|
not `whatsapp:write`. Reads (`signup.get`, templates, the window, senders and
|
|
@@ -1191,16 +1191,14 @@ gives its slot back. Note: Meta
|
|
|
1191
1191
|
has paused marketing template delivery to US (+1) numbers.
|
|
1192
1192
|
|
|
1193
1193
|
```ruby
|
|
1194
|
-
# 1. Connect a number (
|
|
1194
|
+
# 1. Connect a number (free; a human must open the connect URL)
|
|
1195
1195
|
signup = client.whatsapp.signup.create(phone_number: "+15125550123")
|
|
1196
1196
|
puts "Have your user open: #{signup.connect_url}"
|
|
1197
1197
|
|
|
1198
1198
|
# 2. Poll until active. After the Facebook step the signup is "registering"
|
|
1199
1199
|
# while WhatsApp activates the number. Activation usually takes a few minutes
|
|
1200
1200
|
# but can take hours. If it hasn't finished about 6 hours after the session
|
|
1201
|
-
# began, the session fails with registration_timeout
|
|
1202
|
-
# If the connection fails, the $19 fee is refunded automatically; once a
|
|
1203
|
-
# number has connected, a later disconnect gets nothing back.
|
|
1201
|
+
# began, the session fails with registration_timeout.
|
|
1204
1202
|
status = client.whatsapp.signup.get(signup.id)
|
|
1205
1203
|
puts status.failure_reasons if status.failed? # e.g. ["waba_mismatch"]
|
|
1206
1204
|
|
|
@@ -1257,7 +1255,7 @@ puts added.status # "verifying"
|
|
|
1257
1255
|
# code has been submitted it is the newest code since the signup started, so
|
|
1258
1256
|
# after a resend it still shows the earlier code until the new one arrives.
|
|
1259
1257
|
# Once WhatsApp has checked a code, only a code that arrived after the last
|
|
1260
|
-
# submission or resend is returned. A
|
|
1258
|
+
# submission or resend is returned. A 503 whatsapp_verification_unavailable
|
|
1261
1259
|
# isn't counted, so the same unchecked code can come back, and submitting it
|
|
1262
1260
|
# again is safe.
|
|
1263
1261
|
code = client.whatsapp.signup.get(added.id).verification_code
|
|
@@ -1324,7 +1322,7 @@ WhatsApp errors map onto the usual classes:
|
|
|
1324
1322
|
while WhatsApp connections are unavailable. Only signup returns it; no send
|
|
1325
1323
|
does. Nothing is charged, the response carries a `Retry-After: 3600` header,
|
|
1326
1324
|
and `e.response_body["retryAfter"]` (3600) says how many seconds to wait; the client
|
|
1327
|
-
retries it like any 5xx before raising. After 5 failed
|
|
1325
|
+
retries it like any 5xx before raising. After 5 failed signups in
|
|
1328
1326
|
24 hours it raises `Sendly::RateLimitError` for 429
|
|
1329
1327
|
`whatsapp_signup_limit_reached`, which is not retried: try again the next
|
|
1330
1328
|
day.
|
|
@@ -1332,10 +1330,13 @@ WhatsApp errors map onto the usual classes:
|
|
|
1332
1330
|
`whatsapp_window_closed`); send a template instead.
|
|
1333
1331
|
- `whatsapp_send_failed` is a `Sendly::ValidationError` (422) when WhatsApp
|
|
1334
1332
|
refused the message, which is final (cached under the idempotency key and
|
|
1335
|
-
replayed for 24 hours), and a `Sendly::ServerError` (
|
|
1336
|
-
provably never reached
|
|
1337
|
-
|
|
1338
|
-
same
|
|
1333
|
+
replayed for 24 hours), and a `Sendly::ServerError` (503) when the message
|
|
1334
|
+
provably never reached WhatsApp, so it was not sent and is safe to resend
|
|
1335
|
+
under the same idempotency key; the 503 is never cached and the client
|
|
1336
|
+
retries it automatically, up to 3 times under the same key, before raising.
|
|
1337
|
+
Neither is charged. With `fallback_to_sms: true`, a 422 means the SMS
|
|
1338
|
+
couldn't go either, and `e.response_body["fallback"]` says why; see
|
|
1339
|
+
[SMS fallback](#sms-fallback).
|
|
1339
1340
|
- `whatsapp_send_unconfirmed` is a `Sendly::APIError` with `status_code` 409:
|
|
1340
1341
|
the outcome is unknown. The message was marked failed and refunded but may
|
|
1341
1342
|
still be delivered, so check before sending it again (it could arrive
|
|
@@ -1363,11 +1364,11 @@ WhatsApp errors map onto the usual classes:
|
|
|
1363
1364
|
400 `display_name_required` and for 422 `whatsapp_verification_start_failed`
|
|
1364
1365
|
(WhatsApp refused to verify the number; final), `Sendly::APIError` (409) for
|
|
1365
1366
|
`whatsapp_signup_in_progress` and `whatsapp_already_enabled`, and
|
|
1366
|
-
`Sendly::ServerError` for
|
|
1367
|
+
`Sendly::ServerError` for 503 `whatsapp_verification_start_failed` (WhatsApp
|
|
1367
1368
|
couldn't start verifying the number; start again). Either failure fails the
|
|
1368
|
-
session
|
|
1369
|
-
|
|
1370
|
-
|
|
1369
|
+
session. With `business_account_id:` the client never retries a 5xx, a
|
|
1370
|
+
timeout or a dropped connection, because each retry would start a new
|
|
1371
|
+
session. A
|
|
1371
1372
|
Facebook signup for a number
|
|
1372
1373
|
that is being added by code gets 409 `whatsapp_verification_in_progress`,
|
|
1373
1374
|
with that session's id in `e.response_body["id"]`.
|
|
@@ -1375,9 +1376,9 @@ WhatsApp errors map onto the usual classes:
|
|
|
1375
1376
|
`invalid_verification_code` (not 6 digits) and 422
|
|
1376
1377
|
`whatsapp_verification_code_invalid` (a wrong code, with
|
|
1377
1378
|
`e.response_body["attemptsRemaining"]`); `Sendly::APIError` (409) for
|
|
1378
|
-
`whatsapp_verification_failed` (after 5 wrong codes the session fails
|
|
1379
|
-
|
|
1380
|
-
`signup_not_active`; and `Sendly::ServerError` for
|
|
1379
|
+
`whatsapp_verification_failed` (after 5 wrong codes the session fails),
|
|
1380
|
+
`whatsapp_verification_busy` (try again) and
|
|
1381
|
+
`signup_not_active`; and `Sendly::ServerError` for 503
|
|
1381
1382
|
`whatsapp_verification_unavailable` (the attempt isn't counted) and
|
|
1382
1383
|
`whatsapp_activation_pending` (the code was accepted but connecting didn't
|
|
1383
1384
|
finish; check back shortly with `signup.get`). The client never retries a
|
|
@@ -1387,13 +1388,13 @@ WhatsApp errors map onto the usual classes:
|
|
|
1387
1388
|
- `signup.resend` raises `Sendly::RateLimitError` for 429
|
|
1388
1389
|
`whatsapp_verification_resend_too_soon` at once, with the seconds to wait in
|
|
1389
1390
|
`e.retry_after`, and `whatsapp_verification_resend_failed` as a
|
|
1390
|
-
`Sendly::ValidationError` (422) or a `Sendly::ServerError` (
|
|
1391
|
+
`Sendly::ValidationError` (422) or a `Sendly::ServerError` (503).
|
|
1391
1392
|
- `senders.upload_profile_photo` raises `Sendly::ValidationError` for 400
|
|
1392
1393
|
`whatsapp_profile_photo_invalid` (not a JPEG or PNG) and `file_required`,
|
|
1393
1394
|
and `Sendly::APIError` with `status_code` 413 for
|
|
1394
1395
|
`whatsapp_profile_photo_too_large`. The photo methods raise
|
|
1395
|
-
`Sendly::ServerError` for
|
|
1396
|
-
conversational component methods for
|
|
1396
|
+
`Sendly::ServerError` for 503 `whatsapp_profile_update_failed`, and the
|
|
1397
|
+
conversational component methods for 503
|
|
1397
1398
|
`whatsapp_conversational_components_fetch_failed` and
|
|
1398
1399
|
`whatsapp_conversational_components_update_failed`; an invalid list is a
|
|
1399
1400
|
`Sendly::ValidationError` (400 `invalid_request`) with a message naming the
|
|
@@ -1403,10 +1404,78 @@ WhatsApp errors map onto the usual classes:
|
|
|
1403
1404
|
(switch voice on for the number first), `Sendly::ValidationError` for 422
|
|
1404
1405
|
`whatsapp_calling_unavailable` (Meta only allows calling once the account may
|
|
1405
1406
|
message at least 2,000 people a day and the display name is approved), and
|
|
1406
|
-
`Sendly::ServerError` for
|
|
1407
|
+
`Sendly::ServerError` for 503 `whatsapp_calling_update_failed`.
|
|
1407
1408
|
- A sender method for a number that isn't connected raises
|
|
1408
1409
|
`Sendly::NotFoundError` (404 `whatsapp_sender_not_connected`).
|
|
1409
1410
|
|
|
1411
|
+
### SMS fallback
|
|
1412
|
+
|
|
1413
|
+
Pass `fallback_to_sms: true` and a WhatsApp message that can't be delivered
|
|
1414
|
+
goes as an SMS from the same number instead.
|
|
1415
|
+
|
|
1416
|
+
```ruby
|
|
1417
|
+
begin
|
|
1418
|
+
message = client.messages.send(
|
|
1419
|
+
channel: "whatsapp",
|
|
1420
|
+
to: "+15555550100",
|
|
1421
|
+
from: "+15125550123",
|
|
1422
|
+
text: "Your table is ready!",
|
|
1423
|
+
fallback_to_sms: true
|
|
1424
|
+
)
|
|
1425
|
+
puts message.fallback.status # "pending", "sent", "failed", "unavailable" or "skipped"
|
|
1426
|
+
puts message.fallback.reason # "window_closed", "whatsapp_refused" or "whatsapp_failed"
|
|
1427
|
+
if message.fell_back? # the SMS went instead (message.status is "failed")
|
|
1428
|
+
sms = client.messages.get(message.fallback.message_id)
|
|
1429
|
+
puts sms.fallback_of # the WhatsApp message's id
|
|
1430
|
+
end
|
|
1431
|
+
rescue Sendly::Error => e
|
|
1432
|
+
body = e.response_body || {}
|
|
1433
|
+
case e.code
|
|
1434
|
+
when "sms_fallback_unavailable"
|
|
1435
|
+
# 403: this number can't send SMS to the recipient; nothing was sent
|
|
1436
|
+
when "sms_fallback_not_supported"
|
|
1437
|
+
puts body["reason"] # marketing_template, authentication_template or media_without_caption
|
|
1438
|
+
else
|
|
1439
|
+
raise unless body["fallback"]
|
|
1440
|
+
# 422 whatsapp_window_closed or whatsapp_send_failed: the SMS couldn't go
|
|
1441
|
+
# either. A closed window also carries the WhatsApp message's id.
|
|
1442
|
+
fallback = Sendly::WhatsAppSmsFallback.new(body["fallback"])
|
|
1443
|
+
puts "#{e.code} #{body["id"]} #{fallback.status}: #{fallback.error}"
|
|
1444
|
+
end
|
|
1445
|
+
end
|
|
1446
|
+
```
|
|
1447
|
+
|
|
1448
|
+
- It falls back when the 24-hour window is closed (the SMS goes at once:
|
|
1449
|
+
free-form text and media go only within 24 hours of the customer's last
|
|
1450
|
+
message, templates any time), when WhatsApp refuses the message, or when
|
|
1451
|
+
WhatsApp reports it undeliverable later. Never after WhatsApp reports it
|
|
1452
|
+
delivered or read, and at most once per message.
|
|
1453
|
+
- The number must already be able to send SMS to the recipient (a verified
|
|
1454
|
+
toll-free number, or a local number on a 10DLC campaign, to a US or Canada
|
|
1455
|
+
number), or the send is refused with 403 `sms_fallback_unavailable`
|
|
1456
|
+
before anything is sent.
|
|
1457
|
+
- Marketing and authentication templates, and media without a caption, can't
|
|
1458
|
+
fall back: 422 `sms_fallback_not_supported`. A utility template falls
|
|
1459
|
+
back as its text header, body and footer with the variables filled in, and
|
|
1460
|
+
its URL and phone buttons as `label: target`; quick replies are dropped and
|
|
1461
|
+
listed in `fallback.content_dropped`.
|
|
1462
|
+
- Billing: the WhatsApp charge is refunded (a closed window is never charged)
|
|
1463
|
+
and the SMS is billed as an SMS. A message that fell back at once comes
|
|
1464
|
+
back with `status` `"failed"`, `credits_used` 0 and `fallback.status` `"sent"`; `fallback.credits_used` is
|
|
1465
|
+
what the SMS cost.
|
|
1466
|
+
- No SMS goes on a 409 `whatsapp_send_unconfirmed` or a 503
|
|
1467
|
+
`whatsapp_send_failed`. The status is `"unavailable"` when SMS rules stop it
|
|
1468
|
+
(the contact opted out, a promotional-looking text in the recipient's quiet
|
|
1469
|
+
hours, restricted content), with the reason in `fallback.error`, and
|
|
1470
|
+
`"skipped"` for a WhatsApp error an SMS can't fix.
|
|
1471
|
+
- There is no time-based fallback: a message WhatsApp accepted but hasn't
|
|
1472
|
+
delivered, for example to a phone that is off, stays `"pending"`, and no SMS
|
|
1473
|
+
goes unless WhatsApp later reports a failure.
|
|
1474
|
+
- `messages.get` and `messages.list` return the same fallback on the WhatsApp message, so check a
|
|
1475
|
+
pending one later. The SMS carries `fallback_of`, the WhatsApp message's id.
|
|
1476
|
+
|
|
1477
|
+
Guide: [Send WhatsApp with an SMS Fallback](https://sendly.live/docs/whatsapp/send-with-fallback).
|
|
1478
|
+
|
|
1410
1479
|
## RCS
|
|
1411
1480
|
|
|
1412
1481
|
Send branded rich messages — text with suggested replies and actions, or
|
|
@@ -1750,8 +1819,8 @@ string), `invalid_voice_mode`, `agent_required`,
|
|
|
1750
1819
|
the address could not be validated, and `e.response_body["suggested"]` holds
|
|
1751
1820
|
a corrected address when one was found); `Sendly::APIError` with
|
|
1752
1821
|
`status_code` 409 for `agent_disabled`, `agent_limit` (20 agents per
|
|
1753
|
-
workspace) and `agent_in_use`; and `Sendly::ServerError` for
|
|
1754
|
-
`voice_attach_failed
|
|
1822
|
+
workspace) and `agent_in_use`; and `Sendly::ServerError` for 503
|
|
1823
|
+
`voice_attach_failed`, `carrier_refused` and `voice_unavailable`,
|
|
1755
1824
|
raised after the client has already retried the 5xx on its own. Not every
|
|
1756
1825
|
`carrier_refused` is worth retrying: when the message says the number
|
|
1757
1826
|
couldn't be found for emergency registration, retrying won't help, so
|
|
@@ -1770,12 +1839,12 @@ begin
|
|
|
1770
1839
|
rescue Sendly::AuthenticationError => e
|
|
1771
1840
|
puts "Invalid API key"
|
|
1772
1841
|
rescue Sendly::RateLimitError => e
|
|
1773
|
-
if e.
|
|
1842
|
+
if e.code == "too_many_failed_key_attempts"
|
|
1774
1843
|
# Repeated wrong API keys from this address locked it out for up to
|
|
1775
1844
|
# 5 minutes. Fix the key; do not retry until the lockout ends.
|
|
1776
1845
|
puts "Locked out for #{e.retry_after}s: check the API key"
|
|
1777
1846
|
else
|
|
1778
|
-
puts "Rate limited (#{e.
|
|
1847
|
+
puts "Rate limited (#{e.code}), retry after #{e.retry_after} seconds"
|
|
1779
1848
|
end
|
|
1780
1849
|
rescue Sendly::InsufficientCreditsError => e
|
|
1781
1850
|
puts "Add more credits to your account"
|
|
@@ -1786,17 +1855,18 @@ rescue Sendly::NotFoundError => e
|
|
|
1786
1855
|
rescue Sendly::NetworkError => e
|
|
1787
1856
|
puts "Network error: #{e.message}"
|
|
1788
1857
|
rescue Sendly::Error => e
|
|
1789
|
-
puts "Error: #{e.message} (#{e.
|
|
1858
|
+
puts "Error: #{e.message} (#{e.code})"
|
|
1790
1859
|
end
|
|
1791
1860
|
```
|
|
1792
1861
|
|
|
1793
1862
|
Every error is a `Sendly::Error`, which carries `message`, `code`,
|
|
1794
1863
|
`status_code`, `details` and `response_body` — the parsed JSON body of the
|
|
1795
1864
|
API's response, or `nil` for an error raised before the request went out.
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1865
|
+
`code` is the API's own error code, such as `"whatsapp_window_closed"` or
|
|
1866
|
+
`"sms_fallback_unavailable"` (the body's `"error"`), so refusals with the same
|
|
1867
|
+
status can be told apart. When the body names no code, `code` is the SDK's
|
|
1868
|
+
name for the class (`"VALIDATION_ERROR"`, `"RATE_LIMIT_EXCEEDED"` and so on).
|
|
1869
|
+
Some refusals put more in that body than a message: a 409 `agent_in_use` lists the
|
|
1800
1870
|
numbers under `"numbers"`, a 422 `invalid_address` carries a corrected
|
|
1801
1871
|
address under `"suggested"`.
|
|
1802
1872
|
|
|
@@ -1809,7 +1879,7 @@ endpoint.
|
|
|
1809
1879
|
| Class | Raised for |
|
|
1810
1880
|
|-------|-----------|
|
|
1811
1881
|
| `Sendly::AuthenticationError` | 401, and a missing or malformed API key at construction |
|
|
1812
|
-
| `Sendly::ValidationError` | 400 and 422; `field_errors` holds the API's `errors` list. `status_code` reads 400 for both, so tell them apart by `
|
|
1882
|
+
| `Sendly::ValidationError` | 400 and 422; `field_errors` holds the API's `errors` list. `status_code` reads 400 for both, so tell them apart by `code` |
|
|
1813
1883
|
| `Sendly::InsufficientCreditsError` | 402 |
|
|
1814
1884
|
| `Sendly::NotFoundError` | 404, including a feature that is not enabled for you |
|
|
1815
1885
|
| `Sendly::RateLimitError` | 429; `retry_after` in seconds when the API sent one |
|
|
@@ -2066,7 +2136,6 @@ client.enterprise.workspaces.list_keys("ws_xxx")
|
|
|
2066
2136
|
# Enterprise-level account, credits and billing
|
|
2067
2137
|
client.enterprise.get_account
|
|
2068
2138
|
client.enterprise.credits.get
|
|
2069
|
-
client.enterprise.credits.deposit(amount: 100_000, description: "Q4 top-up")
|
|
2070
2139
|
client.enterprise.settings.get_auto_top_up
|
|
2071
2140
|
client.enterprise.settings.update_auto_top_up(enabled: true, threshold: 1000, amount: 10_000)
|
|
2072
2141
|
client.enterprise.billing.get_breakdown(period: "30d")
|
|
@@ -55,6 +55,7 @@ module Sendly
|
|
|
55
55
|
ring_timeout callee_declined callee_busy caller_cancelled room_closed_unanswered
|
|
56
56
|
agent_left_unanswered agent_caller_never_joined invalid_number destination_rejected
|
|
57
57
|
max_duration credits_exhausted media_aborted peer_connection_lost room_closed agent_left
|
|
58
|
+
transferred voicemail voicemail_unavailable
|
|
58
59
|
setup_failed agent_dispatch_failed agent_api_unreachable agent_already_ended
|
|
59
60
|
ended
|
|
60
61
|
].freeze
|
data/lib/sendly/errors.rb
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module Sendly
|
|
4
4
|
# Base error class for all Sendly errors
|
|
5
5
|
class Error < StandardError
|
|
6
|
-
# @return [String, nil]
|
|
6
|
+
# @return [String, nil] The API's error code, such as
|
|
7
|
+
# +"whatsapp_window_closed"+ or +"sms_fallback_unavailable"+, for an
|
|
8
|
+
# error built from an API response that names one. Otherwise the
|
|
9
|
+
# class's own code, such as +"VALIDATION_ERROR"+.
|
|
7
10
|
attr_reader :code
|
|
8
11
|
|
|
9
12
|
# @return [Hash, nil] Additional error details
|
|
@@ -40,8 +43,8 @@ module Sendly
|
|
|
40
43
|
|
|
41
44
|
# Raised when the API key is invalid or missing
|
|
42
45
|
class AuthenticationError < Error
|
|
43
|
-
def initialize(message = "Invalid or missing API key")
|
|
44
|
-
super(message, code: "AUTHENTICATION_ERROR", status_code: 401)
|
|
46
|
+
def initialize(message = "Invalid or missing API key", code: nil)
|
|
47
|
+
super(message, code: code || "AUTHENTICATION_ERROR", status_code: 401)
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
|
|
@@ -50,16 +53,16 @@ module Sendly
|
|
|
50
53
|
# @return [Integer, nil] Seconds to wait before retrying
|
|
51
54
|
attr_reader :retry_after
|
|
52
55
|
|
|
53
|
-
def initialize(message = "Rate limit exceeded", retry_after: nil)
|
|
56
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil, code: nil)
|
|
54
57
|
@retry_after = retry_after
|
|
55
|
-
super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429)
|
|
58
|
+
super(message, code: code || "RATE_LIMIT_EXCEEDED", status_code: 429)
|
|
56
59
|
end
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
# Raised when the account has insufficient credits
|
|
60
63
|
class InsufficientCreditsError < Error
|
|
61
|
-
def initialize(message = "Insufficient credits")
|
|
62
|
-
super(message, code: "INSUFFICIENT_CREDITS", status_code: 402)
|
|
64
|
+
def initialize(message = "Insufficient credits", code: nil)
|
|
65
|
+
super(message, code: code || "INSUFFICIENT_CREDITS", status_code: 402)
|
|
63
66
|
end
|
|
64
67
|
end
|
|
65
68
|
|
|
@@ -70,16 +73,16 @@ module Sendly
|
|
|
70
73
|
# e.g. +[{ "path" => "brand.ein", "message" => "Enter a 9-digit EIN" }]+
|
|
71
74
|
attr_reader :field_errors
|
|
72
75
|
|
|
73
|
-
def initialize(message = "Validation failed", field_errors: nil, details: nil)
|
|
76
|
+
def initialize(message = "Validation failed", field_errors: nil, details: nil, code: nil, status_code: 400)
|
|
74
77
|
@field_errors = field_errors
|
|
75
|
-
super(message, code: "VALIDATION_ERROR", details: details, status_code:
|
|
78
|
+
super(message, code: code || "VALIDATION_ERROR", details: details, status_code: status_code)
|
|
76
79
|
end
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# Raised when the requested resource is not found
|
|
80
83
|
class NotFoundError < Error
|
|
81
|
-
def initialize(message = "Resource not found")
|
|
82
|
-
super(message, code: "NOT_FOUND", status_code: 404)
|
|
84
|
+
def initialize(message = "Resource not found", code: nil)
|
|
85
|
+
super(message, code: code || "NOT_FOUND", status_code: 404)
|
|
83
86
|
end
|
|
84
87
|
end
|
|
85
88
|
|
|
@@ -107,38 +110,50 @@ module Sendly
|
|
|
107
110
|
|
|
108
111
|
# Raised for server errors (5xx)
|
|
109
112
|
class ServerError < Error
|
|
110
|
-
def initialize(message = "Server error occurred", status_code: 500)
|
|
111
|
-
super(message, code: "SERVER_ERROR", status_code: status_code)
|
|
113
|
+
def initialize(message = "Server error occurred", status_code: 500, code: nil)
|
|
114
|
+
super(message, code: code || "SERVER_ERROR", status_code: status_code)
|
|
112
115
|
end
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
# Convert API response to appropriate error
|
|
116
119
|
class ErrorFactory
|
|
120
|
+
API_ERROR_CODE = /\A[a-z][a-z0-9_]*\z/
|
|
121
|
+
private_constant :API_ERROR_CODE
|
|
122
|
+
|
|
117
123
|
def self.from_response(status, body, retry_after_header: nil)
|
|
118
124
|
message = body["message"] || body["error"] || "Unknown error"
|
|
119
|
-
code = body
|
|
125
|
+
code = api_error_code(body)
|
|
120
126
|
details = body["details"]
|
|
121
127
|
|
|
122
128
|
error = case status
|
|
123
129
|
when 400, 422
|
|
124
|
-
ValidationError.new(message, details: details, field_errors: body["errors"])
|
|
130
|
+
ValidationError.new(message, details: details, field_errors: body["errors"], code: code, status_code: status)
|
|
125
131
|
when 401
|
|
126
|
-
AuthenticationError.new(message)
|
|
132
|
+
AuthenticationError.new(message, code: code)
|
|
127
133
|
when 402
|
|
128
|
-
InsufficientCreditsError.new(message)
|
|
134
|
+
InsufficientCreditsError.new(message, code: code)
|
|
129
135
|
when 404
|
|
130
|
-
NotFoundError.new(message)
|
|
136
|
+
NotFoundError.new(message, code: code)
|
|
131
137
|
when 429
|
|
132
138
|
retry_after = parse_retry_after(retry_after_header) || body["retry_after"] || body["retryAfter"]
|
|
133
|
-
RateLimitError.new(message, retry_after: retry_after)
|
|
139
|
+
RateLimitError.new(message, retry_after: retry_after, code: code)
|
|
134
140
|
when 500..599
|
|
135
|
-
ServerError.new(message, status_code: status)
|
|
141
|
+
ServerError.new(message, status_code: status, code: code)
|
|
136
142
|
else
|
|
137
143
|
APIError.new(message, status_code: status, code: code, details: details)
|
|
138
144
|
end
|
|
139
145
|
error.with_response_body(body)
|
|
140
146
|
end
|
|
141
147
|
|
|
148
|
+
def self.api_error_code(body)
|
|
149
|
+
error = body["error"]
|
|
150
|
+
return error.strip if error.is_a?(String) && error.strip.match?(API_ERROR_CODE)
|
|
151
|
+
|
|
152
|
+
code = body["code"]
|
|
153
|
+
code.is_a?(String) && !code.strip.empty? ? code : nil
|
|
154
|
+
end
|
|
155
|
+
private_class_method :api_error_code
|
|
156
|
+
|
|
142
157
|
def self.parse_retry_after(value)
|
|
143
158
|
text = value.to_s.strip
|
|
144
159
|
return text.to_i if text.match?(/\A\d+\z/)
|
data/lib/sendly/messages.rb
CHANGED
|
@@ -62,9 +62,17 @@ module Sendly
|
|
|
62
62
|
# { action: { text: ..., postbackData: ..., url: ... } }. Passed
|
|
63
63
|
# through verbatim, so use the camelCase keys shown. Suggestions have
|
|
64
64
|
# no SMS form and are dropped on a fallback.
|
|
65
|
-
# @param fallback_to_sms [Boolean] RCS
|
|
65
|
+
# @param fallback_to_sms [Boolean] RCS: deliver +text+ as plain SMS
|
|
66
66
|
# when the recipient can't receive RCS (default true). Pass false to
|
|
67
|
-
# fail with 422 +rcs_not_supported_for_recipient+ instead.
|
|
67
|
+
# fail with 422 +rcs_not_supported_for_recipient+ instead. WhatsApp:
|
|
68
|
+
# default false. With true, a message WhatsApp can't deliver (the
|
|
69
|
+
# 24-hour window is closed, WhatsApp refuses it, or reports it
|
|
70
|
+
# undeliverable later) goes as an SMS from the same number, billed as
|
|
71
|
+
# an SMS; +message.fallback+ ({Sendly::WhatsAppSmsFallback}) says what
|
|
72
|
+
# happened. The number must already be able to send SMS to the
|
|
73
|
+
# recipient (US and Canada), or the API answers 403
|
|
74
|
+
# +sms_fallback_unavailable+; marketing and authentication templates,
|
|
75
|
+
# and media without a caption, get 422 +sms_fallback_not_supported+.
|
|
68
76
|
# @param idempotency_key [String] Idempotency key for this operation
|
|
69
77
|
# (1-255 printable ASCII characters). The SDK already generates a key
|
|
70
78
|
# per logical request automatically, so the server can dedupe the
|
|
@@ -78,7 +86,7 @@ module Sendly
|
|
|
78
86
|
# WhatsApp the 422 +whatsapp_send_failed+ when WhatsApp refused the
|
|
79
87
|
# message (final, not retried, not charged; cached under the
|
|
80
88
|
# idempotency key and replayed for 24 hours)
|
|
81
|
-
# @raise [Sendly::ServerError] On WhatsApp, the
|
|
89
|
+
# @raise [Sendly::ServerError] On WhatsApp, the 503 +whatsapp_send_failed+:
|
|
82
90
|
# the message provably never reached the carrier, so it was not sent
|
|
83
91
|
# and is safe to send again. It isn't charged and is never cached, so
|
|
84
92
|
# the client retries it like any 5xx first under the same idempotency
|
|
@@ -130,6 +138,17 @@ module Sendly
|
|
|
130
138
|
# puts message.whatsapp.kind # "template"
|
|
131
139
|
# puts message.credits_used # priced by country + category
|
|
132
140
|
#
|
|
141
|
+
# @example WhatsApp with an SMS fallback
|
|
142
|
+
# message = client.messages.send(
|
|
143
|
+
# channel: "whatsapp",
|
|
144
|
+
# to: "+15551234567",
|
|
145
|
+
# from: "+15559876543",
|
|
146
|
+
# text: "Your table is ready!",
|
|
147
|
+
# fallback_to_sms: true
|
|
148
|
+
# )
|
|
149
|
+
# puts message.fallback&.status # "sent" when it went as an SMS
|
|
150
|
+
# puts message.fell_back?
|
|
151
|
+
#
|
|
133
152
|
# @example RCS text with suggested replies and actions
|
|
134
153
|
# message = client.messages.send(
|
|
135
154
|
# channel: "rcs",
|
|
@@ -189,6 +208,7 @@ module Sendly
|
|
|
189
208
|
body[:mediaUrls] = media_urls if has_media
|
|
190
209
|
body[:template] = template if template
|
|
191
210
|
body[:metadata] = metadata if metadata
|
|
211
|
+
body[:fallbackToSms] = fallback_to_sms unless fallback_to_sms.nil?
|
|
192
212
|
|
|
193
213
|
response = client.post("/messages", body, idempotency_key: idempotency_key)
|
|
194
214
|
return WhatsAppMessage.new(response)
|
data/lib/sendly/types.rb
CHANGED
|
@@ -69,6 +69,14 @@ module Sendly
|
|
|
69
69
|
# @return [String] "sms", "mms", "rcs" or "whatsapp"; "sms" when the response does not say
|
|
70
70
|
attr_reader :message_format
|
|
71
71
|
|
|
72
|
+
# @return [Sendly::WhatsAppSmsFallback, nil] On a WhatsApp message sent
|
|
73
|
+
# with +fallback_to_sms+, what happened to its SMS fallback
|
|
74
|
+
attr_reader :fallback
|
|
75
|
+
|
|
76
|
+
# @return [String, nil] On an SMS fallback, the id of the WhatsApp
|
|
77
|
+
# message it replaced
|
|
78
|
+
attr_reader :fallback_of
|
|
79
|
+
|
|
72
80
|
# Message status constants (sending removed - doesn't exist in database)
|
|
73
81
|
STATUSES = %w[queued sent delivered failed bounced retrying].freeze
|
|
74
82
|
|
|
@@ -98,6 +106,8 @@ module Sendly
|
|
|
98
106
|
@ai_metadata = data["aiMetadata"]
|
|
99
107
|
@media_urls = data["mediaUrls"] || data["media_urls"] || []
|
|
100
108
|
@message_format = data["messageFormat"] || data["message_format"] || "sms"
|
|
109
|
+
@fallback = data["fallback"].is_a?(Hash) ? WhatsAppSmsFallback.new(data["fallback"]) : nil
|
|
110
|
+
@fallback_of = data["fallbackOf"] || data["fallback_of"]
|
|
101
111
|
end
|
|
102
112
|
|
|
103
113
|
# Check if message was delivered
|
|
@@ -143,7 +153,9 @@ module Sendly
|
|
|
143
153
|
metadata: metadata,
|
|
144
154
|
ai_metadata: ai_metadata,
|
|
145
155
|
media_urls: media_urls,
|
|
146
|
-
message_format: message_format
|
|
156
|
+
message_format: message_format,
|
|
157
|
+
fallback: fallback&.to_h,
|
|
158
|
+
fallback_of: fallback_of
|
|
147
159
|
}.compact
|
|
148
160
|
end
|
|
149
161
|
|
data/lib/sendly/version.rb
CHANGED
|
@@ -402,7 +402,7 @@ module Sendly
|
|
|
402
402
|
# @raise [Sendly::NotFoundError] HTTP 404 +number_not_found+ / +agent_not_found+
|
|
403
403
|
# @raise [Sendly::APIError] HTTP 409 +agent_disabled+ (the agent is
|
|
404
404
|
# switched off), 403 +forbidden+ / +live_key_required+
|
|
405
|
-
# @raise [Sendly::ServerError] HTTP
|
|
405
|
+
# @raise [Sendly::ServerError] HTTP 503 +voice_attach_failed+ (voice could
|
|
406
406
|
# not be switched on; try again) or 503 +voice_unavailable+, after the
|
|
407
407
|
# client's automatic retries. Either can follow a mode alone on a number
|
|
408
408
|
# whose voice is off.
|
|
@@ -454,7 +454,7 @@ module Sendly
|
|
|
454
454
|
# 422 +invalid_address+ when the address could not be validated, with a
|
|
455
455
|
# corrected address (or +nil+) in +e.response_body["suggested"]+
|
|
456
456
|
# @raise [Sendly::NotFoundError] HTTP 404 +number_not_found+
|
|
457
|
-
# @raise [Sendly::ServerError] HTTP
|
|
457
|
+
# @raise [Sendly::ServerError] HTTP 503 +carrier_refused+ when the
|
|
458
458
|
# registration was refused, raised after the client has already retried
|
|
459
459
|
# the 5xx on its own. When the message says the number couldn't be
|
|
460
460
|
# found for emergency registration, retrying won't help: contact
|
|
@@ -691,7 +691,7 @@ module Sendly
|
|
|
691
691
|
# +agent_disabled+ / +agent_limit+ / +agent_in_use+ (409) and +forbidden+
|
|
692
692
|
# / +live_key_required+ / +insufficient_permissions+ (403); and
|
|
693
693
|
# {Sendly::ServerError} (not an +APIError+) for +voice_attach_failed+ /
|
|
694
|
-
# +carrier_refused+
|
|
694
|
+
# +carrier_refused+ / +voice_unavailable+ (503) and
|
|
695
695
|
# +voice_internal_error+ (500). Every API error keeps the parsed body on
|
|
696
696
|
# {Sendly::Error#response_body}. The full list is {Call::ERROR_CODES}.
|
|
697
697
|
#
|
data/lib/sendly/webhooks.rb
CHANGED
|
@@ -103,6 +103,11 @@ module Sendly
|
|
|
103
103
|
EVENT_SHORT_CODE_REJECTED = "short_code.rejected"
|
|
104
104
|
EVENT_SHORT_CODE_FILED = "short_code.filed"
|
|
105
105
|
EVENT_SHORT_CODE_LIVE = "short_code.live"
|
|
106
|
+
EVENT_SHORT_CODE_SUSPENDED = "short_code.suspended"
|
|
107
|
+
EVENT_SHORT_CODE_REACTIVATED = "short_code.reactivated"
|
|
108
|
+
EVENT_SHORT_CODE_CANCELLED = "short_code.cancelled"
|
|
109
|
+
EVENT_SHORT_CODE_PAYMENT_SUCCEEDED = "short_code.payment_succeeded"
|
|
110
|
+
EVENT_SHORT_CODE_PAYMENT_FAILED = "short_code.payment_failed"
|
|
106
111
|
|
|
107
112
|
# Source of a list-health event. Frozen enum — new values will be
|
|
108
113
|
# added in minor SDK versions, never removed.
|
|
@@ -63,8 +63,8 @@ module Sendly
|
|
|
63
63
|
# The status of a WhatsApp signup. +status+ is +"initiated"+,
|
|
64
64
|
# +"registering"+ (WhatsApp is activating the number; activation usually
|
|
65
65
|
# takes a few minutes but can take hours, and a session that hasn't
|
|
66
|
-
# finished about 6 hours after it began fails with +registration_timeout+
|
|
67
|
-
#
|
|
66
|
+
# finished about 6 hours after it began fails with +registration_timeout+),
|
|
67
|
+
# +"verifying"+ (a number being added to an
|
|
68
68
|
# already-connected account, waiting for its code), +"active"+ or
|
|
69
69
|
# +"failed"+. The API does not send +"expired"+,
|
|
70
70
|
# so {#expired?} is never true; it stays for compatibility.
|
|
@@ -72,7 +72,8 @@ module Sendly
|
|
|
72
72
|
# +"active"+ (from the start for a number added by code); it is nil while
|
|
73
73
|
# +"initiated"+ or +"registering"+ and after a failure.
|
|
74
74
|
# +failure_reasons+ is set when the status is +"failed"+, with one code:
|
|
75
|
-
# +setup_fee_payment_failed
|
|
75
|
+
# +setup_fee_payment_failed+ (the connection couldn't be started),
|
|
76
|
+
# +signup_abandoned+, +meta_exchange_failed+,
|
|
76
77
|
# +registration_failed+, +waba_already_connected+, +waba_mismatch+ (the
|
|
77
78
|
# WhatsApp Business Account chosen in the Facebook step doesn't hold the
|
|
78
79
|
# verified number), +registration_timeout+ (activation hadn't finished
|
|
@@ -80,7 +81,6 @@ module Sendly
|
|
|
80
81
|
# +verification_start_failed+ (WhatsApp couldn't start verifying an added
|
|
81
82
|
# number), +verification_failed+ (too many wrong codes) or
|
|
82
83
|
# +verification_expired+ (a verifying session left untouched for an hour).
|
|
83
|
-
# If the connection fails, the $19 fee is refunded automatically.
|
|
84
84
|
#
|
|
85
85
|
# While the status is +"verifying"+, +verification_method+ (+"sms"+ or
|
|
86
86
|
# +"voice"+) and +verification_attempts_remaining+ are set, and on
|
|
@@ -90,7 +90,7 @@ module Sendly
|
|
|
90
90
|
# started, so after a resend it still shows the earlier code until the new
|
|
91
91
|
# one arrives. Once WhatsApp has checked a code, only a code that arrived
|
|
92
92
|
# after the last submission or resend is returned. A submission answered
|
|
93
|
-
# with
|
|
93
|
+
# with 503 +whatsapp_verification_unavailable+ is not counted, so the same
|
|
94
94
|
# unchecked code can come back, and submitting it again with
|
|
95
95
|
# {WhatsAppSignupResource#verify} is safe. All three are nil in any other
|
|
96
96
|
# status.
|
|
@@ -420,6 +420,51 @@ module Sendly
|
|
|
420
420
|
end
|
|
421
421
|
end
|
|
422
422
|
|
|
423
|
+
# The SMS fallback of a WhatsApp message sent with +fallback_to_sms: true+.
|
|
424
|
+
# +status+ is +"pending"+ until WhatsApp's outcome is known, +"sent"+ when
|
|
425
|
+
# the SMS went, +"failed"+ when the SMS was refused or credits ran out,
|
|
426
|
+
# +"unavailable"+ when the SMS could no longer be sent from this number,
|
|
427
|
+
# and +"skipped"+ when the WhatsApp failure isn't one an SMS fixes.
|
|
428
|
+
# +reason+ is what made the message fall back (+"window_closed"+,
|
|
429
|
+
# +"whatsapp_refused"+ or +"whatsapp_failed"+). +message_id+ is the SMS
|
|
430
|
+
# message's id once it exists, +credits_used+ what the SMS cost, +error+
|
|
431
|
+
# why the SMS didn't go, in plain English, and +content_dropped+ the
|
|
432
|
+
# template parts with no SMS form, such as +"quick_replies"+. Values the
|
|
433
|
+
# API adds later come through unchanged.
|
|
434
|
+
class WhatsAppSmsFallback
|
|
435
|
+
attr_reader :channel, :status, :reason, :message_id, :credits_used,
|
|
436
|
+
:error, :content_dropped
|
|
437
|
+
|
|
438
|
+
STATUSES = %w[pending sent failed unavailable skipped].freeze
|
|
439
|
+
REASONS = %w[window_closed whatsapp_refused whatsapp_failed].freeze
|
|
440
|
+
|
|
441
|
+
def initialize(data)
|
|
442
|
+
@channel = data["channel"] || "sms"
|
|
443
|
+
@status = data["status"]
|
|
444
|
+
@reason = data["reason"]
|
|
445
|
+
@message_id = data["messageId"] || data["message_id"]
|
|
446
|
+
@credits_used = data["creditsUsed"] || data["credits_used"]
|
|
447
|
+
@error = data["error"]
|
|
448
|
+
@content_dropped = data["contentDropped"] || data["content_dropped"]
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def pending?
|
|
452
|
+
status == "pending"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def sent?
|
|
456
|
+
status == "sent"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def to_h
|
|
460
|
+
{
|
|
461
|
+
channel: channel, status: status, reason: reason,
|
|
462
|
+
message_id: message_id, credits_used: credits_used, error: error,
|
|
463
|
+
content_dropped: content_dropped
|
|
464
|
+
}.compact
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
423
468
|
# A sent WhatsApp message. Unlike {Message}, +segments+ is always 1
|
|
424
469
|
# (WhatsApp has no segment concept), +text+ is the caption for a media
|
|
425
470
|
# send (pass it as +text+ with +media_urls+) and nil for template sends
|
|
@@ -431,10 +476,14 @@ module Sendly
|
|
|
431
476
|
# utility price of 12 credits. Templates are priced by category and
|
|
432
477
|
# destination country; countries without a listed price use 33
|
|
433
478
|
# (marketing), 12 (utility) and 12 (authentication) credits. A failed
|
|
434
|
-
# send gives its slot back.
|
|
479
|
+
# send gives its slot back. +fallback+ is a {WhatsAppSmsFallback} when the
|
|
480
|
+
# send asked for +fallback_to_sms+: when WhatsApp couldn't take the
|
|
481
|
+
# message and it went as an SMS instead, +status+ is +"failed"+ and
|
|
482
|
+
# +fallback.status+ is +"sent"+ (check with {#fell_back?}).
|
|
435
483
|
class WhatsAppMessage
|
|
436
484
|
attr_reader :id, :channel, :message_format, :to, :from, :text, :status,
|
|
437
|
-
:segments, :credits_used, :whatsapp, :created_at, :metadata
|
|
485
|
+
:segments, :credits_used, :whatsapp, :created_at, :metadata,
|
|
486
|
+
:fallback
|
|
438
487
|
|
|
439
488
|
def initialize(data)
|
|
440
489
|
@id = data["id"]
|
|
@@ -449,6 +498,11 @@ module Sendly
|
|
|
449
498
|
@whatsapp = data["whatsapp"] ? WhatsAppMessageDetails.new(data["whatsapp"]) : nil
|
|
450
499
|
@created_at = data["createdAt"] || data["created_at"]
|
|
451
500
|
@metadata = data["metadata"]
|
|
501
|
+
@fallback = data["fallback"].is_a?(Hash) ? WhatsAppSmsFallback.new(data["fallback"]) : nil
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def fell_back?
|
|
505
|
+
fallback&.sent? || false
|
|
452
506
|
end
|
|
453
507
|
|
|
454
508
|
def delivered?
|
|
@@ -464,7 +518,7 @@ module Sendly
|
|
|
464
518
|
id: id, channel: channel, message_format: message_format,
|
|
465
519
|
to: to, from: from, text: text, status: status, segments: segments,
|
|
466
520
|
credits_used: credits_used, whatsapp: whatsapp&.to_h,
|
|
467
|
-
created_at: created_at, metadata: metadata
|
|
521
|
+
created_at: created_at, metadata: metadata, fallback: fallback&.to_h
|
|
468
522
|
}.compact
|
|
469
523
|
end
|
|
470
524
|
end
|
|
@@ -479,34 +533,31 @@ module Sendly
|
|
|
479
533
|
|
|
480
534
|
# Start connecting a number to WhatsApp.
|
|
481
535
|
#
|
|
482
|
-
#
|
|
536
|
+
# Connecting a number is free (no setup or monthly fee). Returns a
|
|
483
537
|
# +connect_url+. Completing the connection requires a human: hand the
|
|
484
538
|
# URL to your user — they open it in a browser and log in with Facebook
|
|
485
539
|
# to link their WhatsApp Business Account. Then poll {#get} until the
|
|
486
540
|
# status is +"active"+.
|
|
487
541
|
#
|
|
488
542
|
# Calling again for a number with an in-flight signup returns the
|
|
489
|
-
# existing signup (same +connect_url+)
|
|
543
|
+
# existing signup (same +connect_url+). Requires
|
|
490
544
|
# a live API key with the +whatsapp:write+ scope (a test key gets 403
|
|
491
545
|
# +whatsapp_requires_live_key+) and, in a team workspace, an owner or
|
|
492
546
|
# admin (+settings:write+). After the Facebook step the signup stays
|
|
493
547
|
# +"registering"+ while WhatsApp activates the number. Activation usually
|
|
494
548
|
# takes a few minutes but can take hours. If it hasn't finished about 6
|
|
495
549
|
# hours after the session began, the session fails with
|
|
496
|
-
# +registration_timeout
|
|
497
|
-
# fails, the $19 fee is refunded automatically; once the number has
|
|
498
|
-
# connected there is no refund, and a later disconnect gets nothing
|
|
499
|
-
# back.
|
|
550
|
+
# +registration_timeout+.
|
|
500
551
|
#
|
|
501
552
|
# To add a number to a WhatsApp Business Account this workspace has
|
|
502
553
|
# already connected, pass its +business_account_id+ (as shown on
|
|
503
554
|
# {WhatsAppSender#business_account_id}). There is no Facebook step and no
|
|
504
|
-
# +connect_url+:
|
|
555
|
+
# +connect_url+: it is free too, Meta sends the number a
|
|
505
556
|
# 6-digit code by text or voice call, and the session is +"verifying"+
|
|
506
557
|
# until you submit the code with {#verify}. Calling again for a number
|
|
507
|
-
# that is verifying returns the same session without
|
|
508
|
-
#
|
|
509
|
-
#
|
|
558
|
+
# that is verifying returns the same session without sending a second
|
|
559
|
+
# code; a verifying session older than 3 hours is expired and a new one
|
|
560
|
+
# is started.
|
|
510
561
|
#
|
|
511
562
|
# @param phone_number [String] The number to connect, in E.164 format.
|
|
512
563
|
# Must be an active number in your workspace.
|
|
@@ -521,20 +572,20 @@ module Sendly
|
|
|
521
572
|
# +business_account_id+.
|
|
522
573
|
# @return [WhatsAppSignupSession]
|
|
523
574
|
# @raise [Sendly::ServerError] +whatsapp_unavailable+ (503) while
|
|
524
|
-
# WhatsApp connections are unavailable.
|
|
575
|
+
# WhatsApp connections are unavailable. Only
|
|
525
576
|
# signup returns it, with +retryAfter+ 3600 in the body
|
|
526
577
|
# (+e.response_body["retryAfter"]+) and a +Retry-After: 3600+ header.
|
|
527
578
|
# The client retries it like any 5xx before raising.
|
|
528
579
|
# @raise [Sendly::RateLimitError] +whatsapp_signup_limit_reached+ (429)
|
|
529
|
-
# after 5 failed
|
|
530
|
-
# the next day.
|
|
580
|
+
# after 5 failed signups (or 20 cancelled) in 24 hours. Not retried;
|
|
581
|
+
# try again the next day.
|
|
531
582
|
# @raise [Sendly::NotFoundError] +whatsapp_business_account_not_found+
|
|
532
583
|
# (404) when +business_account_id+ isn't connected in this workspace
|
|
533
584
|
# with an active number.
|
|
534
585
|
# @raise [Sendly::ValidationError] +display_name_required+ (400) when no
|
|
535
586
|
# display name is given and the account has none to reuse, and
|
|
536
587
|
# +whatsapp_verification_start_failed+ (422) when WhatsApp refused to
|
|
537
|
-
# verify the number: final, the session failed
|
|
588
|
+
# verify the number: final, the session failed.
|
|
538
589
|
# @raise [Sendly::APIError] 409 +whatsapp_signup_in_progress+ (a Facebook
|
|
539
590
|
# connection for the number is in flight; +e.response_body["id"]+ is
|
|
540
591
|
# that session), 409 +whatsapp_verification_in_progress+ (a Facebook
|
|
@@ -543,14 +594,14 @@ module Sendly
|
|
|
543
594
|
# still being added, so try again in a moment) or 409
|
|
544
595
|
# +whatsapp_already_enabled+.
|
|
545
596
|
# @raise [Sendly::InsufficientCreditsError] 402 +payment_method_required+
|
|
546
|
-
# or +payment_failed
|
|
547
|
-
# @raise [Sendly::ServerError] +whatsapp_verification_start_failed+ (
|
|
548
|
-
# when WhatsApp couldn't start verifying the number: the session failed
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
#
|
|
597
|
+
# or +payment_failed+: not returned while connecting a number is free.
|
|
598
|
+
# @raise [Sendly::ServerError] +whatsapp_verification_start_failed+ (503)
|
|
599
|
+
# when WhatsApp couldn't start verifying the number: the session failed,
|
|
600
|
+
# so start again. With +business_account_id+, a 5xx is raised at once
|
|
601
|
+
# and never retried by the client, because each retry would start a
|
|
602
|
+
# new session (a timeout or dropped connection is never retried
|
|
603
|
+
# either). Without it the client retries a 5xx before raising, as
|
|
604
|
+
# before.
|
|
554
605
|
#
|
|
555
606
|
# @example
|
|
556
607
|
# signup = client.whatsapp.signup.create(phone_number: "+15559876543")
|
|
@@ -595,14 +646,14 @@ module Sendly
|
|
|
595
646
|
# (422) for a wrong code, with the attempts left in
|
|
596
647
|
# +e.response_body["attemptsRemaining"]+
|
|
597
648
|
# @raise [Sendly::APIError] 409 +whatsapp_verification_failed+ after 5
|
|
598
|
-
# wrong codes (the session failed
|
|
649
|
+
# wrong codes (the session failed and
|
|
599
650
|
# +whatsapp_account.failed+ fires), 409 +whatsapp_verification_busy+
|
|
600
651
|
# while another code for the number is being checked (try again), or
|
|
601
652
|
# 409 +signup_not_active+ when the signup isn't waiting for a code or is
|
|
602
653
|
# older than 3 hours
|
|
603
654
|
# @raise [Sendly::NotFoundError] +signup_not_found+ (404)
|
|
604
|
-
# @raise [Sendly::ServerError]
|
|
605
|
-
# (WhatsApp couldn't check the code; the attempt isn't counted) or
|
|
655
|
+
# @raise [Sendly::ServerError] 503 +whatsapp_verification_unavailable+
|
|
656
|
+
# (WhatsApp couldn't check the code; the attempt isn't counted) or 503
|
|
606
657
|
# +whatsapp_activation_pending+ (the code was accepted but connecting
|
|
607
658
|
# the number didn't finish; check back shortly with {#get}). A 5xx, a
|
|
608
659
|
# timeout or a dropped connection is raised at once and never retried
|
|
@@ -641,7 +692,7 @@ module Sendly
|
|
|
641
692
|
# waiting for a code or is older than 3 hours
|
|
642
693
|
# @raise [Sendly::NotFoundError] +signup_not_found+ (404)
|
|
643
694
|
# @raise [Sendly::ServerError] +whatsapp_verification_resend_failed+
|
|
644
|
-
# (
|
|
695
|
+
# (503) when WhatsApp couldn't be reached. The client retries a 5xx
|
|
645
696
|
# before raising.
|
|
646
697
|
#
|
|
647
698
|
# @example
|
|
@@ -793,7 +844,7 @@ module Sendly
|
|
|
793
844
|
# @raise [Sendly::APIError] +whatsapp_profile_photo_too_large+ with
|
|
794
845
|
# +status_code+ 413 when the photo is over 5 MB
|
|
795
846
|
# @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
|
|
796
|
-
# @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (
|
|
847
|
+
# @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (503)
|
|
797
848
|
# when WhatsApp refused the image or couldn't be reached. A 5xx, a
|
|
798
849
|
# timeout or a dropped connection is raised at once and never retried
|
|
799
850
|
# by the client; check the image is
|
|
@@ -822,7 +873,7 @@ module Sendly
|
|
|
822
873
|
# @return [WhatsAppSenderProfile] The profile, normally with
|
|
823
874
|
# +profile_photo_url+ nil
|
|
824
875
|
# @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
|
|
825
|
-
# @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (
|
|
876
|
+
# @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (503).
|
|
826
877
|
# The client retries a 5xx before raising.
|
|
827
878
|
#
|
|
828
879
|
# @example
|
|
@@ -845,7 +896,7 @@ module Sendly
|
|
|
845
896
|
# @return [WhatsAppConversationalComponents]
|
|
846
897
|
# @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
|
|
847
898
|
# @raise [Sendly::ServerError]
|
|
848
|
-
# +whatsapp_conversational_components_fetch_failed+ (
|
|
899
|
+
# +whatsapp_conversational_components_fetch_failed+ (503). The client
|
|
849
900
|
# retries a 5xx before raising.
|
|
850
901
|
#
|
|
851
902
|
# @example
|
|
@@ -879,7 +930,7 @@ module Sendly
|
|
|
879
930
|
# +invalid_request+ (400) with a message naming the problem
|
|
880
931
|
# @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
|
|
881
932
|
# @raise [Sendly::ServerError]
|
|
882
|
-
# +whatsapp_conversational_components_update_failed+ (
|
|
933
|
+
# +whatsapp_conversational_components_update_failed+ (503). The client
|
|
883
934
|
# retries a 5xx before raising.
|
|
884
935
|
#
|
|
885
936
|
# @example
|
|
@@ -920,7 +971,7 @@ module Sendly
|
|
|
920
971
|
# when Meta refused: calling needs the account at the 2,000
|
|
921
972
|
# recipients a day messaging limit and an approved display name
|
|
922
973
|
# @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
|
|
923
|
-
# @raise [Sendly::ServerError] +whatsapp_calling_update_failed+ (
|
|
974
|
+
# @raise [Sendly::ServerError] +whatsapp_calling_update_failed+ (503).
|
|
924
975
|
# The client retries a 5xx before raising.
|
|
925
976
|
#
|
|
926
977
|
# @example
|
|
@@ -1099,7 +1150,7 @@ module Sendly
|
|
|
1099
1150
|
# create Meta-reviewed message templates, and send via
|
|
1100
1151
|
# +client.messages.send(channel: "whatsapp", ...)+.
|
|
1101
1152
|
#
|
|
1102
|
-
# Connecting a number is
|
|
1153
|
+
# Connecting a number is free (no setup or monthly fee). The first
|
|
1103
1154
|
# number always ends with a human step: {WhatsAppSignupResource#create}
|
|
1104
1155
|
# returns a +connect_url+ that a person must open in a browser and log in
|
|
1105
1156
|
# with Facebook to link their WhatsApp Business Account. More numbers can
|
|
@@ -1141,7 +1192,7 @@ module Sendly
|
|
|
1141
1192
|
# every method on this resource gets 404 +not_found+.
|
|
1142
1193
|
#
|
|
1143
1194
|
# @example Connect a number, create a template, then send
|
|
1144
|
-
# # 1. Connect (
|
|
1195
|
+
# # 1. Connect (free; a human must open the connect URL)
|
|
1145
1196
|
# signup = client.whatsapp.signup.create(phone_number: "+15559876543")
|
|
1146
1197
|
# puts "Have your user open: #{signup.connect_url}"
|
|
1147
1198
|
#
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sendly
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sendly
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -159,7 +159,7 @@ metadata:
|
|
|
159
159
|
source_code_uri: https://github.com/SendlyHQ/sendly-ruby
|
|
160
160
|
changelog_uri: https://github.com/SendlyHQ/sendly-ruby/blob/main/CHANGELOG.md
|
|
161
161
|
documentation_uri: https://sendly.live/docs
|
|
162
|
-
post_install_message:
|
|
162
|
+
post_install_message:
|
|
163
163
|
rdoc_options: []
|
|
164
164
|
require_paths:
|
|
165
165
|
- lib
|
|
@@ -174,8 +174,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
174
174
|
- !ruby/object:Gem::Version
|
|
175
175
|
version: '0'
|
|
176
176
|
requirements: []
|
|
177
|
-
rubygems_version: 3.
|
|
178
|
-
signing_key:
|
|
177
|
+
rubygems_version: 3.5.22
|
|
178
|
+
signing_key:
|
|
179
179
|
specification_version: 4
|
|
180
180
|
summary: Official Ruby SDK for the Sendly SMS API
|
|
181
181
|
test_files: []
|