sendly 4.0.0__tar.gz → 4.1.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 (58) hide show
  1. {sendly-4.0.0 → sendly-4.1.0}/CHANGELOG.md +11 -0
  2. {sendly-4.0.0 → sendly-4.1.0}/PKG-INFO +84 -1
  3. {sendly-4.0.0 → sendly-4.1.0}/README.md +83 -0
  4. {sendly-4.0.0 → sendly-4.1.0}/pyproject.toml +1 -1
  5. {sendly-4.0.0 → sendly-4.1.0}/sendly/__init__.py +33 -1
  6. {sendly-4.0.0 → sendly-4.1.0}/sendly/client.py +3 -0
  7. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/__init__.py +3 -0
  8. sendly-4.1.0/sendly/resources/calls.py +360 -0
  9. {sendly-4.0.0 → sendly-4.1.0}/sendly/types.py +228 -0
  10. {sendly-4.0.0 → sendly-4.1.0}/sendly/utils/http.py +1 -1
  11. sendly-4.1.0/tests/test_calls.py +1047 -0
  12. {sendly-4.0.0 → sendly-4.1.0}/tests/test_messages.py +23 -1
  13. {sendly-4.0.0 → sendly-4.1.0}/.github/header.svg +0 -0
  14. {sendly-4.0.0 → sendly-4.1.0}/.gitignore +0 -0
  15. {sendly-4.0.0 → sendly-4.1.0}/examples/async_example.py +0 -0
  16. {sendly-4.0.0 → sendly-4.1.0}/examples/error_handling.py +0 -0
  17. {sendly-4.0.0 → sendly-4.1.0}/examples/send_sms.py +0 -0
  18. {sendly-4.0.0 → sendly-4.1.0}/sendly/errors.py +0 -0
  19. {sendly-4.0.0 → sendly-4.1.0}/sendly/py.typed +0 -0
  20. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/account.py +0 -0
  21. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/business_upgrade.py +0 -0
  22. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/campaigns.py +0 -0
  23. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/contacts.py +0 -0
  24. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/conversations.py +0 -0
  25. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/drafts.py +0 -0
  26. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/enterprise.py +0 -0
  27. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/labels.py +0 -0
  28. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/links.py +0 -0
  29. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/media.py +0 -0
  30. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/messages.py +0 -0
  31. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/numbers.py +0 -0
  32. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/rcs.py +0 -0
  33. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/rules.py +0 -0
  34. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/templates.py +0 -0
  35. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/tendlc.py +0 -0
  36. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/verify.py +0 -0
  37. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/webhooks.py +0 -0
  38. {sendly-4.0.0 → sendly-4.1.0}/sendly/resources/whatsapp.py +0 -0
  39. {sendly-4.0.0 → sendly-4.1.0}/sendly/utils/__init__.py +0 -0
  40. {sendly-4.0.0 → sendly-4.1.0}/sendly/utils/validation.py +0 -0
  41. {sendly-4.0.0 → sendly-4.1.0}/sendly/webhooks.py +0 -0
  42. {sendly-4.0.0 → sendly-4.1.0}/tests/conftest.py +0 -0
  43. {sendly-4.0.0 → sendly-4.1.0}/tests/test_account.py +0 -0
  44. {sendly-4.0.0 → sendly-4.1.0}/tests/test_async_client.py +0 -0
  45. {sendly-4.0.0 → sendly-4.1.0}/tests/test_client.py +0 -0
  46. {sendly-4.0.0 → sendly-4.1.0}/tests/test_errors.py +0 -0
  47. {sendly-4.0.0 → sendly-4.1.0}/tests/test_http_body_kwarg.py +0 -0
  48. {sendly-4.0.0 → sendly-4.1.0}/tests/test_idempotency.py +0 -0
  49. {sendly-4.0.0 → sendly-4.1.0}/tests/test_messages.py.bak +0 -0
  50. {sendly-4.0.0 → sendly-4.1.0}/tests/test_messages_batch.py +0 -0
  51. {sendly-4.0.0 → sendly-4.1.0}/tests/test_messages_schedule.py +0 -0
  52. {sendly-4.0.0 → sendly-4.1.0}/tests/test_numbers.py +0 -0
  53. {sendly-4.0.0 → sendly-4.1.0}/tests/test_rcs.py +0 -0
  54. {sendly-4.0.0 → sendly-4.1.0}/tests/test_rcs_registration.py +0 -0
  55. {sendly-4.0.0 → sendly-4.1.0}/tests/test_tendlc.py +0 -0
  56. {sendly-4.0.0 → sendly-4.1.0}/tests/test_validation.py +0 -0
  57. {sendly-4.0.0 → sendly-4.1.0}/tests/test_webhooks.py +0 -0
  58. {sendly-4.0.0 → sendly-4.1.0}/tests/test_whatsapp.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # sendly (Python)
