sendly 4.4.0__tar.gz → 4.5.1__tar.gz

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. {sendly-4.4.0 → sendly-4.5.1}/CHANGELOG.md +11 -0
  2. {sendly-4.4.0 → sendly-4.5.1}/PKG-INFO +92 -19
  3. {sendly-4.4.0 → sendly-4.5.1}/README.md +91 -18
  4. {sendly-4.4.0 → sendly-4.5.1}/pyproject.toml +1 -1
  5. {sendly-4.4.0 → sendly-4.5.1}/sendly/__init__.py +1 -1
  6. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/messages.py +9 -4
  7. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/whatsapp.py +19 -18
  8. {sendly-4.4.0 → sendly-4.5.1}/sendly/types.py +7 -2
  9. {sendly-4.4.0 → sendly-4.5.1}/sendly/utils/http.py +1 -1
  10. {sendly-4.4.0 → sendly-4.5.1}/tests/test_whatsapp.py +15 -0
  11. {sendly-4.4.0 → sendly-4.5.1}/.github/header.svg +0 -0
  12. {sendly-4.4.0 → sendly-4.5.1}/.gitignore +0 -0
  13. {sendly-4.4.0 → sendly-4.5.1}/examples/async_example.py +0 -0
  14. {sendly-4.4.0 → sendly-4.5.1}/examples/error_handling.py +0 -0
  15. {sendly-4.4.0 → sendly-4.5.1}/examples/send_sms.py +0 -0
  16. {sendly-4.4.0 → sendly-4.5.1}/sendly/client.py +0 -0
  17. {sendly-4.4.0 → sendly-4.5.1}/sendly/errors.py +0 -0
  18. {sendly-4.4.0 → sendly-4.5.1}/sendly/py.typed +0 -0
  19. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/__init__.py +0 -0
  20. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/account.py +0 -0
  21. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/business_upgrade.py +0 -0
  22. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/calls.py +0 -0
  23. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/campaigns.py +0 -0
  24. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/contacts.py +0 -0
  25. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/conversations.py +0 -0
  26. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/drafts.py +0 -0
  27. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/enterprise.py +0 -0
  28. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/labels.py +0 -0
  29. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/links.py +0 -0
  30. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/media.py +0 -0
  31. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/numbers.py +0 -0
  32. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/rcs.py +0 -0
  33. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/rules.py +0 -0
  34. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/templates.py +0 -0
  35. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/tendlc.py +0 -0
  36. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/verify.py +0 -0
  37. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/voice.py +0 -0
  38. {sendly-4.4.0 → sendly-4.5.1}/sendly/resources/webhooks.py +0 -0
  39. {sendly-4.4.0 → sendly-4.5.1}/sendly/utils/__init__.py +0 -0
  40. {sendly-4.4.0 → sendly-4.5.1}/sendly/utils/validation.py +0 -0
  41. {sendly-4.4.0 → sendly-4.5.1}/sendly/webhooks.py +0 -0
  42. {sendly-4.4.0 → sendly-4.5.1}/tests/conftest.py +0 -0
  43. {sendly-4.4.0 → sendly-4.5.1}/tests/test_account.py +0 -0
  44. {sendly-4.4.0 → sendly-4.5.1}/tests/test_async_client.py +0 -0
  45. {sendly-4.4.0 → sendly-4.5.1}/tests/test_calls.py +0 -0
  46. {sendly-4.4.0 → sendly-4.5.1}/tests/test_campaigns.py +0 -0
  47. {sendly-4.4.0 → sendly-4.5.1}/tests/test_client.py +0 -0
  48. {sendly-4.4.0 → sendly-4.5.1}/tests/test_contacts.py +0 -0
  49. {sendly-4.4.0 → sendly-4.5.1}/tests/test_conversations.py +0 -0
  50. {sendly-4.4.0 → sendly-4.5.1}/tests/test_enterprise.py +0 -0
  51. {sendly-4.4.0 → sendly-4.5.1}/tests/test_errors.py +0 -0
  52. {sendly-4.4.0 → sendly-4.5.1}/tests/test_http_body_kwarg.py +0 -0
  53. {sendly-4.4.0 → sendly-4.5.1}/tests/test_http_retry.py +0 -0
  54. {sendly-4.4.0 → sendly-4.5.1}/tests/test_idempotency.py +0 -0
  55. {sendly-4.4.0 → sendly-4.5.1}/tests/test_media.py +0 -0
  56. {sendly-4.4.0 → sendly-4.5.1}/tests/test_messages.py +0 -0
  57. {sendly-4.4.0 → sendly-4.5.1}/tests/test_messages.py.bak +0 -0
  58. {sendly-4.4.0 → sendly-4.5.1}/tests/test_messages_batch.py +0 -0
  59. {sendly-4.4.0 → sendly-4.5.1}/tests/test_messages_schedule.py +0 -0
  60. {sendly-4.4.0 → sendly-4.5.1}/tests/test_messages_wire.py +0 -0
  61. {sendly-4.4.0 → sendly-4.5.1}/tests/test_numbers.py +0 -0
  62. {sendly-4.4.0 → sendly-4.5.1}/tests/test_path_ids.py +0 -0
  63. {sendly-4.4.0 → sendly-4.5.1}/tests/test_rcs.py +0 -0
  64. {sendly-4.4.0 → sendly-4.5.1}/tests/test_rcs_registration.py +0 -0
  65. {sendly-4.4.0 → sendly-4.5.1}/tests/test_rules.py +0 -0
  66. {sendly-4.4.0 → sendly-4.5.1}/tests/test_templates.py +0 -0
  67. {sendly-4.4.0 → sendly-4.5.1}/tests/test_tendlc.py +0 -0
  68. {sendly-4.4.0 → sendly-4.5.1}/tests/test_validation.py +0 -0
  69. {sendly-4.4.0 → sendly-4.5.1}/tests/test_voice.py +0 -0
  70. {sendly-4.4.0 → sendly-4.5.1}/tests/test_webhooks.py +0 -0
  71. {sendly-4.4.0 → sendly-4.5.1}/tests/test_webhooks_resource.py +0 -0
  72. {sendly-4.4.0 → sendly-4.5.1}/tests/test_whatsapp_extras.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # sendly (Python)
