sendly 4.3.0__tar.gz → 4.5.0__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.3.0 → sendly-4.5.0}/CHANGELOG.md +21 -0
  2. {sendly-4.3.0 → sendly-4.5.0}/PKG-INFO +98 -28
  3. {sendly-4.3.0 → sendly-4.5.0}/README.md +97 -27
  4. {sendly-4.3.0 → sendly-4.5.0}/pyproject.toml +1 -1
  5. {sendly-4.3.0 → sendly-4.5.0}/sendly/__init__.py +3 -1
  6. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/media.py +5 -4
  7. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/messages.py +19 -6
  8. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/whatsapp.py +28 -33
  9. {sendly-4.3.0 → sendly-4.5.0}/sendly/types.py +89 -4
  10. {sendly-4.3.0 → sendly-4.5.0}/sendly/utils/http.py +1 -1
  11. {sendly-4.3.0 → sendly-4.5.0}/tests/test_media.py +30 -7
  12. {sendly-4.3.0 → sendly-4.5.0}/tests/test_whatsapp.py +188 -0
  13. {sendly-4.3.0 → sendly-4.5.0}/tests/test_whatsapp_extras.py +2 -4
  14. {sendly-4.3.0 → sendly-4.5.0}/.github/header.svg +0 -0
  15. {sendly-4.3.0 → sendly-4.5.0}/.gitignore +0 -0
  16. {sendly-4.3.0 → sendly-4.5.0}/examples/async_example.py +0 -0
  17. {sendly-4.3.0 → sendly-4.5.0}/examples/error_handling.py +0 -0
  18. {sendly-4.3.0 → sendly-4.5.0}/examples/send_sms.py +0 -0
  19. {sendly-4.3.0 → sendly-4.5.0}/sendly/client.py +0 -0
  20. {sendly-4.3.0 → sendly-4.5.0}/sendly/errors.py +0 -0
  21. {sendly-4.3.0 → sendly-4.5.0}/sendly/py.typed +0 -0
  22. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/__init__.py +0 -0
  23. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/account.py +0 -0
  24. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/business_upgrade.py +0 -0
  25. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/calls.py +0 -0
  26. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/campaigns.py +0 -0
  27. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/contacts.py +0 -0
  28. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/conversations.py +0 -0
  29. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/drafts.py +0 -0
  30. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/enterprise.py +0 -0
  31. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/labels.py +0 -0
  32. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/links.py +0 -0
  33. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/numbers.py +0 -0
  34. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/rcs.py +0 -0
  35. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/rules.py +0 -0
  36. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/templates.py +0 -0
  37. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/tendlc.py +0 -0
  38. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/verify.py +0 -0
  39. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/voice.py +0 -0
  40. {sendly-4.3.0 → sendly-4.5.0}/sendly/resources/webhooks.py +0 -0
  41. {sendly-4.3.0 → sendly-4.5.0}/sendly/utils/__init__.py +0 -0
  42. {sendly-4.3.0 → sendly-4.5.0}/sendly/utils/validation.py +0 -0
  43. {sendly-4.3.0 → sendly-4.5.0}/sendly/webhooks.py +0 -0
  44. {sendly-4.3.0 → sendly-4.5.0}/tests/conftest.py +0 -0
  45. {sendly-4.3.0 → sendly-4.5.0}/tests/test_account.py +0 -0
  46. {sendly-4.3.0 → sendly-4.5.0}/tests/test_async_client.py +0 -0
  47. {sendly-4.3.0 → sendly-4.5.0}/tests/test_calls.py +0 -0
  48. {sendly-4.3.0 → sendly-4.5.0}/tests/test_campaigns.py +0 -0
  49. {sendly-4.3.0 → sendly-4.5.0}/tests/test_client.py +0 -0
  50. {sendly-4.3.0 → sendly-4.5.0}/tests/test_contacts.py +0 -0
  51. {sendly-4.3.0 → sendly-4.5.0}/tests/test_conversations.py +0 -0
  52. {sendly-4.3.0 → sendly-4.5.0}/tests/test_enterprise.py +0 -0
  53. {sendly-4.3.0 → sendly-4.5.0}/tests/test_errors.py +0 -0
  54. {sendly-4.3.0 → sendly-4.5.0}/tests/test_http_body_kwarg.py +0 -0
  55. {sendly-4.3.0 → sendly-4.5.0}/tests/test_http_retry.py +0 -0
  56. {sendly-4.3.0 → sendly-4.5.0}/tests/test_idempotency.py +0 -0
  57. {sendly-4.3.0 → sendly-4.5.0}/tests/test_messages.py +0 -0
  58. {sendly-4.3.0 → sendly-4.5.0}/tests/test_messages.py.bak +0 -0
  59. {sendly-4.3.0 → sendly-4.5.0}/tests/test_messages_batch.py +0 -0
  60. {sendly-4.3.0 → sendly-4.5.0}/tests/test_messages_schedule.py +0 -0
  61. {sendly-4.3.0 → sendly-4.5.0}/tests/test_messages_wire.py +0 -0
  62. {sendly-4.3.0 → sendly-4.5.0}/tests/test_numbers.py +0 -0
  63. {sendly-4.3.0 → sendly-4.5.0}/tests/test_path_ids.py +0 -0
  64. {sendly-4.3.0 → sendly-4.5.0}/tests/test_rcs.py +0 -0
  65. {sendly-4.3.0 → sendly-4.5.0}/tests/test_rcs_registration.py +0 -0
  66. {sendly-4.3.0 → sendly-4.5.0}/tests/test_rules.py +0 -0
  67. {sendly-4.3.0 → sendly-4.5.0}/tests/test_templates.py +0 -0
  68. {sendly-4.3.0 → sendly-4.5.0}/tests/test_tendlc.py +0 -0
  69. {sendly-4.3.0 → sendly-4.5.0}/tests/test_validation.py +0 -0
  70. {sendly-4.3.0 → sendly-4.5.0}/tests/test_voice.py +0 -0
  71. {sendly-4.3.0 → sendly-4.5.0}/tests/test_webhooks.py +0 -0
  72. {sendly-4.3.0 → sendly-4.5.0}/tests/test_webhooks_resource.py +0 -0
