sendly 4.0.0__tar.gz → 4.2.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 (60) hide show
  1. {sendly-4.0.0 → sendly-4.2.0}/CHANGELOG.md +22 -0
  2. {sendly-4.0.0 → sendly-4.2.0}/PKG-INFO +217 -6
  3. {sendly-4.0.0 → sendly-4.2.0}/README.md +216 -5
  4. {sendly-4.0.0 → sendly-4.2.0}/pyproject.toml +1 -1
  5. {sendly-4.0.0 → sendly-4.2.0}/sendly/__init__.py +69 -1
  6. {sendly-4.0.0 → sendly-4.2.0}/sendly/client.py +6 -0
  7. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/__init__.py +6 -0
  8. sendly-4.2.0/sendly/resources/calls.py +362 -0
  9. sendly-4.2.0/sendly/resources/voice.py +642 -0
  10. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/whatsapp.py +1 -1
  11. {sendly-4.0.0 → sendly-4.2.0}/sendly/types.py +439 -0
  12. {sendly-4.0.0 → sendly-4.2.0}/sendly/utils/http.py +1 -1
  13. sendly-4.2.0/tests/test_calls.py +1047 -0
  14. {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages.py +23 -1
  15. sendly-4.2.0/tests/test_voice.py +1001 -0
  16. {sendly-4.0.0 → sendly-4.2.0}/.github/header.svg +0 -0
  17. {sendly-4.0.0 → sendly-4.2.0}/.gitignore +0 -0
  18. {sendly-4.0.0 → sendly-4.2.0}/examples/async_example.py +0 -0
  19. {sendly-4.0.0 → sendly-4.2.0}/examples/error_handling.py +0 -0
  20. {sendly-4.0.0 → sendly-4.2.0}/examples/send_sms.py +0 -0
  21. {sendly-4.0.0 → sendly-4.2.0}/sendly/errors.py +0 -0
  22. {sendly-4.0.0 → sendly-4.2.0}/sendly/py.typed +0 -0
  23. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/account.py +0 -0
  24. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/business_upgrade.py +0 -0
  25. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/campaigns.py +0 -0
  26. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/contacts.py +0 -0
  27. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/conversations.py +0 -0
  28. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/drafts.py +0 -0
  29. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/enterprise.py +0 -0
  30. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/labels.py +0 -0
  31. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/links.py +0 -0
  32. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/media.py +0 -0
  33. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/messages.py +0 -0
  34. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/numbers.py +0 -0
  35. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/rcs.py +0 -0
  36. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/rules.py +0 -0
  37. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/templates.py +0 -0
  38. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/tendlc.py +0 -0
  39. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/verify.py +0 -0
  40. {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/webhooks.py +0 -0
  41. {sendly-4.0.0 → sendly-4.2.0}/sendly/utils/__init__.py +0 -0
  42. {sendly-4.0.0 → sendly-4.2.0}/sendly/utils/validation.py +0 -0
  43. {sendly-4.0.0 → sendly-4.2.0}/sendly/webhooks.py +0 -0
  44. {sendly-4.0.0 → sendly-4.2.0}/tests/conftest.py +0 -0
  45. {sendly-4.0.0 → sendly-4.2.0}/tests/test_account.py +0 -0
  46. {sendly-4.0.0 → sendly-4.2.0}/tests/test_async_client.py +0 -0
  47. {sendly-4.0.0 → sendly-4.2.0}/tests/test_client.py +0 -0
  48. {sendly-4.0.0 → sendly-4.2.0}/tests/test_errors.py +0 -0
  49. {sendly-4.0.0 → sendly-4.2.0}/tests/test_http_body_kwarg.py +0 -0
  50. {sendly-4.0.0 → sendly-4.2.0}/tests/test_idempotency.py +0 -0
  51. {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages.py.bak +0 -0
  52. {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages_batch.py +0 -0
  53. {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages_schedule.py +0 -0
  54. {sendly-4.0.0 → sendly-4.2.0}/tests/test_numbers.py +0 -0
  55. {sendly-4.0.0 → sendly-4.2.0}/tests/test_rcs.py +0 -0
  56. {sendly-4.0.0 → sendly-4.2.0}/tests/test_rcs_registration.py +0 -0
  57. {sendly-4.0.0 → sendly-4.2.0}/tests/test_tendlc.py +0 -0
  58. {sendly-4.0.0 → sendly-4.2.0}/tests/test_validation.py +0 -0
  59. {sendly-4.0.0 → sendly-4.2.0}/tests/test_webhooks.py +0 -0
  60. {sendly-4.0.0 → sendly-4.2.0}/tests/test_whatsapp.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # sendly (Python)
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Voice configuration: `client.voice`.** Everything a phone call depends on is now configurable from code. `voice.numbers.list()`, `get(number)`, `update(number, voice_enabled=..., voice_mode=..., agent_id=...)` and `register_emergency_address(number, street=..., unit=..., city=..., state=..., zip=..., country=...)` switch voice on for a number, choose how it answers and register the emergency address it needs before it can place calls; `number` is the number's id or its E.164 phone number. `voice.agents.list()`, `create(name, ...)`, `get(id)`, `update(id, ...)` and `delete(id)` manage the AI agents that talk, and `voice.voices.list()` lists the voices they can use. Sync and async clients. Reads need the `calls:read` scope, writes `calls:write` and a live API key. Deleting an agent that still answers a number raises `SendlyError` with code `agent_in_use` (HTTP 409) and the numbers in `e.response.model_extra['numbers']`.
8
+ - New types: `VoiceNumber`, `VoiceNumberListResponse`, `VoiceNumberEmergencyAddress`, `EmergencyAddress`, `VoiceNumberRates`, `VoiceMode`, `VoiceAgent`, `VoiceAgentListResponse`, `VoiceAgentTools`, `Voice`, `VoiceListResponse`, `CreateVoiceAgentRequest`, `UpdateVoiceAgentRequest`, `UpdateVoiceNumberRequest` and `DeletedVoiceAgent`.
9
+
10
+ ### Patch Changes
11
+
12
+ - The `calls.recording()` docstring had the channels the wrong way round. Agent calls are recorded with the agent on the left channel and the other party on the right.
13
+
14
+ ## 4.1.0
15
+
16
+ ### Minor Changes
17
+
18
+ - **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`.
19
+ - `voice_enabled` and `voice_mode` on owned numbers.
20
+
21
+ ### Patch Changes
22
+
23
+ - `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.
24
+
3
25
  ## 4.0.0
4
26
 
5
27
  **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.2.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
@@ -884,21 +884,28 @@ print(registration.stage) # 'in_review' -> 'brand_verification' -> 'agent_revie
884
884
  # 5. Once in testing: invite devices, describe the campaign, then request launch
885
885
  if registration.stage == RcsCustomerStage.TESTING:
886
886
  client.rcs.agents.set_test_devices(agent.id, [
887
- '+15551234567',
888
- {'phone_number': '+15557654321', 'label': 'Sam'},
887
+ '+15125550142',
888
+ {'phone_number': '+15125550177', 'label': 'Sam'},
889
889
  ])
890
890
  client.rcs.agents.update(
891
891
  agent.id,
892
892
  campaign={
893
+ 'company_overview': 'Acme Coffee runs 12 cafes and an online store.',
893
894
  'agent_overview': 'Order updates and support replies',
894
895
  'interactions': [{'interaction_type': 'TRANSACTIONAL_UPDATES',
895
896
  'description': 'Shipping and delivery updates'}],
896
- 'message_examples': ['Your order #123 has shipped!',
897
- 'Your table is ready!',
898
- 'Reply HELP for help'],
897
+ 'message_examples': ['Acme Coffee: order #123 has shipped. Reply STOP to opt out.',
898
+ 'Acme Coffee: your table is ready!',
899
+ 'Acme Coffee: reply HELP for help.'],
899
900
  'consent_settings': {
900
901
  'opt_in_methods': [{'method_type': 'WEBSITE',
901
902
  'description': 'Checkout checkbox'}],
903
+ 'call_to_action': 'Get order updates by message',
904
+ 'call_to_action_url': 'https://acme.example/checkout',
905
+ 'call_to_action_media_url': 'https://acme.example/rcs/opt-in.png',
906
+ 'double_opt_in': False,
907
+ 'opt_in_message': 'Acme Coffee: you are subscribed. Reply HELP for help, STOP to opt out.',
908
+ 'help_response': 'Acme Coffee: email help@acme.example for help.',
902
909
  'opt_out_response': 'You are unsubscribed.',
903
910
  },
904
911
  },
@@ -913,6 +920,146 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
913
920
  `request_launch()` replays return the original response without notifying the
914
921
  reviewers again.
915
922
 
923
+ ## Voice Calls
924
+
925
+ Place phone calls that one of your AI agents handles, follow them while they
926
+ ring and after they end, end them early, and download recordings. Requires an
927
+ API key with the `calls:read` / `calls:write` scopes; placing and ending calls
928
+ needs a live key. Voice is enabled workspace by workspace: until it is enabled
929
+ for yours, every call method raises `SendlyError` with code `voice_not_enabled`
930
+ (HTTP 404).
931
+
932
+ Calls are prepaid from your balance per started minute: 2 credits a minute
933
+ outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
934
+ for an agent-handled outbound call); unanswered calls cost nothing. The number
935
+ you call from must be voice-enabled, with an emergency address registered (see
936
+ [Configure voice](#configure-voice)); `client.voice.numbers.list()` shows each
937
+ number's `voice_enabled`, `voice_mode` and `emergency_address`. Destinations
938
+ are US and Canada.
939
+
940
+ ```python
941
+ # Pick a voice-enabled number (optional when exactly one is voice-enabled)
942
+ voice_numbers = [n for n in client.voice.numbers.list().data if n.voice_enabled]
943
+
944
+ # Place a call; the agent speaks first, using the context you pass
945
+ call = client.calls.create(
946
+ to='+15555550123',
947
+ agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
948
+ from_=voice_numbers[0].phone_number,
949
+ context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
950
+ metadata={'crmId': 'lead_8812'},
951
+ )
952
+ print(call.id, call.status) # ... 'ringing'
953
+
954
+ # Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
955
+ call = client.calls.get(call.id)
956
+ print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
957
+ for line in call.transcript or []: # agent calls only; None on other calls
958
+ print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
959
+
960
+ # List calls, newest first, with filters and paging
961
+ page = client.calls.list(status='completed', direction='outbound', limit=20)
962
+ for c in page.data:
963
+ print(c.id, c.to, c.duration_secs, c.credits_charged)
964
+ if page.pagination.has_more:
965
+ page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
966
+
967
+ # End a call early (idempotent: an ended call is returned unchanged)
968
+ client.calls.hangup(call.id)
969
+
970
+ # Fetch the recording; the signed URL is valid for 5 minutes
971
+ recording = client.calls.recording(call.id)
972
+ if recording.status == 'ready':
973
+ print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
974
+ ```
975
+
976
+ Refusals come back as `SendlyError` with a code that says what to do:
977
+ `insufficient_credits` (raised as `InsufficientCreditsError`; top up),
978
+ `e911_required` (register an emergency address for the number), `lines_busy`
979
+ (retry with backoff), `daily_call_limit` (try again tomorrow),
980
+ `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
981
+ `destination_not_supported`, `live_key_required`. Webhooks `call.started`,
982
+ `call.completed` and `call.recording.ready` carry the same call object in
983
+ snake_case, including `billing` and your `metadata`.
984
+
985
+ ### Configure voice
986
+
987
+ Everything a call depends on can be set up from code: switch voice on for a
988
+ number and choose how it answers, register its emergency address, and create
989
+ the AI agents that talk. Reads need `calls:read`; writes need `calls:write` and
990
+ a live key. In a team workspace, changing numbers and managing agents needs an
991
+ owner or admin role (`forbidden` otherwise). These settings change how real
992
+ phone calls to your numbers are answered.
993
+
994
+ ```python
995
+ from sendly import SendlyError
996
+
997
+ # The voices an agent can speak with
998
+ for v in client.voice.voices.list().data:
999
+ print(v.id, v.label, v.language)
1000
+
1001
+ # Create an agent (up to 20 per workspace); it gets its own key for texting
1002
+ agent = client.voice.agents.create(
1003
+ name='Front desk',
1004
+ voice='ashley',
1005
+ greeting='Thanks for calling Acme, how can I help?',
1006
+ instructions='Answer questions about opening hours and take a message for anything else.',
1007
+ tools={'send_sms': True},
1008
+ )
1009
+ print(agent.id, agent.voice_label, agent.can_send_sms)
1010
+
1011
+ # Edit it later; only the fields you pass change
1012
+ client.voice.agents.update(agent.id, greeting='Hi, you have reached Acme.')
1013
+
1014
+ # Numbers with their voice settings; pass either the id or the E.164 number
1015
+ for n in client.voice.numbers.list().data:
1016
+ e911 = n.emergency_address.status if n.emergency_address else 'none'
1017
+ print(n.phone_number, n.voice_mode, n.agent_id, e911)
1018
+
1019
+ # Register the emergency address a number needs before it can place calls
1020
+ # (US and Canadian numbers; the first registration adds 1.50 USD a month)
1021
+ number = client.voice.numbers.register_emergency_address(
1022
+ '+15555550188',
1023
+ street='500 Example Ave',
1024
+ unit='Suite 2',
1025
+ city='Austin',
1026
+ state='TX',
1027
+ zip='78701',
1028
+ )
1029
+
1030
+ # Let the agent answer the number (voice_mode='ring_dashboard' rings your team)
1031
+ number = client.voice.numbers.update(
1032
+ '+15555550188', voice_enabled=True, voice_mode='agent', agent_id=agent.id
1033
+ )
1034
+ print(number.voice_mode, number.rate_per_minute.agent) # credits a minute
1035
+
1036
+ # Switch voice off (the same as voice_mode='none')
1037
+ client.voice.numbers.update('+15555550188', voice_enabled=False)
1038
+
1039
+ # Deleting an agent revokes its key; refused while a number still points at it
1040
+ try:
1041
+ client.voice.agents.delete(agent.id)
1042
+ except SendlyError as e:
1043
+ if e.code == 'agent_in_use':
1044
+ print(e.response.model_extra['numbers'])
1045
+ ```
1046
+
1047
+ Without `voice_enabled`, `voice_mode='ring_dashboard'` or `'agent'` switches
1048
+ voice on and `voice_mode='none'` switches it off; `voice_enabled=False` always
1049
+ switches voice off.
1050
+
1051
+ Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
1052
+ `agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
1053
+ `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
1054
+ retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
1055
+ says the number couldn't be found for emergency registration: contact
1056
+ support), `voice_unavailable` (HTTP 503),
1057
+ `e911_not_applicable` (only US and Canadian numbers take an emergency
1058
+ address) and `invalid_address`; on HTTP 422 the closest valid address is in
1059
+ `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
1060
+ stored, but agents do not transfer calls yet: when a caller asks for a person,
1061
+ the agent offers to pass a message on and takes their name and number.
1062
+
916
1063
  ## Error Handling
917
1064
 
918
1065
  The SDK provides typed exception classes:
@@ -1207,6 +1354,70 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
1207
1354
 
1208
1355
  Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
1209
1356
 
1357
+ ### `client.calls`
1358
+
1359
+ #### `create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call`
1360
+
1361
+ 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.
1362
+
1363
+ #### `list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse`
1364
+
1365
+ List calls newest first; `pagination.has_more` says whether another page exists.
1366
+
1367
+ #### `get(id) -> Call`
1368
+
1369
+ Fetch one call. Agent-handled calls carry `transcript`; on other calls it is `None`.
1370
+
1371
+ #### `hangup(id, idempotency_key=None) -> Call`
1372
+
1373
+ End a call (live key, `calls:write`). `ringing` becomes `cancelled`, `active` becomes `completed`; an ended call is returned unchanged.
1374
+
1375
+ #### `recording(id) -> CallRecording`
1376
+
1377
+ Where to download the recording. `url` and `expires_at` are set only while `status` is `ready`; the link is valid for 5 minutes.
1378
+
1379
+ ### `client.voice`
1380
+
1381
+ #### `numbers.list() -> VoiceNumberListResponse`
1382
+
1383
+ Active numbers in the workspace with `voice_enabled`, `voice_mode`, `agent_id`, `emergency_address` and `rate_per_minute`.
1384
+
1385
+ #### `numbers.get(number) -> VoiceNumber`
1386
+
1387
+ One number's voice settings. `number` is the number's id or its E.164 phone number.
1388
+
1389
+ #### `numbers.update(number, voice_enabled=None, voice_mode=None, agent_id=None, idempotency_key=None) -> VoiceNumber`
1390
+
1391
+ Change how a number answers calls (live key, `calls:write`). Without `voice_enabled`, `ring_dashboard` or `agent` switches voice on and `none` switches it off; `voice_enabled=False` always switches it off. `agent` needs an enabled agent.
1392
+
1393
+ #### `numbers.register_emergency_address(number, street, city, state, zip, unit=None, country=None, idempotency_key=None) -> VoiceNumber`
1394
+
1395
+ Register the emergency address a number needs before it can place calls (live key, `calls:write`). US and Canadian numbers only.
1396
+
1397
+ #### `agents.list() -> VoiceAgentListResponse`
1398
+
1399
+ The workspace's AI agents.
1400
+
1401
+ #### `agents.create(name, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
1402
+
1403
+ Create an AI agent (live key, `calls:write`). Up to 20 per workspace.
1404
+
1405
+ #### `agents.get(id) -> VoiceAgent`
1406
+
1407
+ Fetch one agent.
1408
+
1409
+ #### `agents.update(id, name=None, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
1410
+
1411
+ Edit an agent; only the fields you pass change, and `tools` may be partial.
1412
+
1413
+ #### `agents.delete(id, idempotency_key=None) -> DeletedVoiceAgent`
1414
+
1415
+ Delete an agent and revoke its sending key. Raises `agent_in_use` (HTTP 409) while a number still points at it.
1416
+
1417
+ #### `voices.list() -> VoiceListResponse`
1418
+
1419
+ The voices an agent can speak with; pass a voice's `id` as `voice`.
1420
+
1210
1421
  ## Enterprise
1211
1422
 
1212
1423
  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_*`).
@@ -849,21 +849,28 @@ print(registration.stage) # 'in_review' -> 'brand_verification' -> 'agent_revie
849
849
  # 5. Once in testing: invite devices, describe the campaign, then request launch
850
850
  if registration.stage == RcsCustomerStage.TESTING:
851
851
  client.rcs.agents.set_test_devices(agent.id, [
852
- '+15551234567',
853
- {'phone_number': '+15557654321', 'label': 'Sam'},
852
+ '+15125550142',
853
+ {'phone_number': '+15125550177', 'label': 'Sam'},
854
854
  ])
855
855
  client.rcs.agents.update(
856
856
  agent.id,
857
857
  campaign={
858
+ 'company_overview': 'Acme Coffee runs 12 cafes and an online store.',
858
859
  'agent_overview': 'Order updates and support replies',
859
860
  'interactions': [{'interaction_type': 'TRANSACTIONAL_UPDATES',
860
861
  'description': 'Shipping and delivery updates'}],
861
- 'message_examples': ['Your order #123 has shipped!',
862
- 'Your table is ready!',
863
- 'Reply HELP for help'],
862
+ 'message_examples': ['Acme Coffee: order #123 has shipped. Reply STOP to opt out.',
863
+ 'Acme Coffee: your table is ready!',
864
+ 'Acme Coffee: reply HELP for help.'],
864
865
  'consent_settings': {
865
866
  'opt_in_methods': [{'method_type': 'WEBSITE',
866
867
  'description': 'Checkout checkbox'}],
868
+ 'call_to_action': 'Get order updates by message',
869
+ 'call_to_action_url': 'https://acme.example/checkout',
870
+ 'call_to_action_media_url': 'https://acme.example/rcs/opt-in.png',
871
+ 'double_opt_in': False,
872
+ 'opt_in_message': 'Acme Coffee: you are subscribed. Reply HELP for help, STOP to opt out.',
873
+ 'help_response': 'Acme Coffee: email help@acme.example for help.',
867
874
  'opt_out_response': 'You are unsubscribed.',
868
875
  },
869
876
  },
@@ -878,6 +885,146 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
878
885
  `request_launch()` replays return the original response without notifying the
879
886
  reviewers again.
880
887
 
888
+ ## Voice Calls
889
+
890
+ Place phone calls that one of your AI agents handles, follow them while they
891
+ ring and after they end, end them early, and download recordings. Requires an
892
+ API key with the `calls:read` / `calls:write` scopes; placing and ending calls
893
+ needs a live key. Voice is enabled workspace by workspace: until it is enabled
894
+ for yours, every call method raises `SendlyError` with code `voice_not_enabled`
895
+ (HTTP 404).
896
+
897
+ Calls are prepaid from your balance per started minute: 2 credits a minute
898
+ outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
899
+ for an agent-handled outbound call); unanswered calls cost nothing. The number
900
+ you call from must be voice-enabled, with an emergency address registered (see
901
+ [Configure voice](#configure-voice)); `client.voice.numbers.list()` shows each
902
+ number's `voice_enabled`, `voice_mode` and `emergency_address`. Destinations
903
+ are US and Canada.
904
+
905
+ ```python
906
+ # Pick a voice-enabled number (optional when exactly one is voice-enabled)
907
+ voice_numbers = [n for n in client.voice.numbers.list().data if n.voice_enabled]
908
+
909
+ # Place a call; the agent speaks first, using the context you pass
910
+ call = client.calls.create(
911
+ to='+15555550123',
912
+ agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
913
+ from_=voice_numbers[0].phone_number,
914
+ context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
915
+ metadata={'crmId': 'lead_8812'},
916
+ )
917
+ print(call.id, call.status) # ... 'ringing'
918
+
919
+ # Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
920
+ call = client.calls.get(call.id)
921
+ print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
922
+ for line in call.transcript or []: # agent calls only; None on other calls
923
+ print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
924
+
925
+ # List calls, newest first, with filters and paging
926
+ page = client.calls.list(status='completed', direction='outbound', limit=20)
927
+ for c in page.data:
928
+ print(c.id, c.to, c.duration_secs, c.credits_charged)
929
+ if page.pagination.has_more:
930
+ page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
931
+
932
+ # End a call early (idempotent: an ended call is returned unchanged)
933
+ client.calls.hangup(call.id)
934
+
935
+ # Fetch the recording; the signed URL is valid for 5 minutes
936
+ recording = client.calls.recording(call.id)
937
+ if recording.status == 'ready':
938
+ print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
939
+ ```
940
+
941
+ Refusals come back as `SendlyError` with a code that says what to do:
942
+ `insufficient_credits` (raised as `InsufficientCreditsError`; top up),
943
+ `e911_required` (register an emergency address for the number), `lines_busy`
944
+ (retry with backoff), `daily_call_limit` (try again tomorrow),
945
+ `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
946
+ `destination_not_supported`, `live_key_required`. Webhooks `call.started`,
947
+ `call.completed` and `call.recording.ready` carry the same call object in
948
+ snake_case, including `billing` and your `metadata`.
949
+
950
+ ### Configure voice
951
+
952
+ Everything a call depends on can be set up from code: switch voice on for a
953
+ number and choose how it answers, register its emergency address, and create
954
+ the AI agents that talk. Reads need `calls:read`; writes need `calls:write` and
955
+ a live key. In a team workspace, changing numbers and managing agents needs an
956
+ owner or admin role (`forbidden` otherwise). These settings change how real
957
+ phone calls to your numbers are answered.
958
+
959
+ ```python
960
+ from sendly import SendlyError
961
+
962
+ # The voices an agent can speak with
963
+ for v in client.voice.voices.list().data:
964
+ print(v.id, v.label, v.language)
965
+
966
+ # Create an agent (up to 20 per workspace); it gets its own key for texting
967
+ agent = client.voice.agents.create(
968
+ name='Front desk',
969
+ voice='ashley',
970
+ greeting='Thanks for calling Acme, how can I help?',
971
+ instructions='Answer questions about opening hours and take a message for anything else.',
972
+ tools={'send_sms': True},
973
+ )
974
+ print(agent.id, agent.voice_label, agent.can_send_sms)
975
+
976
+ # Edit it later; only the fields you pass change
977
+ client.voice.agents.update(agent.id, greeting='Hi, you have reached Acme.')
978
+
979
+ # Numbers with their voice settings; pass either the id or the E.164 number
980
+ for n in client.voice.numbers.list().data:
981
+ e911 = n.emergency_address.status if n.emergency_address else 'none'
982
+ print(n.phone_number, n.voice_mode, n.agent_id, e911)
983
+
984
+ # Register the emergency address a number needs before it can place calls
985
+ # (US and Canadian numbers; the first registration adds 1.50 USD a month)
986
+ number = client.voice.numbers.register_emergency_address(
987
+ '+15555550188',
988
+ street='500 Example Ave',
989
+ unit='Suite 2',
990
+ city='Austin',
991
+ state='TX',
992
+ zip='78701',
993
+ )
994
+
995
+ # Let the agent answer the number (voice_mode='ring_dashboard' rings your team)
996
+ number = client.voice.numbers.update(
997
+ '+15555550188', voice_enabled=True, voice_mode='agent', agent_id=agent.id
998
+ )
999
+ print(number.voice_mode, number.rate_per_minute.agent) # credits a minute
1000
+
1001
+ # Switch voice off (the same as voice_mode='none')
1002
+ client.voice.numbers.update('+15555550188', voice_enabled=False)
1003
+
1004
+ # Deleting an agent revokes its key; refused while a number still points at it
1005
+ try:
1006
+ client.voice.agents.delete(agent.id)
1007
+ except SendlyError as e:
1008
+ if e.code == 'agent_in_use':
1009
+ print(e.response.model_extra['numbers'])
1010
+ ```
1011
+
1012
+ Without `voice_enabled`, `voice_mode='ring_dashboard'` or `'agent'` switches
1013
+ voice on and `voice_mode='none'` switches it off; `voice_enabled=False` always
1014
+ switches voice off.
1015
+
1016
+ Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
1017
+ `agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
1018
+ `number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
1019
+ retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
1020
+ says the number couldn't be found for emergency registration: contact
1021
+ support), `voice_unavailable` (HTTP 503),
1022
+ `e911_not_applicable` (only US and Canadian numbers take an emergency
1023
+ address) and `invalid_address`; on HTTP 422 the closest valid address is in
1024
+ `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
1025
+ stored, but agents do not transfer calls yet: when a caller asks for a person,
1026
+ the agent offers to pass a message on and takes their name and number.
1027
+
881
1028
  ## Error Handling
882
1029
 
883
1030
  The SDK provides typed exception classes:
@@ -1172,6 +1319,70 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
1172
1319
 
1173
1320
  Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
1174
1321
 
1322
+ ### `client.calls`
1323
+
1324
+ #### `create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call`
1325
+
1326
+ 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.
1327
+
1328
+ #### `list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse`
1329
+
1330
+ List calls newest first; `pagination.has_more` says whether another page exists.
1331
+
1332
+ #### `get(id) -> Call`
1333
+
1334
+ Fetch one call. Agent-handled calls carry `transcript`; on other calls it is `None`.
1335
+
1336
+ #### `hangup(id, idempotency_key=None) -> Call`
1337
+
1338
+ End a call (live key, `calls:write`). `ringing` becomes `cancelled`, `active` becomes `completed`; an ended call is returned unchanged.
1339
+
1340
+ #### `recording(id) -> CallRecording`
1341
+
1342
+ Where to download the recording. `url` and `expires_at` are set only while `status` is `ready`; the link is valid for 5 minutes.
1343
+
1344
+ ### `client.voice`
1345
+
1346
+ #### `numbers.list() -> VoiceNumberListResponse`
1347
+
1348
+ Active numbers in the workspace with `voice_enabled`, `voice_mode`, `agent_id`, `emergency_address` and `rate_per_minute`.
1349
+
1350
+ #### `numbers.get(number) -> VoiceNumber`
1351
+
1352
+ One number's voice settings. `number` is the number's id or its E.164 phone number.
1353
+
1354
+ #### `numbers.update(number, voice_enabled=None, voice_mode=None, agent_id=None, idempotency_key=None) -> VoiceNumber`
1355
+
1356
+ Change how a number answers calls (live key, `calls:write`). Without `voice_enabled`, `ring_dashboard` or `agent` switches voice on and `none` switches it off; `voice_enabled=False` always switches it off. `agent` needs an enabled agent.
1357
+
1358
+ #### `numbers.register_emergency_address(number, street, city, state, zip, unit=None, country=None, idempotency_key=None) -> VoiceNumber`
1359
+
1360
+ Register the emergency address a number needs before it can place calls (live key, `calls:write`). US and Canadian numbers only.
1361
+
1362
+ #### `agents.list() -> VoiceAgentListResponse`
1363
+
1364
+ The workspace's AI agents.
1365
+
1366
+ #### `agents.create(name, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
1367
+
1368
+ Create an AI agent (live key, `calls:write`). Up to 20 per workspace.
1369
+
1370
+ #### `agents.get(id) -> VoiceAgent`
1371
+
1372
+ Fetch one agent.
1373
+
1374
+ #### `agents.update(id, name=None, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
1375
+
1376
+ Edit an agent; only the fields you pass change, and `tools` may be partial.
1377
+
1378
+ #### `agents.delete(id, idempotency_key=None) -> DeletedVoiceAgent`
1379
+
1380
+ Delete an agent and revoke its sending key. Raises `agent_in_use` (HTTP 409) while a number still points at it.
1381
+
1382
+ #### `voices.list() -> VoiceListResponse`
1383
+
1384
+ The voices an agent can speak with; pass a voice's `id` as `voice`.
1385
+
1175
1386
  ## Enterprise
1176
1387
 
1177
1388
  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.2.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.2.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -51,11 +51,13 @@ 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
57
58
  from .resources.rcs import AsyncRcsResource, RcsResource
58
59
  from .resources.tendlc import AsyncTenDlcResource, TenDlcResource
60
+ from .resources.voice import AsyncVoiceResource, VoiceResource
59
61
  from .resources.whatsapp import AsyncWhatsAppResource, WhatsAppResource
60
62
 
61
63
  # Types
@@ -194,6 +196,36 @@ from .types import (
194
196
  RcsTestDeviceInput,
195
197
  RcsTestDeviceListResponse,
196
198
  RcsTesting,
199
+ # Voice call types
200
+ Call,
201
+ CallBilling,
202
+ CallDirection,
203
+ CallHandledBy,
204
+ CallKind,
205
+ CallListResponse,
206
+ CallPagination,
207
+ CallRecording,
208
+ CallRecordingStatus,
209
+ CallStatus,
210
+ CallTranscriptLine,
211
+ CreateCallRequest,
212
+ ListCallsOptions,
213
+ # Voice configuration types
214
+ CreateVoiceAgentRequest,
215
+ DeletedVoiceAgent,
216
+ EmergencyAddress,
217
+ UpdateVoiceAgentRequest,
218
+ UpdateVoiceNumberRequest,
219
+ Voice,
220
+ VoiceAgent,
221
+ VoiceAgentListResponse,
222
+ VoiceAgentTools,
223
+ VoiceListResponse,
224
+ VoiceMode,
225
+ VoiceNumber,
226
+ VoiceNumberEmergencyAddress,
227
+ VoiceNumberListResponse,
228
+ VoiceNumberRates,
197
229
  OptInPage,
198
230
  PricingTier,
199
231
  QuotaSettings,
@@ -406,6 +438,36 @@ __all__ = [
406
438
  "RcsTestDeviceInput",
407
439
  "RcsTestDeviceListResponse",
408
440
  "RcsTesting",
441
+ # Voice call types
442
+ "Call",
443
+ "CallBilling",
444
+ "CallDirection",
445
+ "CallHandledBy",
446
+ "CallKind",
447
+ "CallListResponse",
448
+ "CallPagination",
449
+ "CallRecording",
450
+ "CallRecordingStatus",
451
+ "CallStatus",
452
+ "CallTranscriptLine",
453
+ "CreateCallRequest",
454
+ "ListCallsOptions",
455
+ # Voice configuration types
456
+ "CreateVoiceAgentRequest",
457
+ "DeletedVoiceAgent",
458
+ "EmergencyAddress",
459
+ "UpdateVoiceAgentRequest",
460
+ "UpdateVoiceNumberRequest",
461
+ "Voice",
462
+ "VoiceAgent",
463
+ "VoiceAgentListResponse",
464
+ "VoiceAgentTools",
465
+ "VoiceListResponse",
466
+ "VoiceMode",
467
+ "VoiceNumber",
468
+ "VoiceNumberEmergencyAddress",
469
+ "VoiceNumberListResponse",
470
+ "VoiceNumberRates",
409
471
  # Links resources
410
472
  "LinksResource",
411
473
  "AsyncLinksResource",
@@ -427,6 +489,12 @@ __all__ = [
427
489
  # RCS resources
428
490
  "RcsResource",
429
491
  "AsyncRcsResource",
492
+ # Voice call resources
493
+ "CallsResource",
494
+ "AsyncCallsResource",
495
+ # Voice configuration resources
496
+ "VoiceResource",
497
+ "AsyncVoiceResource",
430
498
  # Errors
431
499
  "SendlyError",
432
500
  "AuthenticationError",