2
2
 
3
+ ## 4.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Voice calls: `client.calls`.** `create(to, agent_id, from_=..., context=..., metadata=...)` places a phone call that one of your workspace's AI agents handles; `list(...)`, `get(id)`, `hangup(id)` and `recording(id)` follow it, end it early and fetch the recording. Sync and async clients. Reads need the `calls:read` scope, writes `calls:write` and a live API key; until voice is enabled for your workspace the routes answer 404 `voice_not_enabled`.
8
+ - `voice_enabled` and `voice_mode` on owned numbers.
9
+
10
+ ### Patch Changes
11
+
12
+ - `MessageStatus` gains `RECEIVED` and `UNDELIVERED`. `messages.list()` returns inbound rows by default and their status is `received`, which the strict enum rejected, so listing failed for any workspace with inbound traffic. Any status this version does not know now maps to `MessageStatus.UNKNOWN` instead of raising.
13
+
3
14
  ## 4.0.0
4
15
 
5
16
  **Upgrading from 3.40.0:** that release already contained the breaking changes below, published by mistake as a minor version. 4.0.0 carries them under the correct major. Relative to 3.40.0, the only new changes are under **Security**.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 4.0.0
3
+ Version: 4.1.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
@@ -913,6 +913,67 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
913
913
  `request_launch()` replays return the original response without notifying the
914
914
  reviewers again.
915
915
 
916
+ ## Voice Calls
917
+
918
+ Place phone calls that one of your AI agents handles, follow them while they
919
+ ring and after they end, end them early, and download recordings. Requires an
920
+ API key with the `calls:read` / `calls:write` scopes; placing and ending calls
921
+ needs a live key. Voice is enabled workspace by workspace: until it is enabled
922
+ for yours, every call method raises `SendlyError` with code `voice_not_enabled`
923
+ (HTTP 404).
924
+
925
+ Calls are prepaid from your balance per started minute: 2 credits a minute
926
+ outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
927
+ for an agent-handled outbound call); unanswered calls cost nothing. The number
928
+ you call from must be voice-enabled in the dashboard (Calls, then Settings),
929
+ with an emergency address registered; `client.numbers.list()` shows each
930
+ number's `voice_enabled` and `voice_mode`. Destinations are US and Canada.
931
+
932
+ ```python
933
+ # Pick a voice-enabled number (optional when exactly one is voice-enabled)
934
+ voice_numbers = [n for n in client.numbers.list().numbers if n.voice_enabled]
935
+
936
+ # Place a call; the agent speaks first, using the context you pass
937
+ call = client.calls.create(
938
+ to='+15555550123',
939
+ agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
940
+ from_=voice_numbers[0].phone_number,
941
+ context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
942
+ metadata={'crmId': 'lead_8812'},
943
+ )
944
+ print(call.id, call.status) # ... 'ringing'
945
+
946
+ # Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
947
+ call = client.calls.get(call.id)
948
+ print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
949
+ for line in call.transcript or []: # agent calls only; None on other calls
950
+ print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
951
+
952
+ # List calls, newest first, with filters and paging
953
+ page = client.calls.list(status='completed', direction='outbound', limit=20)
954
+ for c in page.data:
955
+ print(c.id, c.to, c.duration_secs, c.credits_charged)
956
+ if page.pagination.has_more:
957
+ page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
958
+
959
+ # End a call early (idempotent: an ended call is returned unchanged)
960
+ client.calls.hangup(call.id)
961
+
962
+ # Fetch the recording; the signed URL is valid for 5 minutes
963
+ recording = client.calls.recording(call.id)
964
+ if recording.status == 'ready':
965
+ print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
966
+ ```
967
+
968
+ Refusals come back as `SendlyError` with a code that says what to do:
969
+ `insufficient_credits` (raised as `InsufficientCreditsError`; top up),
970
+ `e911_required` (register an emergency address for the number), `lines_busy`
971
+ (retry with backoff), `daily_call_limit` (try again tomorrow),
972
+ `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
973
+ `destination_not_supported`, `live_key_required`. Webhooks `call.started`,
974
+ `call.completed` and `call.recording.ready` carry the same call object in
975
+ snake_case, including `billing` and your `metadata`.
976
+
916
977
  ## Error Handling
917
978
 
918
979
  The SDK provides typed exception classes:
@@ -1207,6 +1268,28 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
1207
1268
 
1208
1269
  Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
1209
1270
 
1271
+ ### `client.calls`
1272
+
1273
+ #### `create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call`
1274
+
1275
+ Place a phone call handled by an AI agent (live key, `calls:write`). Returns the call while it is `ringing`; `from_` is required when more than one number is voice-enabled.
1276
+
1277
+ #### `list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse`
1278
+
1279
+ List calls newest first; `pagination.has_more` says whether another page exists.
1280
+
1281
+ #### `get(id) -> Call`
1282
+
1283
+ Fetch one call. Agent-handled calls carry `transcript`; on other calls it is `None`.
1284
+
1285
+ #### `hangup(id, idempotency_key=None) -> Call`
1286
+
1287
+ End a call (live key, `calls:write`). `ringing` becomes `cancelled`, `active` becomes `completed`; an ended call is returned unchanged.
1288
+
1289
+ #### `recording(id) -> CallRecording`
1290
+
1291
+ Where to download the recording. `url` and `expires_at` are set only while `status` is `ready`; the link is valid for 5 minutes.
1292
+
1210
1293
  ## Enterprise
1211
1294
 
1212
1295
  The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
@@ -878,6 +878,67 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
878
878
  `request_launch()` replays return the original response without notifying the
879
879
  reviewers again.
880
880
 
881
+ ## Voice Calls
882
+
883
+ Place phone calls that one of your AI agents handles, follow them while they
884
+ ring and after they end, end them early, and download recordings. Requires an
885
+ API key with the `calls:read` / `calls:write` scopes; placing and ending calls
886
+ needs a live key. Voice is enabled workspace by workspace: until it is enabled
887
+ for yours, every call method raises `SendlyError` with code `voice_not_enabled`
888
+ (HTTP 404).
889
+
890
+ Calls are prepaid from your balance per started minute: 2 credits a minute
891
+ outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
892
+ for an agent-handled outbound call); unanswered calls cost nothing. The number
893
+ you call from must be voice-enabled in the dashboard (Calls, then Settings),
894
+ with an emergency address registered; `client.numbers.list()` shows each
895
+ number's `voice_enabled` and `voice_mode`. Destinations are US and Canada.
896
+
897
+ ```python
898
+ # Pick a voice-enabled number (optional when exactly one is voice-enabled)
899
+ voice_numbers = [n for n in client.numbers.list().numbers if n.voice_enabled]
900
+
901
+ # Place a call; the agent speaks first, using the context you pass
902
+ call = client.calls.create(
903
+ to='+15555550123',
904
+ agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
905
+ from_=voice_numbers[0].phone_number,
906
+ context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
907
+ metadata={'crmId': 'lead_8812'},
908
+ )
909
+ print(call.id, call.status) # ... 'ringing'
910
+
911
+ # Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
912
+ call = client.calls.get(call.id)
913
+ print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
914
+ for line in call.transcript or []: # agent calls only; None on other calls
915
+ print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
916
+
917
+ # List calls, newest first, with filters and paging
918
+ page = client.calls.list(status='completed', direction='outbound', limit=20)
919
+ for c in page.data:
920
+ print(c.id, c.to, c.duration_secs, c.credits_charged)
921
+ if page.pagination.has_more:
922
+ page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
923
+
924
+ # End a call early (idempotent: an ended call is returned unchanged)
925
+ client.calls.hangup(call.id)
926
+
927
+ # Fetch the recording; the signed URL is valid for 5 minutes
928
+ recording = client.calls.recording(call.id)
929
+ if recording.status == 'ready':
930
+ print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
931
+ ```
932
+
933
+ Refusals come back as `SendlyError` with a code that says what to do:
934
+ `insufficient_credits` (raised as `InsufficientCreditsError`; top up),
935
+ `e911_required` (register an emergency address for the number), `lines_busy`
936
+ (retry with backoff), `daily_call_limit` (try again tomorrow),
937
+ `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
938
+ `destination_not_supported`, `live_key_required`. Webhooks `call.started`,
939
+ `call.completed` and `call.recording.ready` carry the same call object in
940
+ snake_case, including `billing` and your `metadata`.
941
+
881
942
  ## Error Handling
882
943
 
883
944
  The SDK provides typed exception classes:
@@ -1172,6 +1233,28 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
1172
1233
 
1173
1234
  Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
1174
1235
 
1236
+ ### `client.calls`
1237
+
1238
+ #### `create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call`
1239
+
1240
+ Place a phone call handled by an AI agent (live key, `calls:write`). Returns the call while it is `ringing`; `from_` is required when more than one number is voice-enabled.
1241
+
1242
+ #### `list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse`
1243
+
1244
+ List calls newest first; `pagination.has_more` says whether another page exists.
1245
+
1246
+ #### `get(id) -> Call`
1247
+
1248
+ Fetch one call. Agent-handled calls carry `transcript`; on other calls it is `None`.
1249
+
1250
+ #### `hangup(id, idempotency_key=None) -> Call`
1251
+
1252
+ End a call (live key, `calls:write`). `ringing` becomes `cancelled`, `active` becomes `completed`; an ended call is returned unchanged.
1253
+
1254
+ #### `recording(id) -> CallRecording`
1255
+
1256
+ Where to download the recording. `url` and `expires_at` are set only while `status` is `ready`; the link is valid for 5 minutes.
1257
+
1175
1258
  ## Enterprise
1176
1259
 
1177
1260
  The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "4.0.0"
7
+ version = "4.1.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.0.0"
32
+ __version__ = "4.1.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -51,6 +51,7 @@ from .resources.business_upgrade import (
51
51
  AsyncBusinessUpgradeResource,
52
52
  BusinessUpgradeResource,
53
53
  )
54
+ from .resources.calls import AsyncCallsResource, CallsResource
54
55
  from .resources.links import AsyncLinksResource, LinksResource
55
56
  from .resources.media import AsyncMediaResource, MediaResource
56
57
  from .resources.numbers import AsyncNumbersResource, NumbersResource
@@ -194,6 +195,20 @@ from .types import (
194
195
  RcsTestDeviceInput,
195
196
  RcsTestDeviceListResponse,
196
197
  RcsTesting,
198
+ # Voice call types
199
+ Call,
200
+ CallBilling,
201
+ CallDirection,
202
+ CallHandledBy,
203
+ CallKind,
204
+ CallListResponse,
205
+ CallPagination,
206
+ CallRecording,
207
+ CallRecordingStatus,
208
+ CallStatus,
209
+ CallTranscriptLine,
210
+ CreateCallRequest,
211
+ ListCallsOptions,
197
212
  OptInPage,
198
213
  PricingTier,
199
214
  QuotaSettings,
@@ -406,6 +421,20 @@ __all__ = [
406
421
  "RcsTestDeviceInput",
407
422
  "RcsTestDeviceListResponse",
408
423
  "RcsTesting",
424
+ # Voice call types
425
+ "Call",
426
+ "CallBilling",
427
+ "CallDirection",
428
+ "CallHandledBy",
429
+ "CallKind",
430
+ "CallListResponse",
431
+ "CallPagination",
432
+ "CallRecording",
433
+ "CallRecordingStatus",
434
+ "CallStatus",
435
+ "CallTranscriptLine",
436
+ "CreateCallRequest",
437
+ "ListCallsOptions",
409
438
  # Links resources
410
439
  "LinksResource",
411
440
  "AsyncLinksResource",
@@ -427,6 +456,9 @@ __all__ = [
427
456
  # RCS resources
428
457
  "RcsResource",
429
458
  "AsyncRcsResource",
459
+ # Voice call resources
460
+ "CallsResource",
461
+ "AsyncCallsResource",
430
462
  # Errors
431
463
  "SendlyError",
432
464
  "AuthenticationError",
@@ -11,6 +11,7 @@ from .resources.business_upgrade import (
11
11
  AsyncBusinessUpgradeResource,
12
12
  BusinessUpgradeResource,
13
13
  )
14
+ from .resources.calls import AsyncCallsResource, CallsResource
14
15
  from .resources.campaigns import AsyncCampaignsResource, CampaignsResource
15
16
  from .resources.contacts import AsyncContactsResource, ContactsResource
16
17
  from .resources.conversations import AsyncConversationsResource, ConversationsResource
@@ -132,6 +133,7 @@ class Sendly:
132
133
  self.business_upgrade = BusinessUpgradeResource(self._http)
133
134
  self.whatsapp = WhatsAppResource(self._http)
134
135
  self.rcs = RcsResource(self._http)
136
+ self.calls = CallsResource(self._http)
135
137
 
136
138
  def __enter__(self) -> "Sendly":
137
139
  return self
@@ -275,6 +277,7 @@ class AsyncSendly:
275
277
  self.business_upgrade = AsyncBusinessUpgradeResource(self._http)
276
278
  self.whatsapp = AsyncWhatsAppResource(self._http)
277
279
  self.rcs = AsyncRcsResource(self._http)
280
+ self.calls = AsyncCallsResource(self._http)
278
281
 
279
282
  async def __aenter__(self) -> "AsyncSendly":
280
283
  return self
@@ -1,5 +1,6 @@
1
1
  """Sendly SDK Resources"""
2
2
 
3
+ from .calls import AsyncCallsResource, CallsResource
3
4
  from .campaigns import AsyncCampaignsResource, CampaignsResource
4
5
  from .contacts import AsyncContactsResource, ContactsResource
5
6
  from .conversations import AsyncConversationsResource, ConversationsResource
@@ -17,6 +18,8 @@ from .verify import AsyncVerifyResource, VerifyResource
17
18
  from .whatsapp import AsyncWhatsAppResource, WhatsAppResource
18
19
 
19
20
  __all__ = [
21
+ "CallsResource",
22
+ "AsyncCallsResource",
20
23
  "CampaignsResource",
21
24
  "AsyncCampaignsResource",
22
25
  "ContactsResource",