2
2
 
3
+ ## 4.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `WhatsAppMessage.fell_back` is `True` when the message went as an SMS instead (`fallback.status` is `"sent"`), matching Ruby's `fell_back?` and the other SDKs.
8
+
9
+ ### Patch Changes
10
+
11
+ - The async `messages.send` docstring said `fallback_to_sms` is RCS only; it documents the WhatsApp fallback too, like the sync one.
12
+ - 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.
13
+
3
14
  ## 4.4.0
4
15
 
5
16
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 4.4.0
3
+ Version: 4.5.1
4
4
  Summary: Official Sendly Python SDK for SMS messaging
5
5
  Project-URL: Homepage, https://sendly.live
6
6
  Project-URL: Documentation, https://sendly.live/docs
@@ -1355,7 +1355,7 @@ client.links.update(link.code, True)
1355
1355
 
1356
1356
  ## WhatsApp
1357
1357
 
1358
- Connect a number you own to WhatsApp ($19 one-time, no monthly fee), create
1358
+ Connect a number you own to WhatsApp ($9.99 one-time, no monthly fee), create
1359
1359
  Meta-reviewed message templates, and send on the `whatsapp` channel. Connecting
1360
1360
  the first number always ends with a human step: hand the `connect_url` to your
1361
1361
  user - they open it in a browser and log in with Facebook to link their
@@ -1388,9 +1388,10 @@ print(f'Open {signup.connect_url} and log in with Facebook')
1388
1388
  # number. Activation usually takes a few minutes but can take hours. If it
1389
1389
  # hasn't finished about 6 hours after the session began, the session fails
1390
1390
  # with registration_timeout and the fee is refunded ('failed', with
1391
- # signup.failure_reasons explaining why). If the connection fails, the $19 fee
1392
- # is refunded automatically; once a number has connected, a later disconnect
1393
- # gets nothing back.
1391
+ # signup.failure_reasons explaining why). The one-time $9.99 connection fee is
1392
+ # charged to your card when you start connecting the number and refunded
1393
+ # automatically if the connection fails; it is not refundable once the number
1394
+ # is connected.
1394
1395
 
1395
1396
  # 2. Create a template (Meta reviews it, usually 24-48h). category is required
1396
1397
  # (UTILITY, AUTHENTICATION or MARKETING) with no default, and an update can't