@@ -1,5 +1,26 @@
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
+
14
+ ## 4.4.0
15
+
16
+ ### Minor Changes
17
+
18
+ - **WhatsApp to SMS fallback.** `messages.send(channel="whatsapp", fallback_to_sms=True)` (sync and async) sends `fallbackToSms`. When WhatsApp can't deliver the message, it goes as an SMS from the same number, billed as an SMS. The new `WhatsAppSmsFallback` (`channel`, `status`, `reason`, `message_id`, `credits_used`, `error`, `content_dropped`) is `WhatsAppMessage.fallback`; when the SMS went in place of the WhatsApp message, `status` is `failed` and `fallback.status` is `sent`. `Message` (from `messages.get` and `list`) gains `fallback`, `fallback_of` (on the SMS, the WhatsApp message it replaced), `message_format`, `batch_id` and `media_urls`.
19
+
20
+ ### Patch Changes
21
+
22
+ - A WhatsApp send that WhatsApp accepted but Sendly couldn't fully record answers 201 without `whatsapp` or `createdAt`, and `messages.send` raised `invalid_response` on it. It now returns the message, with `whatsapp` and `created_at` set to `None`. That message is in flight and was charged, so don't send it again.
23
+
3
24
  ## 4.3.0
4
25
 
5
26
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 4.3.0
3
+ Version: 4.5.0
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
@@ -448,7 +448,7 @@ File uploads (`media.upload()`, `business_upgrade.start()` and `resubmit()`,
448
448
  make is the `too_many_concurrent_verifications` wait. `whatsapp.signup.verify()`
449
449
  and `whatsapp.signup.create()` with a `business_account_id` also raise a
450
450
  timeout, a network error or a `5xx` at once, because repeating them can use up
451
- a verification attempt or start a new charged signup; they still wait out a
451
+ a verification attempt or start a new signup; they still wait out a
452
452
  `429` the API never ran.
453
453
 
454
454
  After the last attempt the SDK raises the error it last saw (`TimeoutError`,
@@ -1072,9 +1072,10 @@ client.messages.send(
1072
1072
 
1073
1073
  Uploads accept JPEG, PNG and GIF up to 600 KB. When `content_type` is one of
1074
1074
  those types but the content is not, the upload raises `SendlyError` with code
1075
- `invalid_file`; any other `content_type`, or a file over 600 KB, raises
1076
- `internal_error` (HTTP 500) with the reason in the message. Without MMS enabled
1077
- for your account it is `feature_disabled`.
1075
+ `invalid_file`; any other `content_type` raises `unsupported_media_type`
1076
+ (HTTP 415) with the reason in the message, and a file over 600 KB raises
1077
+ `file_too_large` (HTTP 413). Without MMS enabled for your account it is
1078
+ `feature_disabled`.
1078
1079
 
1079
1080
  ## Account & Credits
1080
1081
 
@@ -1354,7 +1355,7 @@ client.links.update(link.code, True)
1354
1355
 
1355
1356
  ## WhatsApp
1356
1357
 
1357
- Connect a number you own to WhatsApp ($19 one-time, no monthly fee), create
1358
+ Connect a number you own to WhatsApp (free to connect, no monthly fee), create
1358
1359
  Meta-reviewed message templates, and send on the `whatsapp` channel. Connecting
1359
1360
  the first number always ends with a human step: hand the `connect_url` to your
1360
1361
  user - they open it in a browser and log in with Facebook to link their
@@ -1386,10 +1387,8 @@ print(f'Open {signup.connect_url} and log in with Facebook')
1386
1387
  # After the Facebook step it stays 'registering' while WhatsApp activates the
1387
1388
  # number. Activation usually takes a few minutes but can take hours. If it
1388
1389
  # hasn't finished about 6 hours after the session began, the session fails
1389
- # with registration_timeout and the fee is refunded ('failed', with
1390
- # signup.failure_reasons explaining why). If the connection fails, the $19 fee
1391
- # is refunded automatically; once a number has connected, a later disconnect
1392
- # gets nothing back.
1390
+ # with registration_timeout ('failed', with signup.failure_reasons explaining
1391
+ # why).
1393
1392
 
1394
1393
  # 2. Create a template (Meta reviews it, usually 24-48h). category is required
1395
1394
  # (UTILITY, AUTHENTICATION or MARKETING) with no default, and an update can't
@@ -1473,7 +1472,7 @@ components = client.whatsapp.senders.get_conversational_components('+15555550123
1473
1472
  client.whatsapp.senders.set_calling('+15555550123', enabled=True)
1474
1473
 
1475
1474
  # Add another number to an account you already connected: no Facebook step.
1476
- # Same $19 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1475
+ # Free, like a Facebook connection. WhatsApp sends the number a 6-digit code
1477
1476
  senders = client.whatsapp.senders.list().senders
1478
1477
  account_id = next(
1479
1478
  s.business_account_id for s in senders if s.status == 'active' and s.business_account_id
@@ -1497,14 +1496,14 @@ Senders also carry `business_account_id` (None while `pending`),
1497
1496
  name on file), `calling_enabled`, and `outbound_calling_allowed`, which is
1498
1497
  False for +1, +20, +84 and +234 numbers. A signup that is `verifying` carries
1499
1498
  `verification_method` and `verification_attempts_remaining`; after 5 wrong
1500
- codes it fails with `verification_failed` and the fee is refunded. Calls carry
1499
+ codes it fails with `verification_failed`. Calls carry
1501
1500
  `channel` (`phone`, `whatsapp` or `browser`; see `CallChannel`).
1502
1501
 
1503
1502
  `verification_code` comes only from `signup.get()`. Until a code has been
1504
1503
  submitted, it is the newest code that has arrived since the signup started, so
1505
1504
  after a resend it still shows the earlier code until the new one arrives. Once
1506
1505
  WhatsApp has checked a code, only a code that arrived after the last
1507
- submission or resend is returned. A submission answered with 502
1506
+ submission or resend is returned. A submission answered with 503
1508
1507
  `whatsapp_verification_unavailable` is not counted, so the same unchecked code
1509
1508
  can come back, and submitting it again is safe.
1510
1509
 
@@ -1530,14 +1529,14 @@ Errors specific to WhatsApp:
1530
1529
  body carries `retryAfter: 3600` and the response a `Retry-After: 3600`
1531
1530
  header. The SDK retries it like any `5xx` before raising;
1532
1531
  `e.response.retry_after` says how many seconds to wait before trying again.
1533
- - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed, charged signups in
1532
+ - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed signups in
1534
1533
  24 hours. It is final and not retried; try again the next day.
1535
1534
  - `whatsapp_send_failed` on a send: HTTP 422 when WhatsApp refused the message,
1536
1535
  which is final (cached under the idempotency key and replayed for 24 hours),
1537
- or HTTP 502 when the message provably never reached the carrier, so it was
1538
- not sent and is safe to send again. A 502 is never cached, and the SDK
1539
- retries it under the same idempotency key. Either way the message was not
1540
- charged.
1536
+ or HTTP 503 when the message provably never reached WhatsApp, so it was not
1537
+ sent and is safe to resend under the same idempotency key. A 503 is never
1538
+ cached, and the SDK retries it automatically, up to 3 times under the same
1539
+ key, before raising. Either way the message was not charged.
1541
1540
  - `whatsapp_send_unconfirmed` (HTTP 409) on a send: the outcome is unknown.
1542
1541
  The message was marked failed and refunded but may still be delivered, so
1543
1542
  check before sending it again (it could arrive twice). It is not retried
@@ -1555,28 +1554,28 @@ Errors specific to WhatsApp:
1555
1554
  marketing template without an opt-out button only gets a warning.
1556
1555
  - Profile photo: `file_required` and `whatsapp_profile_photo_invalid` (HTTP
1557
1556
  400), `whatsapp_profile_photo_too_large` (HTTP 413),
1558
- `whatsapp_profile_update_failed` (HTTP 502). Conversational components:
1557
+ `whatsapp_profile_update_failed` (HTTP 503). Conversational components:
1559
1558
  `invalid_request` (HTTP 400) with the reason,
1560
1559
  `whatsapp_conversational_components_fetch_failed` and
1561
- `whatsapp_conversational_components_update_failed` (HTTP 502). Calling:
1560
+ `whatsapp_conversational_components_update_failed` (HTTP 503). Calling:
1562
1561
  `voice_not_enabled` (HTTP 409), `whatsapp_calling_unavailable` (HTTP 422;
1563
1562
  WhatsApp only allows calling once the account may message 2,000 people a
1564
1563
  day and the display name is approved), `whatsapp_calling_update_failed`
1565
- (HTTP 502).
1564
+ (HTTP 503).
1566
1565
  - Adding a number by code: `whatsapp_business_account_not_found` (HTTP 404),
1567
1566
  `display_name_required` (HTTP 400), `whatsapp_signup_in_progress` and
1568
1567
  `whatsapp_already_enabled` (HTTP 409), and
1569
- `whatsapp_verification_start_failed` (HTTP 422 refused, 502 unreachable; the
1570
- fee is refunded either way). A Facebook `signup.create()` for a number being
1568
+ `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable). A
1569
+ Facebook `signup.create()` for a number being
1571
1570
  added by code gets `whatsapp_verification_in_progress` (HTTP 409).
1572
1571
  `verify()`: `invalid_verification_code` (HTTP 400, not 6 digits),
1573
1572
  `whatsapp_verification_code_invalid` (HTTP 422, with
1574
1573
  `e.response.attempts_remaining`), `whatsapp_verification_failed` (HTTP 409,
1575
1574
  too many wrong codes), `whatsapp_verification_busy` (HTTP 409, retry),
1576
1575
  `whatsapp_verification_unavailable` and `whatsapp_activation_pending` (HTTP
1577
- 502). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1576
+ 503). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1578
1577
  `e.response.retry_after` seconds) and `whatsapp_verification_resend_failed`
1579
- (HTTP 422 or 502). Both raise `signup_not_active` (HTTP 409) once the signup
1578
+ (HTTP 422 or 503). Both raise `signup_not_active` (HTTP 409) once the signup
1580
1579
  stops waiting for a code, and `signup_not_found` (HTTP 404). The SDK never
1581
1580
  retries a `5xx`, a timeout or a network error from `signup.create()` with a
1582
1581
  `business_account_id` or from `verify()`; it retries `resend()` like any
@@ -1584,6 +1583,78 @@ Errors specific to WhatsApp:
1584
1583
 
1585
1584
  `message.whatsapp.message_id` is `None` until the first delivery report lands.
1586
1585
 
1586
+ ### SMS fallback
1587
+
1588
+ Pass `fallback_to_sms=True` and a WhatsApp message that can't be delivered
1589
+ goes as an SMS from the same number instead.
1590
+
1591
+ ```python
1592
+ from sendly import Sendly, SendlyError, WhatsAppSmsFallback
1593
+
1594
+ client = Sendly('sk_live_v1_YOUR_API_KEY')
1595
+
1596
+ try:
1597
+ message = client.messages.send(
1598
+ channel='whatsapp',
1599
+ to='+15555550100',
1600
+ from_='+15555550123',
1601
+ text='Your table is ready!',
1602
+ fallback_to_sms=True,
1603
+ )
1604
+ print(message.fallback.status) # 'pending', 'sent', 'failed', 'unavailable' or 'skipped'
1605
+ print(message.fallback.reason) # 'window_closed', 'whatsapp_refused' or 'whatsapp_failed'
1606
+ if message.fell_back and message.fallback.message_id:
1607
+ # The SMS went instead (message.status is 'failed')
1608
+ sms = client.messages.get(message.fallback.message_id)
1609
+ print(sms.fallback_of) # the WhatsApp message's id
1610
+ except SendlyError as e:
1611
+ extra = (e.response.model_extra or {}) if e.response else {}
1612
+ if e.code == 'sms_fallback_unavailable':
1613
+ pass # 403: this number can't send SMS to the recipient; nothing was sent
1614
+ elif e.code == 'sms_fallback_not_supported':
1615
+ print(extra.get('reason')) # marketing_template, authentication_template or media_without_caption
1616
+ elif extra.get('fallback'):
1617
+ # 422 whatsapp_window_closed or whatsapp_send_failed: the SMS couldn't go
1618
+ # either. A closed window also carries the WhatsApp message's id.
1619
+ fallback = WhatsAppSmsFallback.model_validate(extra['fallback'])
1620
+ print(e.code, extra.get('id'), fallback.status, fallback.error)
1621
+ else:
1622
+ raise
1623
+ ```
1624
+
1625
+ - It falls back when the 24-hour window is closed (the SMS goes at once:
1626
+ free-form text and media go only within 24 hours of the customer's last
1627
+ message, templates any time), when WhatsApp refuses the message, or when
1628
+ WhatsApp reports it undeliverable later. Never after WhatsApp reports it
1629
+ delivered or read, and at most once per message.
1630
+ - The number must already be able to send SMS to the recipient (a verified
1631
+ toll-free number, or a local number on a 10DLC campaign, to a US or Canada
1632
+ number), or the send is refused with HTTP 403 `sms_fallback_unavailable`
1633
+ before anything is sent.
1634
+ - Marketing and authentication templates, and media without a caption, can't
1635
+ fall back: HTTP 422 `sms_fallback_not_supported`. A utility template falls
1636
+ back as its text header, body and footer with the variables filled in, and
1637
+ its URL and phone buttons as `label: target`; quick replies are dropped and
1638
+ listed in `fallback.content_dropped`.
1639
+ - Billing: the WhatsApp charge is refunded (a closed window is never charged)
1640
+ and the SMS is billed as an SMS. A message that fell back at once comes
1641
+ back with `status == 'failed'`, `credits_used == 0` and
1642
+ `fallback.status == 'sent'` (`message.fell_back` is `True`);
1643
+ `fallback.credits_used` is what the SMS cost.
1644
+ - No SMS goes on an HTTP 409 `whatsapp_send_unconfirmed` or an HTTP 503
1645
+ `whatsapp_send_failed`. `fallback.status` is `'unavailable'` when SMS rules
1646
+ stop it (the contact opted out, a promotional-looking text in the
1647
+ recipient's quiet hours, restricted content), with the reason in
1648
+ `fallback.error`, and `'skipped'` for a WhatsApp error an SMS can't fix.
1649
+ - There is no time-based fallback: a message WhatsApp accepted but hasn't
1650
+ delivered, for example to a phone that is off, stays `'pending'`, and no
1651
+ SMS goes unless WhatsApp later reports a failure.
1652
+ - `messages.get()` and `messages.list()` return the same `fallback` on the
1653
+ WhatsApp message, so check a pending one later. The SMS carries
1654
+ `fallback_of`, the WhatsApp message's id.
1655
+
1656
+ Guide: [Send WhatsApp with an SMS Fallback](https://sendly.live/docs/whatsapp/send-with-fallback).
1657
+
1587
1658
  ## RCS
1588
1659
 
1589
1660
  Send branded, verified-sender messages on Android: rich cards, suggestion
@@ -1945,8 +2016,8 @@ default, or `CA`) are optional.
1945
2016
 
1946
2017
  Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
1947
2018
  `agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
1948
- `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
1949
- retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
2019
+ `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 503,
2020
+ retry shortly), `carrier_refused` (HTTP 503, retry shortly unless the message
1950
2021
  says the number couldn't be found for emergency registration: contact
1951
2022
  support), `voice_unavailable` (HTTP 503),
1952
2023
  `e911_not_applicable` (only US and Canadian numbers take an emergency
@@ -2592,7 +2663,6 @@ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
2592
2663
 
2593
2664
  # Pooled credits and auto top-up
2594
2665
  client.enterprise.credits.get()
2595
- client.enterprise.credits.deposit(10000, description="Q2 top-up")
2596
2666
  client.enterprise.settings.update_auto_top_up(
2597
2667
  enabled=True, threshold=1000, amount=5000, source_workspace_id="ws_source"
2598
2668
  )
@@ -413,7 +413,7 @@ File uploads (`media.upload()`, `business_upgrade.start()` and `resubmit()`,
413
413
  make is the `too_many_concurrent_verifications` wait. `whatsapp.signup.verify()`
414
414
  and `whatsapp.signup.create()` with a `business_account_id` also raise a
415
415
  timeout, a network error or a `5xx` at once, because repeating them can use up
416
- a verification attempt or start a new charged signup; they still wait out a
416
+ a verification attempt or start a new signup; they still wait out a
417
417
  `429` the API never ran.
418
418
 
419
419
  After the last attempt the SDK raises the error it last saw (`TimeoutError`,
@@ -1037,9 +1037,10 @@ client.messages.send(
1037
1037
 
1038
1038
  Uploads accept JPEG, PNG and GIF up to 600 KB. When `content_type` is one of
1039
1039
  those types but the content is not, the upload raises `SendlyError` with code
1040
- `invalid_file`; any other `content_type`, or a file over 600 KB, raises
1041
- `internal_error` (HTTP 500) with the reason in the message. Without MMS enabled
1042
- for your account it is `feature_disabled`.
1040
+ `invalid_file`; any other `content_type` raises `unsupported_media_type`
1041
+ (HTTP 415) with the reason in the message, and a file over 600 KB raises
1042
+ `file_too_large` (HTTP 413). Without MMS enabled for your account it is
1043
+ `feature_disabled`.
1043
1044
 
1044
1045
  ## Account & Credits
1045
1046
 
@@ -1319,7 +1320,7 @@ client.links.update(link.code, True)
1319
1320
 
1320
1321
  ## WhatsApp
1321
1322
 
1322
- Connect a number you own to WhatsApp ($19 one-time, no monthly fee), create
1323
+ Connect a number you own to WhatsApp (free to connect, no monthly fee), create
1323
1324
  Meta-reviewed message templates, and send on the `whatsapp` channel. Connecting
1324
1325
  the first number always ends with a human step: hand the `connect_url` to your
1325
1326
  user - they open it in a browser and log in with Facebook to link their
@@ -1351,10 +1352,8 @@ print(f'Open {signup.connect_url} and log in with Facebook')
1351
1352
  # After the Facebook step it stays 'registering' while WhatsApp activates the
1352
1353
  # number. Activation usually takes a few minutes but can take hours. If it
1353
1354
  # hasn't finished about 6 hours after the session began, the session fails
1354
- # with registration_timeout and the fee is refunded ('failed', with
1355
- # signup.failure_reasons explaining why). If the connection fails, the $19 fee
1356
- # is refunded automatically; once a number has connected, a later disconnect
1357
- # gets nothing back.
1355
+ # with registration_timeout ('failed', with signup.failure_reasons explaining
1356
+ # why).
1358
1357
 
1359
1358
  # 2. Create a template (Meta reviews it, usually 24-48h). category is required
1360
1359
  # (UTILITY, AUTHENTICATION or MARKETING) with no default, and an update can't
@@ -1438,7 +1437,7 @@ components = client.whatsapp.senders.get_conversational_components('+15555550123
1438
1437
  client.whatsapp.senders.set_calling('+15555550123', enabled=True)
1439
1438
 
1440
1439
  # Add another number to an account you already connected: no Facebook step.
1441
- # Same $19 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1440
+ # Free, like a Facebook connection. WhatsApp sends the number a 6-digit code
1442
1441
  senders = client.whatsapp.senders.list().senders
1443
1442
  account_id = next(
1444
1443
  s.business_account_id for s in senders if s.status == 'active' and s.business_account_id
@@ -1462,14 +1461,14 @@ Senders also carry `business_account_id` (None while `pending`),
1462
1461
  name on file), `calling_enabled`, and `outbound_calling_allowed`, which is
1463
1462
  False for +1, +20, +84 and +234 numbers. A signup that is `verifying` carries
1464
1463
  `verification_method` and `verification_attempts_remaining`; after 5 wrong
1465
- codes it fails with `verification_failed` and the fee is refunded. Calls carry
1464
+ codes it fails with `verification_failed`. Calls carry
1466
1465
  `channel` (`phone`, `whatsapp` or `browser`; see `CallChannel`).
1467
1466
 
1468
1467
  `verification_code` comes only from `signup.get()`. Until a code has been
1469
1468
  submitted, it is the newest code that has arrived since the signup started, so
1470
1469
  after a resend it still shows the earlier code until the new one arrives. Once
1471
1470
  WhatsApp has checked a code, only a code that arrived after the last
1472
- submission or resend is returned. A submission answered with 502
1471
+ submission or resend is returned. A submission answered with 503
1473
1472
  `whatsapp_verification_unavailable` is not counted, so the same unchecked code
1474
1473
  can come back, and submitting it again is safe.
1475
1474
 
@@ -1495,14 +1494,14 @@ Errors specific to WhatsApp:
1495
1494
  body carries `retryAfter: 3600` and the response a `Retry-After: 3600`
1496
1495
  header. The SDK retries it like any `5xx` before raising;
1497
1496
  `e.response.retry_after` says how many seconds to wait before trying again.
1498
- - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed, charged signups in
1497
+ - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed signups in
1499
1498
  24 hours. It is final and not retried; try again the next day.
1500
1499
  - `whatsapp_send_failed` on a send: HTTP 422 when WhatsApp refused the message,
1501
1500
  which is final (cached under the idempotency key and replayed for 24 hours),
1502
- or HTTP 502 when the message provably never reached the carrier, so it was
1503
- not sent and is safe to send again. A 502 is never cached, and the SDK
1504
- retries it under the same idempotency key. Either way the message was not
1505
- charged.
1501
+ or HTTP 503 when the message provably never reached WhatsApp, so it was not
1502
+ sent and is safe to resend under the same idempotency key. A 503 is never
1503
+ cached, and the SDK retries it automatically, up to 3 times under the same
1504
+ key, before raising. Either way the message was not charged.
1506
1505
  - `whatsapp_send_unconfirmed` (HTTP 409) on a send: the outcome is unknown.
1507
1506
  The message was marked failed and refunded but may still be delivered, so
1508
1507
  check before sending it again (it could arrive twice). It is not retried
@@ -1520,28 +1519,28 @@ Errors specific to WhatsApp:
1520
1519
  marketing template without an opt-out button only gets a warning.
1521
1520
  - Profile photo: `file_required` and `whatsapp_profile_photo_invalid` (HTTP
1522
1521
  400), `whatsapp_profile_photo_too_large` (HTTP 413),
1523
- `whatsapp_profile_update_failed` (HTTP 502). Conversational components:
1522
+ `whatsapp_profile_update_failed` (HTTP 503). Conversational components:
1524
1523
  `invalid_request` (HTTP 400) with the reason,
1525
1524
  `whatsapp_conversational_components_fetch_failed` and
1526
- `whatsapp_conversational_components_update_failed` (HTTP 502). Calling:
1525
+ `whatsapp_conversational_components_update_failed` (HTTP 503). Calling:
1527
1526
  `voice_not_enabled` (HTTP 409), `whatsapp_calling_unavailable` (HTTP 422;
1528
1527
  WhatsApp only allows calling once the account may message 2,000 people a
1529
1528
  day and the display name is approved), `whatsapp_calling_update_failed`
1530
- (HTTP 502).
1529
+ (HTTP 503).
1531
1530
  - Adding a number by code: `whatsapp_business_account_not_found` (HTTP 404),
1532
1531
  `display_name_required` (HTTP 400), `whatsapp_signup_in_progress` and
1533
1532
  `whatsapp_already_enabled` (HTTP 409), and
1534
- `whatsapp_verification_start_failed` (HTTP 422 refused, 502 unreachable; the
1535
- fee is refunded either way). A Facebook `signup.create()` for a number being
1533
+ `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable). A
1534
+ Facebook `signup.create()` for a number being
1536
1535
  added by code gets `whatsapp_verification_in_progress` (HTTP 409).
1537
1536
  `verify()`: `invalid_verification_code` (HTTP 400, not 6 digits),
1538
1537
  `whatsapp_verification_code_invalid` (HTTP 422, with
1539
1538
  `e.response.attempts_remaining`), `whatsapp_verification_failed` (HTTP 409,
1540
1539
  too many wrong codes), `whatsapp_verification_busy` (HTTP 409, retry),
1541
1540
  `whatsapp_verification_unavailable` and `whatsapp_activation_pending` (HTTP
1542
- 502). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1541
+ 503). `resend()`: `whatsapp_verification_resend_too_soon` (HTTP 429, wait
1543
1542
  `e.response.retry_after` seconds) and `whatsapp_verification_resend_failed`
1544
- (HTTP 422 or 502). Both raise `signup_not_active` (HTTP 409) once the signup
1543
+ (HTTP 422 or 503). Both raise `signup_not_active` (HTTP 409) once the signup
1545
1544
  stops waiting for a code, and `signup_not_found` (HTTP 404). The SDK never
1546
1545
  retries a `5xx`, a timeout or a network error from `signup.create()` with a
1547
1546
  `business_account_id` or from `verify()`; it retries `resend()` like any
@@ -1549,6 +1548,78 @@ Errors specific to WhatsApp:
1549
1548
 
1550
1549
  `message.whatsapp.message_id` is `None` until the first delivery report lands.
1551
1550
 
1551
+ ### SMS fallback
1552
+
1553
+ Pass `fallback_to_sms=True` and a WhatsApp message that can't be delivered
1554
+ goes as an SMS from the same number instead.
1555
+
1556
+ ```python
1557
+ from sendly import Sendly, SendlyError, WhatsAppSmsFallback
1558
+
1559
+ client = Sendly('sk_live_v1_YOUR_API_KEY')
1560
+
1561
+ try:
1562
+ message = client.messages.send(
1563
+ channel='whatsapp',
1564
+ to='+15555550100',
1565
+ from_='+15555550123',
1566
+ text='Your table is ready!',
1567
+ fallback_to_sms=True,
1568
+ )
1569
+ print(message.fallback.status) # 'pending', 'sent', 'failed', 'unavailable' or 'skipped'
1570
+ print(message.fallback.reason) # 'window_closed', 'whatsapp_refused' or 'whatsapp_failed'
1571
+ if message.fell_back and message.fallback.message_id:
1572
+ # The SMS went instead (message.status is 'failed')
1573
+ sms = client.messages.get(message.fallback.message_id)
1574
+ print(sms.fallback_of) # the WhatsApp message's id
1575
+ except SendlyError as e:
1576
+ extra = (e.response.model_extra or {}) if e.response else {}
1577
+ if e.code == 'sms_fallback_unavailable':
1578
+ pass # 403: this number can't send SMS to the recipient; nothing was sent
1579
+ elif e.code == 'sms_fallback_not_supported':
1580
+ print(extra.get('reason')) # marketing_template, authentication_template or media_without_caption
1581
+ elif extra.get('fallback'):
1582
+ # 422 whatsapp_window_closed or whatsapp_send_failed: the SMS couldn't go
1583
+ # either. A closed window also carries the WhatsApp message's id.
1584
+ fallback = WhatsAppSmsFallback.model_validate(extra['fallback'])
1585
+ print(e.code, extra.get('id'), fallback.status, fallback.error)
1586
+ else:
1587
+ raise
1588
+ ```
1589
+
1590
+ - It falls back when the 24-hour window is closed (the SMS goes at once:
1591
+ free-form text and media go only within 24 hours of the customer's last
1592
+ message, templates any time), when WhatsApp refuses the message, or when
1593
+ WhatsApp reports it undeliverable later. Never after WhatsApp reports it
1594
+ delivered or read, and at most once per message.
1595
+ - The number must already be able to send SMS to the recipient (a verified
1596
+ toll-free number, or a local number on a 10DLC campaign, to a US or Canada
1597
+ number), or the send is refused with HTTP 403 `sms_fallback_unavailable`
1598
+ before anything is sent.
1599
+ - Marketing and authentication templates, and media without a caption, can't
1600
+ fall back: HTTP 422 `sms_fallback_not_supported`. A utility template falls
1601
+ back as its text header, body and footer with the variables filled in, and
1602
+ its URL and phone buttons as `label: target`; quick replies are dropped and
1603
+ listed in `fallback.content_dropped`.
1604
+ - Billing: the WhatsApp charge is refunded (a closed window is never charged)
1605
+ and the SMS is billed as an SMS. A message that fell back at once comes
1606
+ back with `status == 'failed'`, `credits_used == 0` and
1607
+ `fallback.status == 'sent'` (`message.fell_back` is `True`);
1608
+ `fallback.credits_used` is what the SMS cost.
1609
+ - No SMS goes on an HTTP 409 `whatsapp_send_unconfirmed` or an HTTP 503
1610
+ `whatsapp_send_failed`. `fallback.status` is `'unavailable'` when SMS rules
1611
+ stop it (the contact opted out, a promotional-looking text in the
1612
+ recipient's quiet hours, restricted content), with the reason in
1613
+ `fallback.error`, and `'skipped'` for a WhatsApp error an SMS can't fix.
1614
+ - There is no time-based fallback: a message WhatsApp accepted but hasn't
1615
+ delivered, for example to a phone that is off, stays `'pending'`, and no
1616
+ SMS goes unless WhatsApp later reports a failure.
1617
+ - `messages.get()` and `messages.list()` return the same `fallback` on the
1618
+ WhatsApp message, so check a pending one later. The SMS carries
1619
+ `fallback_of`, the WhatsApp message's id.
1620
+
1621
+ Guide: [Send WhatsApp with an SMS Fallback](https://sendly.live/docs/whatsapp/send-with-fallback).
1622
+
1552
1623
  ## RCS
1553
1624
 
1554
1625
  Send branded, verified-sender messages on Android: rich cards, suggestion
@@ -1910,8 +1981,8 @@ default, or `CA`) are optional.
1910
1981
 
1911
1982
  Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
1912
1983
  `agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
1913
- `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
1914
- retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
1984
+ `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 503,
1985
+ retry shortly), `carrier_refused` (HTTP 503, retry shortly unless the message
1915
1986
  says the number couldn't be found for emergency registration: contact
1916
1987
  support), `voice_unavailable` (HTTP 503),
1917
1988
  `e911_not_applicable` (only US and Canadian numbers take an emergency
@@ -2557,7 +2628,6 @@ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
2557
2628
 
2558
2629
  # Pooled credits and auto top-up
2559
2630
  client.enterprise.credits.get()
2560
- client.enterprise.credits.deposit(10000, description="Q2 top-up")
2561
2631
  client.enterprise.settings.update_auto_top_up(
2562
2632
  enabled=True, threshold=1000, amount=5000, source_workspace_id="ws_source"
2563
2633
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "4.3.0"
7
+ version = "4.5.0"
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.3.0"
32
+ __version__ = "4.5.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -160,6 +160,7 @@ from .types import (
160
160
  TenDlcThroughput,
161
161
  # WhatsApp types
162
162
  WhatsAppMessage,
163
+ WhatsAppSmsFallback,
163
164
  WhatsAppMessageDetails,
164
165
  WhatsAppMessageTemplate,
165
166
  WhatsAppSender,
@@ -409,6 +410,7 @@ __all__ = [
409
410
  "TenDlcThroughput",
410
411
  # WhatsApp types
411
412
  "WhatsAppMessage",
413
+ "WhatsAppSmsFallback",
412
414
  "WhatsAppMessageDetails",
413
415
  "WhatsAppMessageTemplate",
414
416
  "WhatsAppSender",
@@ -49,10 +49,11 @@ class MediaResource:
49
49
  ValidationError: If no file reached the API (code ``invalid_request``)
50
50
  SendlyError: With code ``invalid_file`` when content_type is
51
51
  image/jpeg, image/png or image/gif but the content is not a
52
- JPEG, PNG or GIF; ``internal_error`` (HTTP 500) when
53
- content_type is any other type or the file is over 600 KB,
54
- with the reason in the message; or ``feature_disabled`` when
55
- MMS is not enabled for your account
52
+ JPEG, PNG or GIF; ``unsupported_media_type`` (HTTP 415) when
53
+ content_type is any other type, with the reason in the
54
+ message; ``file_too_large`` (HTTP 413) when the file is over
55
+ 600 KB; or ``feature_disabled`` when MMS is not enabled for
56
+ your account
56
57
  AuthenticationError: If the API key is invalid
57
58
  RateLimitError: If rate limit is exceeded
58
59
 
@@ -126,9 +126,13 @@ class MessagesResource:
126
126
  'url': 'https://example.com/track'}}]. Card buttons go in
127
127
  card['suggestions'] instead. Dropped (and disclosed via
128
128
  message.rcs.suggestions_dropped) when the send falls back
129
- fallback_to_sms: RCS only - deliver as SMS when the recipient
129
+ fallback_to_sms: RCS - deliver as SMS when the recipient
130
130
  doesn't support RCS (default True). Pass False to get a 422
131
- rcs_not_supported_for_recipient instead
131
+ rcs_not_supported_for_recipient instead. WhatsApp - default
132
+ False; True sends an SMS from the same number when WhatsApp
133
+ can't deliver the message (see message.fallback). The number
134
+ must be able to send SMS to the recipient, or the API answers
135
+ 403 sms_fallback_unavailable
132
136
  idempotency_key: Optional key (1-255 printable ASCII characters)
133
137
  sent as the Idempotency-Key header - retrying with the same
134
138
  key returns the original result instead of sending again.
@@ -148,8 +152,8 @@ class MessagesResource:
148
152
  SendlyError: ``whatsapp_send_failed`` on WhatsApp: 422 when
149
153
  WhatsApp refused the message, which is final and not retried
150
154
  (cached under the idempotency key and replayed for 24 hours);
151
- 502 when the message provably never reached the carrier, so it
152
- 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,
153
157
  so the SDK retries it like any 5xx under the same idempotency
154
158
  key. Either way the message wasn't charged. No send returns
155
159
  503 ``whatsapp_unavailable``.
@@ -242,6 +246,8 @@ class MessagesResource:
242
246
  body["template"] = template
243
247
  if metadata:
244
248
  body["metadata"] = metadata
249
+ if fallback_to_sms is not None:
250
+ body["fallbackToSms"] = fallback_to_sms
245
251
 
246
252
  data = self._http.request(
247
253
  method="POST",
@@ -1054,8 +1060,13 @@ class AsyncMessagesResource:
1054
1060
  agent_id: RCS only - the agent to send as
1055
1061
  card: RCS only - a rich card (title and description required)
1056
1062
  suggestions: RCS only - suggestion chips for a text message
1057
- fallback_to_sms: RCS only - deliver as SMS when the recipient
1058
- 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
1059
1070
  idempotency_key: Optional key (1-255 printable ASCII characters)
1060
1071
  sent as the Idempotency-Key header - retrying with the same
1061
1072
  key returns the original result instead of sending again.
@@ -1116,6 +1127,8 @@ class AsyncMessagesResource:
1116
1127
  body["template"] = template
1117
1128
  if metadata:
1118
1129
  body["metadata"] = metadata
1130
+ if fallback_to_sms is not None:
1131
+ body["fallbackToSms"] = fallback_to_sms
1119
1132
 
1120
1133
  data = await self._http.request(
1121
1134
  method="POST",