@@ -1474,7 +1475,7 @@ components = client.whatsapp.senders.get_conversational_components('+15555550123
1474
1475
  client.whatsapp.senders.set_calling('+15555550123', enabled=True)
1475
1476
 
1476
1477
  # Add another number to an account you already connected: no Facebook step.
1477
- # Same $19 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1478
+ # Same $9.99 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1478
1479
  senders = client.whatsapp.senders.list().senders
1479
1480
  account_id = next(
1480
1481
  s.business_account_id for s in senders if s.status == 'active' and s.business_account_id
@@ -1505,7 +1506,7 @@ codes it fails with `verification_failed` and the fee is refunded. Calls carry
1505
1506
  submitted, it is the newest code that has arrived since the signup started, so
1506
1507
  after a resend it still shows the earlier code until the new one arrives. Once
1507
1508
  WhatsApp has checked a code, only a code that arrived after the last
1508
- submission or resend is returned. A submission answered with 502
1509
+ submission or resend is returned. A submission answered with 503
1509
1510
  `whatsapp_verification_unavailable` is not counted, so the same unchecked code
1510
1511
  can come back, and submitting it again is safe.
1511
1512
 
@@ -1535,10 +1536,10 @@ Errors specific to WhatsApp:
1535
1536
  24 hours. It is final and not retried; try again the next day.
1536
1537
  - `whatsapp_send_failed` on a send: HTTP 422 when WhatsApp refused the message,
1537
1538
  which is final (cached under the idempotency key and replayed for 24 hours),
1538
- or HTTP 502 when the message provably never reached the carrier, so it was
1539
- not sent and is safe to send again. A 502 is never cached, and the SDK
1540
- retries it under the same idempotency key. Either way the message was not
1541
- charged.
1539
+ or HTTP 503 when the message provably never reached WhatsApp, so it was not
1540
+ sent and is safe to resend under the same idempotency key. A 503 is never
1541
+ cached, and the SDK retries it automatically, up to 3 times under the same
1542
+ key, before raising. Either way the message was not charged.
1542
1543
  - `whatsapp_send_unconfirmed` (HTTP 409) on a send: the outcome is unknown.
1543
1544
  The message was marked failed and refunded but may still be delivered, so
1544
1545
  check before sending it again (it could arrive twice). It is not retried
@@ -1556,18 +1557,18 @@ Errors specific to WhatsApp:
1556
1557
  marketing template without an opt-out button only gets a warning.
1557
1558
  - Profile photo: `file_required` and `whatsapp_profile_photo_invalid` (HTTP
1558
1559
  400), `whatsapp_profile_photo_too_large` (HTTP 413),
1559
- `whatsapp_profile_update_failed` (HTTP 502). Conversational components:
1560
+ `whatsapp_profile_update_failed` (HTTP 503). Conversational components:
1560
1561
  `invalid_request` (HTTP 400) with the reason,
1561
1562
  `whatsapp_conversational_components_fetch_failed` and
1562
- `whatsapp_conversational_components_update_failed` (HTTP 502). Calling:
1563
+ `whatsapp_conversational_components_update_failed` (HTTP 503). Calling:
1563
1564
  `voice_not_enabled` (HTTP 409), `whatsapp_calling_unavailable` (HTTP 422;
1564
1565
  WhatsApp only allows calling once the account may message 2,000 people a
1565
1566
  day and the display name is approved), `whatsapp_calling_update_failed`
1566
- (HTTP 502).
1567
+ (HTTP 503).
1567
1568
  - Adding a number by code: `whatsapp_business_account_not_found` (HTTP 404),
1568
1569
  `display_name_required` (HTTP 400), `whatsapp_signup_in_progress` and
1569
1570
  `whatsapp_already_enabled` (HTTP 409), and
1570
- `whatsapp_verification_start_failed` (HTTP 422 refused, 502 unreachable; the
1571
+ `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable; the
1571
1572
  fee is refunded either way). A Facebook `signup.create()` for a number being
1572
1573
  added by code gets `whatsapp_verification_in_progress` (HTTP 409).
1573
1574
  `verify()`: `invalid_verification_code` (HTTP 400, not 6 digits),
@@ -1575,9 +1576,9 @@ Errors specific to WhatsApp:
1575
1576
  `e.response.attempts_remaining`), `whatsapp_verification_failed` (HTTP 409,
1576
1577
  too many wrong codes), `whatsapp_verification_busy` (HTTP 409, retry),
1577
1578
  `whatsapp_verification_unavailable` and `whatsapp_activation_pending` (HTTP
1578
- 502). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1579
+ 503). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1579
1580
  `e.response.retry_after` seconds) and `whatsapp_verification_resend_failed`
1580
- (HTTP 422 or 502). Both raise `signup_not_active` (HTTP 409) once the signup
1581
+ (HTTP 422 or 503). Both raise `signup_not_active` (HTTP 409) once the signup
1581
1582
  stops waiting for a code, and `signup_not_found` (HTTP 404). The SDK never
1582
1583
  retries a `5xx`, a timeout or a network error from `signup.create()` with a
1583
1584
  `business_account_id` or from `verify()`; it retries `resend()` like any
@@ -1585,6 +1586,78 @@ Errors specific to WhatsApp:
1585
1586
 
1586
1587
  `message.whatsapp.message_id` is `None` until the first delivery report lands.
1587
1588
 
1589
+ ### SMS fallback
1590
+
1591
+ Pass `fallback_to_sms=True` and a WhatsApp message that can't be delivered
1592
+ goes as an SMS from the same number instead.
1593
+
1594
+ ```python
1595
+ from sendly import Sendly, SendlyError, WhatsAppSmsFallback
1596
+
1597
+ client = Sendly('sk_live_v1_YOUR_API_KEY')
1598
+
1599
+ try:
1600
+ message = client.messages.send(
1601
+ channel='whatsapp',
1602
+ to='+15555550100',
1603
+ from_='+15555550123',
1604
+ text='Your table is ready!',
1605
+ fallback_to_sms=True,
1606
+ )
1607
+ print(message.fallback.status) # 'pending', 'sent', 'failed', 'unavailable' or 'skipped'
1608
+ print(message.fallback.reason) # 'window_closed', 'whatsapp_refused' or 'whatsapp_failed'
1609
+ if message.fell_back and message.fallback.message_id:
1610
+ # The SMS went instead (message.status is 'failed')
1611
+ sms = client.messages.get(message.fallback.message_id)
1612
+ print(sms.fallback_of) # the WhatsApp message's id
1613
+ except SendlyError as e:
1614
+ extra = (e.response.model_extra or {}) if e.response else {}
1615
+ if e.code == 'sms_fallback_unavailable':
1616
+ pass # 403: this number can't send SMS to the recipient; nothing was sent
1617
+ elif e.code == 'sms_fallback_not_supported':
1618
+ print(extra.get('reason')) # marketing_template, authentication_template or media_without_caption
1619
+ elif extra.get('fallback'):
1620
+ # 422 whatsapp_window_closed or whatsapp_send_failed: the SMS couldn't go
1621
+ # either. A closed window also carries the WhatsApp message's id.
1622
+ fallback = WhatsAppSmsFallback.model_validate(extra['fallback'])
1623
+ print(e.code, extra.get('id'), fallback.status, fallback.error)
1624
+ else:
1625
+ raise
1626
+ ```
1627
+
1628
+ - It falls back when the 24-hour window is closed (the SMS goes at once:
1629
+ free-form text and media go only within 24 hours of the customer's last
1630
+ message, templates any time), when WhatsApp refuses the message, or when
1631
+ WhatsApp reports it undeliverable later. Never after WhatsApp reports it
1632
+ delivered or read, and at most once per message.
1633
+ - The number must already be able to send SMS to the recipient (a verified
1634
+ toll-free number, or a local number on a 10DLC campaign, to a US or Canada
1635
+ number), or the send is refused with HTTP 403 `sms_fallback_unavailable`
1636
+ before anything is sent.
1637
+ - Marketing and authentication templates, and media without a caption, can't
1638
+ fall back: HTTP 422 `sms_fallback_not_supported`. A utility template falls
1639
+ back as its text header, body and footer with the variables filled in, and
1640
+ its URL and phone buttons as `label: target`; quick replies are dropped and
1641
+ listed in `fallback.content_dropped`.
1642
+ - Billing: the WhatsApp charge is refunded (a closed window is never charged)
1643
+ and the SMS is billed as an SMS. A message that fell back at once comes
1644
+ back with `status == 'failed'`, `credits_used == 0` and
1645
+ `fallback.status == 'sent'` (`message.fell_back` is `True`);
1646
+ `fallback.credits_used` is what the SMS cost.
1647
+ - No SMS goes on an HTTP 409 `whatsapp_send_unconfirmed` or an HTTP 503
1648
+ `whatsapp_send_failed`. `fallback.status` is `'unavailable'` when SMS rules
1649
+ stop it (the contact opted out, a promotional-looking text in the
1650
+ recipient's quiet hours, restricted content), with the reason in
1651
+ `fallback.error`, and `'skipped'` for a WhatsApp error an SMS can't fix.
1652
+ - There is no time-based fallback: a message WhatsApp accepted but hasn't
1653
+ delivered, for example to a phone that is off, stays `'pending'`, and no
1654
+ SMS goes unless WhatsApp later reports a failure.
1655
+ - `messages.get()` and `messages.list()` return the same `fallback` on the
1656
+ WhatsApp message, so check a pending one later. The SMS carries
1657
+ `fallback_of`, the WhatsApp message's id.
1658
+
1659
+ Guide: [Send WhatsApp with an SMS Fallback](https://sendly.live/docs/whatsapp/send-with-fallback).
1660
+
1588
1661
  ## RCS
1589
1662
 
1590
1663
  Send branded, verified-sender messages on Android: rich cards, suggestion
@@ -1946,8 +2019,8 @@ default, or `CA`) are optional.
1946
2019
 
1947
2020
  Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
1948
2021
  `agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
1949
- `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
1950
- retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
2022
+ `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 503,
2023
+ retry shortly), `carrier_refused` (HTTP 503, retry shortly unless the message
1951
2024
  says the number couldn't be found for emergency registration: contact
1952
2025
  support), `voice_unavailable` (HTTP 503),
1953
2026
  `e911_not_applicable` (only US and Canadian numbers take an emergency
@@ -1320,7 +1320,7 @@ client.links.update(link.code, True)
1320
1320
 
1321
1321
  ## WhatsApp
1322
1322
 
1323
- Connect a number you own to WhatsApp ($19 one-time, no monthly fee), create
1323
+ Connect a number you own to WhatsApp ($9.99 one-time, no monthly fee), create
1324
1324
  Meta-reviewed message templates, and send on the `whatsapp` channel. Connecting
1325
1325
  the first number always ends with a human step: hand the `connect_url` to your
1326
1326
  user - they open it in a browser and log in with Facebook to link their
@@ -1353,9 +1353,10 @@ print(f'Open {signup.connect_url} and log in with Facebook')
1353
1353
  # number. Activation usually takes a few minutes but can take hours. If it
1354
1354
  # hasn't finished about 6 hours after the session began, the session fails
1355
1355
  # with registration_timeout and the fee is refunded ('failed', with
1356
- # signup.failure_reasons explaining why). If the connection fails, the $19 fee
1357
- # is refunded automatically; once a number has connected, a later disconnect
1358
- # gets nothing back.
1356
+ # signup.failure_reasons explaining why). The one-time $9.99 connection fee is
1357
+ # charged to your card when you start connecting the number and refunded
1358
+ # automatically if the connection fails; it is not refundable once the number
1359
+ # is connected.
1359
1360
 
1360
1361
  # 2. Create a template (Meta reviews it, usually 24-48h). category is required
1361
1362
  # (UTILITY, AUTHENTICATION or MARKETING) with no default, and an update can't
@@ -1439,7 +1440,7 @@ components = client.whatsapp.senders.get_conversational_components('+15555550123
1439
1440
  client.whatsapp.senders.set_calling('+15555550123', enabled=True)
1440
1441
 
1441
1442
  # Add another number to an account you already connected: no Facebook step.
1442
- # Same $19 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1443
+ # Same $9.99 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1443
1444
  senders = client.whatsapp.senders.list().senders
1444
1445
  account_id = next(
1445
1446
  s.business_account_id for s in senders if s.status == 'active' and s.business_account_id
@@ -1470,7 +1471,7 @@ codes it fails with `verification_failed` and the fee is refunded. Calls carry
1470
1471
  submitted, it is the newest code that has arrived since the signup started, so
1471
1472
  after a resend it still shows the earlier code until the new one arrives. Once
1472
1473
  WhatsApp has checked a code, only a code that arrived after the last
1473
- submission or resend is returned. A submission answered with 502
1474
+ submission or resend is returned. A submission answered with 503
1474
1475
  `whatsapp_verification_unavailable` is not counted, so the same unchecked code
1475
1476
  can come back, and submitting it again is safe.
1476
1477
 
@@ -1500,10 +1501,10 @@ Errors specific to WhatsApp:
1500
1501
  24 hours. It is final and not retried; try again the next day.
1501
1502
  - `whatsapp_send_failed` on a send: HTTP 422 when WhatsApp refused the message,
1502
1503
  which is final (cached under the idempotency key and replayed for 24 hours),
1503
- or HTTP 502 when the message provably never reached the carrier, so it was
1504
- not sent and is safe to send again. A 502 is never cached, and the SDK
1505
- retries it under the same idempotency key. Either way the message was not
1506
- charged.
1504
+ or HTTP 503 when the message provably never reached WhatsApp, so it was not
1505
+ sent and is safe to resend under the same idempotency key. A 503 is never
1506
+ cached, and the SDK retries it automatically, up to 3 times under the same
1507
+ key, before raising. Either way the message was not charged.
1507
1508
  - `whatsapp_send_unconfirmed` (HTTP 409) on a send: the outcome is unknown.
1508
1509
  The message was marked failed and refunded but may still be delivered, so
1509
1510
  check before sending it again (it could arrive twice). It is not retried
@@ -1521,18 +1522,18 @@ Errors specific to WhatsApp:
1521
1522
  marketing template without an opt-out button only gets a warning.
1522
1523
  - Profile photo: `file_required` and `whatsapp_profile_photo_invalid` (HTTP
1523
1524
  400), `whatsapp_profile_photo_too_large` (HTTP 413),
1524
- `whatsapp_profile_update_failed` (HTTP 502). Conversational components:
1525
+ `whatsapp_profile_update_failed` (HTTP 503). Conversational components:
1525
1526
  `invalid_request` (HTTP 400) with the reason,
1526
1527
  `whatsapp_conversational_components_fetch_failed` and
1527
- `whatsapp_conversational_components_update_failed` (HTTP 502). Calling:
1528
+ `whatsapp_conversational_components_update_failed` (HTTP 503). Calling:
1528
1529
  `voice_not_enabled` (HTTP 409), `whatsapp_calling_unavailable` (HTTP 422;
1529
1530
  WhatsApp only allows calling once the account may message 2,000 people a
1530
1531
  day and the display name is approved), `whatsapp_calling_update_failed`
1531
- (HTTP 502).
1532
+ (HTTP 503).
1532
1533
  - Adding a number by code: `whatsapp_business_account_not_found` (HTTP 404),
1533
1534
  `display_name_required` (HTTP 400), `whatsapp_signup_in_progress` and
1534
1535
  `whatsapp_already_enabled` (HTTP 409), and
1535
- `whatsapp_verification_start_failed` (HTTP 422 refused, 502 unreachable; the
1536
+ `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable; the
1536
1537
  fee is refunded either way). A Facebook `signup.create()` for a number being
1537
1538
  added by code gets `whatsapp_verification_in_progress` (HTTP 409).
1538
1539
  `verify()`: `invalid_verification_code` (HTTP 400, not 6 digits),
@@ -1540,9 +1541,9 @@ Errors specific to WhatsApp:
1540
1541
  `e.response.attempts_remaining`), `whatsapp_verification_failed` (HTTP 409,
1541
1542
  too many wrong codes), `whatsapp_verification_busy` (HTTP 409, retry),
1542
1543
  `whatsapp_verification_unavailable` and `whatsapp_activation_pending` (HTTP
1543
- 502). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1544
+ 503). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1544
1545
  `e.response.retry_after` seconds) and `whatsapp_verification_resend_failed`
1545
- (HTTP 422 or 502). Both raise `signup_not_active` (HTTP 409) once the signup
1546
+ (HTTP 422 or 503). Both raise `signup_not_active` (HTTP 409) once the signup
1546
1547
  stops waiting for a code, and `signup_not_found` (HTTP 404). The SDK never
1547
1548
  retries a `5xx`, a timeout or a network error from `signup.create()` with a
1548
1549
  `business_account_id` or from `verify()`; it retries `resend()` like any
@@ -1550,6 +1551,78 @@ Errors specific to WhatsApp:
1550
1551
 
1551
1552
  `message.whatsapp.message_id` is `None` until the first delivery report lands.
1552
1553
 
1554
+ ### SMS fallback
1555
+
1556
+ Pass `fallback_to_sms=True` and a WhatsApp message that can't be delivered
1557
+ goes as an SMS from the same number instead.
1558
+
1559
+ ```python
1560
+ from sendly import Sendly, SendlyError, WhatsAppSmsFallback
1561
+
1562
+ client = Sendly('sk_live_v1_YOUR_API_KEY')
1563
+
1564
+ try:
1565
+ message = client.messages.send(
1566
+ channel='whatsapp',
1567
+ to='+15555550100',
1568
+ from_='+15555550123',
1569
+ text='Your table is ready!',
1570
+ fallback_to_sms=True,
1571
+ )
1572
+ print(message.fallback.status) # 'pending', 'sent', 'failed', 'unavailable' or 'skipped'
1573
+ print(message.fallback.reason) # 'window_closed', 'whatsapp_refused' or 'whatsapp_failed'
1574
+ if message.fell_back and message.fallback.message_id:
1575
+ # The SMS went instead (message.status is 'failed')
1576
+ sms = client.messages.get(message.fallback.message_id)
1577
+ print(sms.fallback_of) # the WhatsApp message's id
1578
+ except SendlyError as e:
1579
+ extra = (e.response.model_extra or {}) if e.response else {}
1580
+ if e.code == 'sms_fallback_unavailable':
1581
+ pass # 403: this number can't send SMS to the recipient; nothing was sent
1582
+ elif e.code == 'sms_fallback_not_supported':
1583
+ print(extra.get('reason')) # marketing_template, authentication_template or media_without_caption
1584
+ elif extra.get('fallback'):
1585
+ # 422 whatsapp_window_closed or whatsapp_send_failed: the SMS couldn't go
1586
+ # either. A closed window also carries the WhatsApp message's id.
1587
+ fallback = WhatsAppSmsFallback.model_validate(extra['fallback'])
1588
+ print(e.code, extra.get('id'), fallback.status, fallback.error)
1589
+ else:
1590
+ raise
1591
+ ```
1592
+
1593
+ - It falls back when the 24-hour window is closed (the SMS goes at once:
1594
+ free-form text and media go only within 24 hours of the customer's last
1595
+ message, templates any time), when WhatsApp refuses the message, or when
1596
+ WhatsApp reports it undeliverable later. Never after WhatsApp reports it
1597
+ delivered or read, and at most once per message.
1598
+ - The number must already be able to send SMS to the recipient (a verified
1599
+ toll-free number, or a local number on a 10DLC campaign, to a US or Canada
1600
+ number), or the send is refused with HTTP 403 `sms_fallback_unavailable`
1601
+ before anything is sent.
1602
+ - Marketing and authentication templates, and media without a caption, can't
1603
+ fall back: HTTP 422 `sms_fallback_not_supported`. A utility template falls
1604
+ back as its text header, body and footer with the variables filled in, and
1605
+ its URL and phone buttons as `label: target`; quick replies are dropped and
1606
+ listed in `fallback.content_dropped`.
1607
+ - Billing: the WhatsApp charge is refunded (a closed window is never charged)
1608
+ and the SMS is billed as an SMS. A message that fell back at once comes
1609
+ back with `status == 'failed'`, `credits_used == 0` and
1610
+ `fallback.status == 'sent'` (`message.fell_back` is `True`);
1611
+ `fallback.credits_used` is what the SMS cost.
1612
+ - No SMS goes on an HTTP 409 `whatsapp_send_unconfirmed` or an HTTP 503
1613
+ `whatsapp_send_failed`. `fallback.status` is `'unavailable'` when SMS rules
1614
+ stop it (the contact opted out, a promotional-looking text in the
1615
+ recipient's quiet hours, restricted content), with the reason in
1616
+ `fallback.error`, and `'skipped'` for a WhatsApp error an SMS can't fix.
1617
+ - There is no time-based fallback: a message WhatsApp accepted but hasn't
1618
+ delivered, for example to a phone that is off, stays `'pending'`, and no
1619
+ SMS goes unless WhatsApp later reports a failure.
1620
+ - `messages.get()` and `messages.list()` return the same `fallback` on the
1621
+ WhatsApp message, so check a pending one later. The SMS carries
1622
+ `fallback_of`, the WhatsApp message's id.
1623
+
1624
+ Guide: [Send WhatsApp with an SMS Fallback](https://sendly.live/docs/whatsapp/send-with-fallback).
1625
+
1553
1626
  ## RCS
1554
1627
 
1555
1628
  Send branded, verified-sender messages on Android: rich cards, suggestion
@@ -1911,8 +1984,8 @@ default, or `CA`) are optional.
1911
1984
 
1912
1985
  Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
1913
1986
  `agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
1914
- `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
1915
- retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
1987
+ `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 503,
1988
+ retry shortly), `carrier_refused` (HTTP 503, retry shortly unless the message
1916
1989
  says the number couldn't be found for emergency registration: contact
1917
1990
  support), `voice_unavailable` (HTTP 503),
1918
1991
  `e911_not_applicable` (only US and Canadian numbers take an emergency
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "4.4.0"
7
+ version = "4.5.1"
8
8
  description = "Official Sendly Python SDK for SMS messaging"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -29,7 +29,7 @@ Async Example:
29
29
  >>> asyncio.run(main())
30
30
  """
31
31
 
32
- __version__ = "4.4.0"
32
+ __version__ = "4.5.1"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -152,8 +152,8 @@ class MessagesResource:
152
152
  SendlyError: ``whatsapp_send_failed`` on WhatsApp: 422 when
153
153
  WhatsApp refused the message, which is final and not retried
154
154
  (cached under the idempotency key and replayed for 24 hours);
155
- 502 when the message provably never reached the carrier, so it
156
- was not sent and is safe to send again. A 502 is never cached,
155
+ 503 when the message provably never reached the carrier, so it
156
+ was not sent and is safe to send again. A 503 is never cached,
157
157
  so the SDK retries it like any 5xx under the same idempotency
158
158
  key. Either way the message wasn't charged. No send returns
159
159
  503 ``whatsapp_unavailable``.
@@ -1060,8 +1060,13 @@ class AsyncMessagesResource:
1060
1060
  agent_id: RCS only - the agent to send as
1061
1061
  card: RCS only - a rich card (title and description required)
1062
1062
  suggestions: RCS only - suggestion chips for a text message
1063
- fallback_to_sms: RCS only - deliver as SMS when the recipient
1064
- doesn't support RCS (default True)
1063
+ fallback_to_sms: RCS - deliver as SMS when the recipient
1064
+ doesn't support RCS (default True). Pass False to get a 422
1065
+ rcs_not_supported_for_recipient instead. WhatsApp - default
1066
+ False; True sends an SMS from the same number when WhatsApp
1067
+ can't deliver the message (see message.fallback). The number
1068
+ must be able to send SMS to the recipient, or the API answers
1069
+ 403 sms_fallback_unavailable
1065
1070
  idempotency_key: Optional key (1-255 printable ASCII characters)
1066
1071
  sent as the Idempotency-Key header - retrying with the same
1067
1072
  key returns the original result instead of sending again.
@@ -5,7 +5,7 @@ WhatsApp is a first-class Sendly channel: connect a number you own, create
5
5
  Meta-reviewed message templates, and send via
6
6
  ``client.messages.send(channel='whatsapp', ...)``.
7
7
 
8
- Connecting a number is a one-time $19 setup (no monthly fee) and always ends
8
+ Connecting a number costs a one-time $9.99 fee (no monthly fee) and always ends
9
9
  with a human step: ``signup.create()`` returns a ``connect_url`` that a person
10
10
  must open in a browser and log in with Facebook to link their WhatsApp
11
11
  Business Account. Hand the URL to your user - that connection cannot be
@@ -99,7 +99,8 @@ class WhatsAppSignupResource:
99
99
  ) -> Union[WhatsAppSignupSession, WhatsAppSignup]:
100
100
  """Start connecting a number to WhatsApp.
101
101
 
102
- Charges a one-time $19 setup fee (no monthly fee) and returns a
102
+ Charges a one-time $9.99 connection fee per number (no monthly fee) to
103
+ your card when the connection starts, and returns a
103
104
  ``connect_url``. Completing the connection requires a human: hand the
104
105
  URL to your user - they open it in a browser and log in with Facebook
105
106
  to link their WhatsApp Business Account. Then poll :meth:`get` until
@@ -113,13 +114,13 @@ class WhatsAppSignupResource:
113
114
  activates the number. Activation usually takes a few minutes but can
114
115
  take hours. If it hasn't finished about 6 hours after the session
115
116
  began, the session fails with ``registration_timeout`` and the fee is
116
- refunded. If the connection fails, the $19 fee is refunded
117
- automatically; once the number has connected there is no refund, and
117
+ refunded. If the connection fails, the $9.99 fee is refunded
118
+ automatically; once the number has connected it is not refundable, and
118
119
  a later disconnect gets nothing back.
119
120
 
120
121
  Pass ``business_account_id`` to add the number to a WhatsApp
121
122
  Business Account this workspace has already connected, without the
122
- Facebook step. The same $19 fee applies (refunded automatically if the
123
+ Facebook step. The same $9.99 fee applies (refunded automatically if the
123
124
  connection fails), then WhatsApp sends the number a 6-digit code by
124
125
  text or voice call. This returns a :class:`WhatsAppSignup` with status
125
126
  ``verifying`` and no ``connect_url``: submit the code with
@@ -174,8 +175,8 @@ class WhatsAppSignupResource:
174
175
  ``Retry-After: 3600`` header. The SDK retries it like any 5xx
175
176
  before raising.
176
177
  SendlyError: ``whatsapp_signup_limit_reached`` (429) after 5
177
- failed, charged signups in 24 hours. Not retried; try again
178
- the next day.
178
+ failed, charged signups (or 20 cancelled) in 24 hours. Not retried;
179
+ try again the next day.
179
180
  SendlyError: ``whatsapp_verification_in_progress`` (409) without
180
181
  ``business_account_id`` for a number being added by code;
181
182
  ``e.response.model_extra['id']`` is that signup.
@@ -188,7 +189,7 @@ class WhatsAppSignupResource:
188
189
  (``e.response.model_extra['id']`` is that signup);
189
190
  ``whatsapp_already_enabled`` (409) when it is connected.
190
191
  SendlyError: ``whatsapp_verification_start_failed``: 422 when
191
- WhatsApp refused to send a code (final), 502 when it couldn't
192
+ WhatsApp refused to send a code (final), 503 when it couldn't
192
193
  be reached. Either way the signup failed and the fee is
193
194
  refunded; start again. Not retried automatically.
194
195
  """
@@ -218,7 +219,7 @@ class WhatsAppSignupResource:
218
219
  (``settings:write``).
219
220
 
220
221
  A 5xx, a timeout or a network error is raised at once, never retried:
221
- every submission uses one of the 5 attempts, and a 502
222
+ every submission uses one of the 5 attempts, and a 503
222
223
  ``whatsapp_activation_pending`` means WhatsApp already accepted the
223
224
  code. Only a 429 the API never ran is retried.
224
225
 
@@ -238,9 +239,9 @@ class WhatsAppSignupResource:
238
239
  code for the number is being checked; try again in a moment.
239
240
  SendlyError: ``signup_not_active`` (409) when the signup isn't
240
241
  waiting for a code, or is more than 3 hours old.
241
- SendlyError: ``whatsapp_verification_unavailable`` (502) when
242
+ SendlyError: ``whatsapp_verification_unavailable`` (503) when
242
243
  WhatsApp couldn't check the code (the attempt isn't counted),
243
- or ``whatsapp_activation_pending`` (502) when the code was
244
+ or ``whatsapp_activation_pending`` (503) when the code was
244
245
  accepted but the connection didn't finish; Sendly is alerted,
245
246
  so check back with :meth:`get`. Neither is retried
246
247
  automatically.
@@ -280,7 +281,7 @@ class WhatsAppSignupResource:
280
281
  ``e.response.retry_after`` is the wait in seconds. Not
281
282
  retried.
282
283
  SendlyError: ``whatsapp_verification_resend_failed``: 422 when
283
- WhatsApp wouldn't send another code yet, 502 when it couldn't
284
+ WhatsApp wouldn't send another code yet, 503 when it couldn't
284
285
  be reached.
285
286
  SendlyError: ``signup_not_active`` (409) when the signup isn't
286
287
  waiting for a code, or is more than 3 hours old.
@@ -444,7 +445,7 @@ class WhatsAppSendersResource:
444
445
  5 MB.
445
446
  SendlyError: ``whatsapp_sender_not_connected`` (404) when the
446
447
  number isn't connected to WhatsApp.
447
- SendlyError: ``whatsapp_profile_update_failed`` (502) when
448
+ SendlyError: ``whatsapp_profile_update_failed`` (503) when
448
449
  WhatsApp refused the photo or couldn't be reached; check the
449
450
  image is square and at least 192 pixels wide. A 5xx, a
450
451
  timeout or a network error is raised at once, not retried
@@ -482,7 +483,7 @@ class WhatsAppSendersResource:
482
483
  Raises:
483
484
  SendlyError: ``whatsapp_sender_not_connected`` (404) when the
484
485
  number isn't connected to WhatsApp.
485
- SendlyError: ``whatsapp_profile_update_failed`` (502) when
486
+ SendlyError: ``whatsapp_profile_update_failed`` (503) when
486
487
  WhatsApp couldn't remove it.
487
488
  """
488
489
  validate_phone_number(phone_number)
@@ -512,7 +513,7 @@ class WhatsAppSendersResource:
512
513
  SendlyError: ``whatsapp_sender_not_connected`` (404) when the
513
514
  number isn't connected to WhatsApp.
514
515
  SendlyError: ``whatsapp_conversational_components_fetch_failed``
515
- (502) when WhatsApp couldn't be reached.
516
+ (503) when WhatsApp couldn't be reached.
516
517
  """
517
518
  validate_phone_number(phone_number)
518
519
  data = self._http.request(
@@ -555,7 +556,7 @@ class WhatsAppSendersResource:
555
556
  SendlyError: ``whatsapp_sender_not_connected`` (404) when the
556
557
  number isn't connected to WhatsApp.
557
558
  SendlyError: ``whatsapp_conversational_components_update_failed``
558
- (502) when WhatsApp couldn't save them.
559
+ (503) when WhatsApp couldn't save them.
559
560
 
560
561
  Example:
561
562
  >>> client.whatsapp.senders.update_conversational_components(
@@ -599,7 +600,7 @@ class WhatsAppSendersResource:
599
600
  at least 2,000 people a day and the display name is approved.
600
601
  SendlyError: ``whatsapp_sender_not_connected`` (404) when the
601
602
  number isn't connected to WhatsApp.
602
- SendlyError: ``whatsapp_calling_update_failed`` (502) when
603
+ SendlyError: ``whatsapp_calling_update_failed`` (503) when
603
604
  WhatsApp couldn't be reached.
604
605
 
605
606
  Example:
@@ -771,7 +772,7 @@ class WhatsAppResource:
771
772
  """WhatsApp API resource (sync)
772
773
 
773
774
  Example:
774
- >>> # 1. Connect a number ($19 one-time, no monthly fee). The connect
775
+ >>> # 1. Connect a number ($9.99 one-time, no monthly fee). The connect
775
776
  >>> # URL must be opened by a human - they log in with Facebook in
776
777
  >>> # a browser to link their WhatsApp Business Account.
777
778
  >>> signup = client.whatsapp.signup.create('+15559876543')
@@ -2941,7 +2941,7 @@ class WhatsAppSignup(BaseModel):
2941
2941
  "it still shows the earlier code until the new one arrives. Once "
2942
2942
  "WhatsApp has checked a code, only a code that arrived after the "
2943
2943
  "last submission or resend is returned. A submission answered with "
2944
- "502 whatsapp_verification_unavailable is not counted, so the same "
2944
+ "503 whatsapp_verification_unavailable is not counted, so the same "
2945
2945
  "unchecked code can come back, and submitting it again is safe"
2946
2946
  ),
2947
2947
  )
@@ -3320,12 +3320,17 @@ class WhatsAppMessage(BaseModel):
3320
3320
  description=(
3321
3321
  "Present when the send asked for fallback_to_sms. When WhatsApp "
3322
3322
  "couldn't take the message and it went as an SMS, status is "
3323
- "failed and fallback.status is sent"
3323
+ "failed and fallback.status is sent (check with fell_back)"
3324
3324
  ),
3325
3325
  )
3326
3326
 
3327
3327
  model_config = ConfigDict(populate_by_name=True)
3328
3328
 
3329
+ @property
3330
+ def fell_back(self) -> bool:
3331
+ """True when the message went as an SMS instead: fallback.status is sent"""
3332
+ return self.fallback is not None and self.fallback.status == "sent"
3333
+
3329
3334
 
3330
3335
  # ============================================================================
3331
3336
  # RCS
@@ -28,7 +28,7 @@ T = TypeVar("T")
28
28
  DEFAULT_BASE_URL = "https://sendly.live/api/v1"
29
29
  DEFAULT_TIMEOUT = 30.0
30
30
  DEFAULT_MAX_RETRIES = 3
31
- SDK_VERSION = "4.4.0"
31
+ SDK_VERSION = "4.5.1"
32
32
 
33
33
 
34
34
  _WAIT_AND_RETRY_CODES = (
@@ -1088,6 +1088,19 @@ class TestWhatsAppSmsFallback:
1088
1088
  assert message.fallback.status == "sent"
1089
1089
  assert message.fallback.message_id == "msg_sms_1"
1090
1090
  assert message.fallback.credits_used == 2
1091
+ assert message.fell_back is True
1092
+
1093
+ @pytest.mark.parametrize("status", ["pending", "failed", "unavailable", "skipped"])
1094
+ def test_fell_back_is_false_until_the_sms_went(
1095
+ self, mock_whatsapp_message, status
1096
+ ):
1097
+ message = WhatsAppMessage.model_validate(
1098
+ {
1099
+ **mock_whatsapp_message,
1100
+ "fallback": {"channel": "sms", "status": status},
1101
+ }
1102
+ )
1103
+ assert message.fell_back is False
1091
1104
 
1092
1105
  def test_leaves_fallback_out_by_default(
1093
1106
  self, api_key, mock_whatsapp_message, httpx_mock: HTTPXMock
@@ -1106,6 +1119,7 @@ class TestWhatsAppSmsFallback:
1106
1119
  payload = json.loads(httpx_mock.get_request().read().decode())
1107
1120
  assert "fallbackToSms" not in payload
1108
1121
  assert message.fallback is None
1122
+ assert message.fell_back is False
1109
1123
 
1110
1124
  @pytest.mark.asyncio
1111
1125
  async def test_async_sends_fallback_to_sms(
@@ -1129,6 +1143,7 @@ class TestWhatsAppSmsFallback:
1129
1143
  payload = json.loads(httpx_mock.get_request().read().decode())
1130
1144
  assert payload["fallbackToSms"] is True
1131
1145
  assert message.fallback.status == "sent"
1146
+ assert message.fell_back is True
1132
1147
 
1133
1148
 
1134
1149
  class TestWhatsAppRecoveredSend:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes