sendly 4.5.0__tar.gz → 4.6.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.5.0 → sendly-4.6.0}/PKG-INFO +118 -19
  2. {sendly-4.5.0 → sendly-4.6.0}/README.md +117 -18
  3. {sendly-4.5.0 → sendly-4.6.0}/pyproject.toml +1 -1
  4. {sendly-4.5.0 → sendly-4.6.0}/sendly/__init__.py +37 -1
  5. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/calls.py +104 -6
  6. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/voice.py +244 -16
  7. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/whatsapp.py +23 -17
  8. {sendly-4.5.0 → sendly-4.6.0}/sendly/types.py +467 -4
  9. {sendly-4.5.0 → sendly-4.6.0}/sendly/utils/http.py +1 -1
  10. {sendly-4.5.0 → sendly-4.6.0}/tests/test_calls.py +305 -0
  11. {sendly-4.5.0 → sendly-4.6.0}/tests/test_voice.py +633 -1
  12. {sendly-4.5.0 → sendly-4.6.0}/tests/test_whatsapp_extras.py +4 -2
  13. {sendly-4.5.0 → sendly-4.6.0}/.github/header.svg +0 -0
  14. {sendly-4.5.0 → sendly-4.6.0}/.gitignore +0 -0
  15. {sendly-4.5.0 → sendly-4.6.0}/CHANGELOG.md +0 -0
  16. {sendly-4.5.0 → sendly-4.6.0}/examples/async_example.py +0 -0
  17. {sendly-4.5.0 → sendly-4.6.0}/examples/error_handling.py +0 -0
  18. {sendly-4.5.0 → sendly-4.6.0}/examples/send_sms.py +0 -0
  19. {sendly-4.5.0 → sendly-4.6.0}/sendly/client.py +0 -0
  20. {sendly-4.5.0 → sendly-4.6.0}/sendly/errors.py +0 -0
  21. {sendly-4.5.0 → sendly-4.6.0}/sendly/py.typed +0 -0
  22. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/__init__.py +0 -0
  23. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/account.py +0 -0
  24. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/business_upgrade.py +0 -0
  25. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/campaigns.py +0 -0
  26. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/contacts.py +0 -0
  27. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/conversations.py +0 -0
  28. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/drafts.py +0 -0
  29. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/enterprise.py +0 -0
  30. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/labels.py +0 -0
  31. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/links.py +0 -0
  32. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/media.py +0 -0
  33. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/messages.py +0 -0
  34. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/numbers.py +0 -0
  35. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/rcs.py +0 -0
  36. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/rules.py +0 -0
  37. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/templates.py +0 -0
  38. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/tendlc.py +0 -0
  39. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/verify.py +0 -0
  40. {sendly-4.5.0 → sendly-4.6.0}/sendly/resources/webhooks.py +0 -0
  41. {sendly-4.5.0 → sendly-4.6.0}/sendly/utils/__init__.py +0 -0
  42. {sendly-4.5.0 → sendly-4.6.0}/sendly/utils/validation.py +0 -0
  43. {sendly-4.5.0 → sendly-4.6.0}/sendly/webhooks.py +0 -0
  44. {sendly-4.5.0 → sendly-4.6.0}/tests/conftest.py +0 -0
  45. {sendly-4.5.0 → sendly-4.6.0}/tests/test_account.py +0 -0
  46. {sendly-4.5.0 → sendly-4.6.0}/tests/test_async_client.py +0 -0
  47. {sendly-4.5.0 → sendly-4.6.0}/tests/test_campaigns.py +0 -0
  48. {sendly-4.5.0 → sendly-4.6.0}/tests/test_client.py +0 -0
  49. {sendly-4.5.0 → sendly-4.6.0}/tests/test_contacts.py +0 -0
  50. {sendly-4.5.0 → sendly-4.6.0}/tests/test_conversations.py +0 -0
  51. {sendly-4.5.0 → sendly-4.6.0}/tests/test_enterprise.py +0 -0
  52. {sendly-4.5.0 → sendly-4.6.0}/tests/test_errors.py +0 -0
  53. {sendly-4.5.0 → sendly-4.6.0}/tests/test_http_body_kwarg.py +0 -0
  54. {sendly-4.5.0 → sendly-4.6.0}/tests/test_http_retry.py +0 -0
  55. {sendly-4.5.0 → sendly-4.6.0}/tests/test_idempotency.py +0 -0
  56. {sendly-4.5.0 → sendly-4.6.0}/tests/test_media.py +0 -0
  57. {sendly-4.5.0 → sendly-4.6.0}/tests/test_messages.py +0 -0
  58. {sendly-4.5.0 → sendly-4.6.0}/tests/test_messages.py.bak +0 -0
  59. {sendly-4.5.0 → sendly-4.6.0}/tests/test_messages_batch.py +0 -0
  60. {sendly-4.5.0 → sendly-4.6.0}/tests/test_messages_schedule.py +0 -0
  61. {sendly-4.5.0 → sendly-4.6.0}/tests/test_messages_wire.py +0 -0
  62. {sendly-4.5.0 → sendly-4.6.0}/tests/test_numbers.py +0 -0
  63. {sendly-4.5.0 → sendly-4.6.0}/tests/test_path_ids.py +0 -0
  64. {sendly-4.5.0 → sendly-4.6.0}/tests/test_rcs.py +0 -0
  65. {sendly-4.5.0 → sendly-4.6.0}/tests/test_rcs_registration.py +0 -0
  66. {sendly-4.5.0 → sendly-4.6.0}/tests/test_rules.py +0 -0
  67. {sendly-4.5.0 → sendly-4.6.0}/tests/test_templates.py +0 -0
  68. {sendly-4.5.0 → sendly-4.6.0}/tests/test_tendlc.py +0 -0
  69. {sendly-4.5.0 → sendly-4.6.0}/tests/test_validation.py +0 -0
  70. {sendly-4.5.0 → sendly-4.6.0}/tests/test_webhooks.py +0 -0
  71. {sendly-4.5.0 → sendly-4.6.0}/tests/test_webhooks_resource.py +0 -0
  72. {sendly-4.5.0 → sendly-4.6.0}/tests/test_whatsapp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 4.5.0
3
+ Version: 4.6.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 signup; they still wait out a
451
+ a verification attempt or start a new charged 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`,
@@ -552,8 +552,8 @@ contents.
552
552
 
553
553
  Write methods that accept `idempotency_key` include `messages.send`,
554
554
  `messages.schedule`, `messages.send_batch`, `messages.send_group`,
555
- `calls.create`, `calls.hangup`, every `voice.*` write, and every
556
- `rcs.brands` / `rcs.agents` write.
555
+ `calls.create`, `calls.hangup`, `calls.transfer`, every `voice.*` write but
556
+ `voice.agents.test`, and every `rcs.brands` / `rcs.agents` write.
557
557
 
558
558
  Full details: https://sendly.live/docs/idempotency
559
559
 
@@ -1355,7 +1355,7 @@ client.links.update(link.code, True)
1355
1355
 
1356
1356
  ## WhatsApp
1357
1357
 
1358
- Connect a number you own to WhatsApp (free to connect, no monthly fee), create
1358
+ Connect a number you own to WhatsApp ($9.99 one-time, no monthly fee), create
1359
1359
  Meta-reviewed message templates, and send on the `whatsapp` channel. Connecting
1360
1360
  the first number always ends with a human step: hand the `connect_url` to your
1361
1361
  user - they open it in a browser and log in with Facebook to link their
@@ -1387,8 +1387,11 @@ print(f'Open {signup.connect_url} and log in with Facebook')
1387
1387
  # After the Facebook step it stays 'registering' while WhatsApp activates the
1388
1388
  # number. Activation usually takes a few minutes but can take hours. If it
1389
1389
  # hasn't finished about 6 hours after the session began, the session fails
1390
- # with registration_timeout ('failed', with signup.failure_reasons explaining
1391
- # why).
1390
+ # with registration_timeout and the fee is refunded ('failed', with
1391
+ # signup.failure_reasons explaining why). The one-time $9.99 connection fee is
1392
+ # charged to your card when you start connecting the number and refunded
1393
+ # automatically if the connection fails; it is not refundable once the number
1394
+ # is connected.
1392
1395
 
1393
1396
  # 2. Create a template (Meta reviews it, usually 24-48h). category is required
1394
1397
  # (UTILITY, AUTHENTICATION or MARKETING) with no default, and an update can't
@@ -1472,7 +1475,7 @@ components = client.whatsapp.senders.get_conversational_components('+15555550123
1472
1475
  client.whatsapp.senders.set_calling('+15555550123', enabled=True)
1473
1476
 
1474
1477
  # Add another number to an account you already connected: no Facebook step.
1475
- # Free, like a Facebook connection. WhatsApp sends the number a 6-digit code
1478
+ # Same $9.99 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1476
1479
  senders = client.whatsapp.senders.list().senders
1477
1480
  account_id = next(
1478
1481
  s.business_account_id for s in senders if s.status == 'active' and s.business_account_id
@@ -1496,7 +1499,7 @@ Senders also carry `business_account_id` (None while `pending`),
1496
1499
  name on file), `calling_enabled`, and `outbound_calling_allowed`, which is
1497
1500
  False for +1, +20, +84 and +234 numbers. A signup that is `verifying` carries
1498
1501
  `verification_method` and `verification_attempts_remaining`; after 5 wrong
1499
- codes it fails with `verification_failed`. Calls carry
1502
+ codes it fails with `verification_failed` and the fee is refunded. Calls carry
1500
1503
  `channel` (`phone`, `whatsapp` or `browser`; see `CallChannel`).
1501
1504
 
1502
1505
  `verification_code` comes only from `signup.get()`. Until a code has been
@@ -1529,7 +1532,7 @@ Errors specific to WhatsApp:
1529
1532
  body carries `retryAfter: 3600` and the response a `Retry-After: 3600`
1530
1533
  header. The SDK retries it like any `5xx` before raising;
1531
1534
  `e.response.retry_after` says how many seconds to wait before trying again.
1532
- - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed signups in
1535
+ - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed, charged signups in
1533
1536
  24 hours. It is final and not retried; try again the next day.
1534
1537
  - `whatsapp_send_failed` on a send: HTTP 422 when WhatsApp refused the message,
1535
1538
  which is final (cached under the idempotency key and replayed for 24 hours),
@@ -1565,8 +1568,8 @@ Errors specific to WhatsApp:
1565
1568
  - Adding a number by code: `whatsapp_business_account_not_found` (HTTP 404),
1566
1569
  `display_name_required` (HTTP 400), `whatsapp_signup_in_progress` and
1567
1570
  `whatsapp_already_enabled` (HTTP 409), and
1568
- `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable). A
1569
- Facebook `signup.create()` for a number being
1571
+ `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable; the
1572
+ fee is refunded either way). A Facebook `signup.create()` for a number being
1570
1573
  added by code gets `whatsapp_verification_in_progress` (HTTP 409).
1571
1574
  `verify()`: `invalid_verification_code` (HTTP 400, not 6 digits),
1572
1575
  `whatsapp_verification_code_invalid` (HTTP 422, with
@@ -1871,9 +1874,9 @@ if event.type.startswith('short_code.'):
1871
1874
  ## Voice Calls
1872
1875
 
1873
1876
  Place phone calls that one of your AI agents handles, follow them while they
1874
- ring and after they end, end them early, and download recordings. Requires an
1875
- API key with the `calls:read` / `calls:write` scopes; placing and ending calls
1876
- needs a live key. Voice is enabled workspace by workspace: until it is enabled
1877
+ ring and after they end, transfer callers, end calls early, and download
1878
+ recordings. Requires an API key with the `calls:read` / `calls:write` scopes;
1879
+ placing, transferring and ending calls needs a live key. Voice is enabled workspace by workspace: until it is enabled
1877
1880
  for yours, every call method raises `SendlyError` with code `voice_not_enabled`
1878
1881
  (HTTP 404).
1879
1882
 
@@ -1912,6 +1915,15 @@ for c in page.data:
1912
1915
  if page.pagination.has_more:
1913
1916
  page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
1914
1917
 
1918
+ # Only calls answered by one kind of agent (sendly, custom_llm, sip, websocket)
1919
+ page = client.calls.list(agent_kind='websocket')
1920
+
1921
+ # Transfer the caller on an incoming phone call; without `to`, the agent's
1922
+ # transfer number (tools.transfer_to) is used
1923
+ incoming = client.calls.list(direction='inbound', status='active').data[0]
1924
+ incoming = client.calls.transfer(incoming.id, to='+15551234500')
1925
+ print(incoming.transfer.status) # 'pending', then 'completed' or 'failed'
1926
+
1915
1927
  # End a call early (idempotent: an ended call is returned unchanged)
1916
1928
  client.calls.hangup(call.id)
1917
1929
 
@@ -1935,10 +1947,23 @@ Refusals come back as `SendlyError` with a code that says what to do:
1935
1947
  `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
1936
1948
  `from_number_not_supported` (calls are placed from US and Canadian numbers only),
1937
1949
  `number_not_found`, `destination_not_supported`, `outbound_calls_not_enabled`,
1938
- `live_key_required`. Webhooks `call.started`, `call.completed`
1950
+ `live_key_required`, and `agent_kind_unsupported` (`sip` and `websocket`
1951
+ agents answer incoming calls only). Webhooks `call.started`, `call.completed`
1939
1952
  and `call.recording.ready` carry the same call object in snake_case, including
1940
1953
  `billing` and your `metadata`.
1941
1954
 
1955
+ Each call carries `agent_kind` (the kind of agent that answered, or None),
1956
+ `agent_fallback` (True when your own agent could not be reached, so Sendly
1957
+ said a short line and transferred the caller or took a message) and
1958
+ `transfer` (None, or `to`, `status` and `failure_reason`). A transfer answers
1959
+ at once with `transfer.status` `pending`; once the caller is handed over the
1960
+ call ends `completed` with `hangup_class` `transferred`, and a failed transfer
1961
+ leaves the caller on the call. Transfer refusals: `transfer_to_required`,
1962
+ `outbound_not_enabled` (a number other than the agent's needs calling out
1963
+ switched on), `insufficient_credits`, `call_not_active`,
1964
+ `call_not_transferable` (not an incoming phone call) and
1965
+ `transfer_in_progress`.
1966
+
1942
1967
  ### Configure voice
1943
1968
 
1944
1969
  Everything a call depends on can be set up from code: switch voice on for a
@@ -2022,9 +2047,83 @@ says the number couldn't be found for emergency registration: contact
2022
2047
  support), `voice_unavailable` (HTTP 503),
2023
2048
  `e911_not_applicable` (only US and Canadian numbers take an emergency
2024
2049
  address) and `invalid_address`; on HTTP 422 the closest valid address is in
2025
- `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
2026
- stored, but agents do not transfer calls yet: when a caller asks for a person,
2027
- the agent offers to pass a message on and takes their name and number.
2050
+ `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is the
2051
+ number it transfers a caller to when asked for a person.
2052
+
2053
+ ### Bring your own agent
2054
+
2055
+ An agent's `kind` says who writes the replies: `sendly` (the default, from the
2056
+ agent's `instructions`), `custom_llm` (Sendly listens and speaks, and your chat
2057
+ completions endpoint writes each reply), `sip` (Sendly connects the caller to
2058
+ your SIP address) or `websocket` (Sendly streams the call audio to your
2059
+ `wss://` server). Your own agents need the workspace's bring-your-own-agent
2060
+ feature, which support switches on (`byo_agents_not_enabled` otherwise).
2061
+
2062
+ ```python
2063
+ from sendly import VoiceAgentWebsocketConnectionInput
2064
+
2065
+ agent = client.voice.agents.create(
2066
+ 'Support line',
2067
+ kind='websocket',
2068
+ connection=VoiceAgentWebsocketConnectionInput(
2069
+ url='wss://agent.example.com/calls', auth_token='tok_123'
2070
+ ),
2071
+ )
2072
+ secret = agent.signing_secret # save it now; it is never shown again
2073
+ print(agent.connection.url, agent.connection.has_auth_token, agent.has_signing_secret)
2074
+
2075
+ # Same kind: the connection is merged, so only what you pass changes
2076
+ client.voice.agents.update(agent.id, connection={'custom_parameters': {'tenant': 'acme'}})
2077
+
2078
+ # A new signing secret; the old one stops working at once
2079
+ secret = client.voice.agents.update(agent.id, rotate_signing_secret=True).signing_secret
2080
+ ```
2081
+
2082
+ `connection` is a `VoiceAgentCustomLlmConnectionInput` (`base_url`, `model`,
2083
+ `api_key`, `headers`), `VoiceAgentSipConnectionInput` (`uri`, `transport`,
2084
+ `media_encryption`, `username`, `password`, `headers`),
2085
+ `VoiceAgentWebsocketConnectionInput` (`url`, `audio_format`, `auth_token`,
2086
+ `custom_parameters`) or a dict. A secret you leave out keeps its saved value
2087
+ and None removes it. Secrets are never returned: `agent.connection` reports
2088
+ `has_api_key`, `has_password` or `has_auth_token` instead. Sendly signs every
2089
+ request to your agent with the signing secret
2090
+ (`X-Sendly-Signature: t=<unix>,v1=<hex HMAC-SHA256>`); `signing_secret` is set
2091
+ only on the create or update response that made it. A `connection` that breaks
2092
+ a rule raises `ValidationError` with a message like
2093
+ `connection.url: <reason>`.
2094
+
2095
+ #### Test an agent's connection
2096
+
2097
+ `voice.agents.test` reaches your own agent the way a call would, without
2098
+ placing one: `custom_llm` gets a signed, streamed chat completion with a test
2099
+ message, `websocket` gets a signed connection with a `start` carrying
2100
+ `test: true`, half a second of silence and a `stop`, and `sip` gets an
2101
+ `OPTIONS` request. Pass `kind`, `connection` or `instructions` to test settings
2102
+ before saving them; nothing is saved.
2103
+
2104
+ ```python
2105
+ result = client.voice.agents.test(agent.id)
2106
+ if not result.ok:
2107
+ print(result.outcome, result.message) # e.g. 'auth_failed'
2108
+ for step in result.steps:
2109
+ print(step.step, step.ok, step.detail)
2110
+
2111
+ # Try a new SIP address before saving it
2112
+ client.voice.agents.test(
2113
+ agent.id,
2114
+ kind='sip',
2115
+ connection={'uri': 'sip:+15125550100@sip.example.com', 'transport': 'tls'},
2116
+ )
2117
+ ```
2118
+
2119
+ A failed test is still a successful request, so check `ok`. `outcome` is
2120
+ `ok`, `blocked_destination`, `unreachable`, `timeout`, `tls_failed`,
2121
+ `auth_required`, `auth_failed`, `not_found`, `rejected` or `bad_reply` (see
2122
+ `VoiceAgentTestOutcome`; a value added later comes through as a plain string),
2123
+ and each step is `address`, `connect`, `login` or `reply`. The result also
2124
+ carries `status` (the HTTP or SIP status, or None), `latency_ms` and `signed`.
2125
+ It needs the `calls:write` scope and a live key, takes no `idempotency_key`,
2126
+ and a workspace can run 10 tests a minute (`RateLimitError` after that).
2028
2127
 
2029
2128
  ## Error Handling
2030
2129
 
@@ -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 signup; they still wait out a
416
+ a verification attempt or start a new charged 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`,
@@ -517,8 +517,8 @@ contents.
517
517
 
518
518
  Write methods that accept `idempotency_key` include `messages.send`,
519
519
  `messages.schedule`, `messages.send_batch`, `messages.send_group`,
520
- `calls.create`, `calls.hangup`, every `voice.*` write, and every
521
- `rcs.brands` / `rcs.agents` write.
520
+ `calls.create`, `calls.hangup`, `calls.transfer`, every `voice.*` write but
521
+ `voice.agents.test`, and every `rcs.brands` / `rcs.agents` write.
522
522
 
523
523
  Full details: https://sendly.live/docs/idempotency
524
524
 
@@ -1320,7 +1320,7 @@ client.links.update(link.code, True)
1320
1320
 
1321
1321
  ## WhatsApp
1322
1322
 
1323
- Connect a number you own to WhatsApp (free to connect, no monthly fee), create
1323
+ Connect a number you own to WhatsApp ($9.99 one-time, no monthly fee), create
1324
1324
  Meta-reviewed message templates, and send on the `whatsapp` channel. Connecting
1325
1325
  the first number always ends with a human step: hand the `connect_url` to your
1326
1326
  user - they open it in a browser and log in with Facebook to link their
@@ -1352,8 +1352,11 @@ print(f'Open {signup.connect_url} and log in with Facebook')
1352
1352
  # After the Facebook step it stays 'registering' while WhatsApp activates the
1353
1353
  # number. Activation usually takes a few minutes but can take hours. If it
1354
1354
  # hasn't finished about 6 hours after the session began, the session fails
1355
- # with registration_timeout ('failed', with signup.failure_reasons explaining
1356
- # why).
1355
+ # with registration_timeout and the fee is refunded ('failed', with
1356
+ # signup.failure_reasons explaining why). The one-time $9.99 connection fee is
1357
+ # charged to your card when you start connecting the number and refunded
1358
+ # automatically if the connection fails; it is not refundable once the number
1359
+ # is connected.
1357
1360
 
1358
1361
  # 2. Create a template (Meta reviews it, usually 24-48h). category is required
1359
1362
  # (UTILITY, AUTHENTICATION or MARKETING) with no default, and an update can't
@@ -1437,7 +1440,7 @@ components = client.whatsapp.senders.get_conversational_components('+15555550123
1437
1440
  client.whatsapp.senders.set_calling('+15555550123', enabled=True)
1438
1441
 
1439
1442
  # Add another number to an account you already connected: no Facebook step.
1440
- # Free, like a Facebook connection. WhatsApp sends the number a 6-digit code
1443
+ # Same $9.99 fee, refunded if it fails. WhatsApp sends the number a 6-digit code
1441
1444
  senders = client.whatsapp.senders.list().senders
1442
1445
  account_id = next(
1443
1446
  s.business_account_id for s in senders if s.status == 'active' and s.business_account_id
@@ -1461,7 +1464,7 @@ Senders also carry `business_account_id` (None while `pending`),
1461
1464
  name on file), `calling_enabled`, and `outbound_calling_allowed`, which is
1462
1465
  False for +1, +20, +84 and +234 numbers. A signup that is `verifying` carries
1463
1466
  `verification_method` and `verification_attempts_remaining`; after 5 wrong
1464
- codes it fails with `verification_failed`. Calls carry
1467
+ codes it fails with `verification_failed` and the fee is refunded. Calls carry
1465
1468
  `channel` (`phone`, `whatsapp` or `browser`; see `CallChannel`).
1466
1469
 
1467
1470
  `verification_code` comes only from `signup.get()`. Until a code has been
@@ -1494,7 +1497,7 @@ Errors specific to WhatsApp:
1494
1497
  body carries `retryAfter: 3600` and the response a `Retry-After: 3600`
1495
1498
  header. The SDK retries it like any `5xx` before raising;
1496
1499
  `e.response.retry_after` says how many seconds to wait before trying again.
1497
- - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed signups in
1500
+ - `whatsapp_signup_limit_reached` (HTTP 429) after 5 failed, charged signups in
1498
1501
  24 hours. It is final and not retried; try again the next day.
1499
1502
  - `whatsapp_send_failed` on a send: HTTP 422 when WhatsApp refused the message,
1500
1503
  which is final (cached under the idempotency key and replayed for 24 hours),
@@ -1530,8 +1533,8 @@ Errors specific to WhatsApp:
1530
1533
  - Adding a number by code: `whatsapp_business_account_not_found` (HTTP 404),
1531
1534
  `display_name_required` (HTTP 400), `whatsapp_signup_in_progress` and
1532
1535
  `whatsapp_already_enabled` (HTTP 409), and
1533
- `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable). A
1534
- Facebook `signup.create()` for a number being
1536
+ `whatsapp_verification_start_failed` (HTTP 422 refused, 503 unreachable; the
1537
+ fee is refunded either way). A Facebook `signup.create()` for a number being
1535
1538
  added by code gets `whatsapp_verification_in_progress` (HTTP 409).
1536
1539
  `verify()`: `invalid_verification_code` (HTTP 400, not 6 digits),
1537
1540
  `whatsapp_verification_code_invalid` (HTTP 422, with
@@ -1836,9 +1839,9 @@ if event.type.startswith('short_code.'):
1836
1839
  ## Voice Calls
1837
1840
 
1838
1841
  Place phone calls that one of your AI agents handles, follow them while they
1839
- ring and after they end, end them early, and download recordings. Requires an
1840
- API key with the `calls:read` / `calls:write` scopes; placing and ending calls
1841
- needs a live key. Voice is enabled workspace by workspace: until it is enabled
1842
+ ring and after they end, transfer callers, end calls early, and download
1843
+ recordings. Requires an API key with the `calls:read` / `calls:write` scopes;
1844
+ placing, transferring and ending calls needs a live key. Voice is enabled workspace by workspace: until it is enabled
1842
1845
  for yours, every call method raises `SendlyError` with code `voice_not_enabled`
1843
1846
  (HTTP 404).
1844
1847
 
@@ -1877,6 +1880,15 @@ for c in page.data:
1877
1880
  if page.pagination.has_more:
1878
1881
  page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
1879
1882
 
1883
+ # Only calls answered by one kind of agent (sendly, custom_llm, sip, websocket)
1884
+ page = client.calls.list(agent_kind='websocket')
1885
+
1886
+ # Transfer the caller on an incoming phone call; without `to`, the agent's
1887
+ # transfer number (tools.transfer_to) is used
1888
+ incoming = client.calls.list(direction='inbound', status='active').data[0]
1889
+ incoming = client.calls.transfer(incoming.id, to='+15551234500')
1890
+ print(incoming.transfer.status) # 'pending', then 'completed' or 'failed'
1891
+
1880
1892
  # End a call early (idempotent: an ended call is returned unchanged)
1881
1893
  client.calls.hangup(call.id)
1882
1894
 
@@ -1900,10 +1912,23 @@ Refusals come back as `SendlyError` with a code that says what to do:
1900
1912
  `agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
1901
1913
  `from_number_not_supported` (calls are placed from US and Canadian numbers only),
1902
1914
  `number_not_found`, `destination_not_supported`, `outbound_calls_not_enabled`,
1903
- `live_key_required`. Webhooks `call.started`, `call.completed`
1915
+ `live_key_required`, and `agent_kind_unsupported` (`sip` and `websocket`
1916
+ agents answer incoming calls only). Webhooks `call.started`, `call.completed`
1904
1917
  and `call.recording.ready` carry the same call object in snake_case, including
1905
1918
  `billing` and your `metadata`.
1906
1919
 
1920
+ Each call carries `agent_kind` (the kind of agent that answered, or None),
1921
+ `agent_fallback` (True when your own agent could not be reached, so Sendly
1922
+ said a short line and transferred the caller or took a message) and
1923
+ `transfer` (None, or `to`, `status` and `failure_reason`). A transfer answers
1924
+ at once with `transfer.status` `pending`; once the caller is handed over the
1925
+ call ends `completed` with `hangup_class` `transferred`, and a failed transfer
1926
+ leaves the caller on the call. Transfer refusals: `transfer_to_required`,
1927
+ `outbound_not_enabled` (a number other than the agent's needs calling out
1928
+ switched on), `insufficient_credits`, `call_not_active`,
1929
+ `call_not_transferable` (not an incoming phone call) and
1930
+ `transfer_in_progress`.
1931
+
1907
1932
  ### Configure voice
1908
1933
 
1909
1934
  Everything a call depends on can be set up from code: switch voice on for a
@@ -1987,9 +2012,83 @@ says the number couldn't be found for emergency registration: contact
1987
2012
  support), `voice_unavailable` (HTTP 503),
1988
2013
  `e911_not_applicable` (only US and Canadian numbers take an emergency
1989
2014
  address) and `invalid_address`; on HTTP 422 the closest valid address is in
1990
- `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
1991
- stored, but agents do not transfer calls yet: when a caller asks for a person,
1992
- the agent offers to pass a message on and takes their name and number.
2015
+ `e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is the
2016
+ number it transfers a caller to when asked for a person.
2017
+
2018
+ ### Bring your own agent
2019
+
2020
+ An agent's `kind` says who writes the replies: `sendly` (the default, from the
2021
+ agent's `instructions`), `custom_llm` (Sendly listens and speaks, and your chat
2022
+ completions endpoint writes each reply), `sip` (Sendly connects the caller to
2023
+ your SIP address) or `websocket` (Sendly streams the call audio to your
2024
+ `wss://` server). Your own agents need the workspace's bring-your-own-agent
2025
+ feature, which support switches on (`byo_agents_not_enabled` otherwise).
2026
+
2027
+ ```python
2028
+ from sendly import VoiceAgentWebsocketConnectionInput
2029
+
2030
+ agent = client.voice.agents.create(
2031
+ 'Support line',
2032
+ kind='websocket',
2033
+ connection=VoiceAgentWebsocketConnectionInput(
2034
+ url='wss://agent.example.com/calls', auth_token='tok_123'
2035
+ ),
2036
+ )
2037
+ secret = agent.signing_secret # save it now; it is never shown again
2038
+ print(agent.connection.url, agent.connection.has_auth_token, agent.has_signing_secret)
2039
+
2040
+ # Same kind: the connection is merged, so only what you pass changes
2041
+ client.voice.agents.update(agent.id, connection={'custom_parameters': {'tenant': 'acme'}})
2042
+
2043
+ # A new signing secret; the old one stops working at once
2044
+ secret = client.voice.agents.update(agent.id, rotate_signing_secret=True).signing_secret
2045
+ ```
2046
+
2047
+ `connection` is a `VoiceAgentCustomLlmConnectionInput` (`base_url`, `model`,
2048
+ `api_key`, `headers`), `VoiceAgentSipConnectionInput` (`uri`, `transport`,
2049
+ `media_encryption`, `username`, `password`, `headers`),
2050
+ `VoiceAgentWebsocketConnectionInput` (`url`, `audio_format`, `auth_token`,
2051
+ `custom_parameters`) or a dict. A secret you leave out keeps its saved value
2052
+ and None removes it. Secrets are never returned: `agent.connection` reports
2053
+ `has_api_key`, `has_password` or `has_auth_token` instead. Sendly signs every
2054
+ request to your agent with the signing secret
2055
+ (`X-Sendly-Signature: t=<unix>,v1=<hex HMAC-SHA256>`); `signing_secret` is set
2056
+ only on the create or update response that made it. A `connection` that breaks
2057
+ a rule raises `ValidationError` with a message like
2058
+ `connection.url: <reason>`.
2059
+
2060
+ #### Test an agent's connection
2061
+
2062
+ `voice.agents.test` reaches your own agent the way a call would, without
2063
+ placing one: `custom_llm` gets a signed, streamed chat completion with a test
2064
+ message, `websocket` gets a signed connection with a `start` carrying
2065
+ `test: true`, half a second of silence and a `stop`, and `sip` gets an
2066
+ `OPTIONS` request. Pass `kind`, `connection` or `instructions` to test settings
2067
+ before saving them; nothing is saved.
2068
+
2069
+ ```python
2070
+ result = client.voice.agents.test(agent.id)
2071
+ if not result.ok:
2072
+ print(result.outcome, result.message) # e.g. 'auth_failed'
2073
+ for step in result.steps:
2074
+ print(step.step, step.ok, step.detail)
2075
+
2076
+ # Try a new SIP address before saving it
2077
+ client.voice.agents.test(
2078
+ agent.id,
2079
+ kind='sip',
2080
+ connection={'uri': 'sip:+15125550100@sip.example.com', 'transport': 'tls'},
2081
+ )
2082
+ ```
2083
+
2084
+ A failed test is still a successful request, so check `ok`. `outcome` is
2085
+ `ok`, `blocked_destination`, `unreachable`, `timeout`, `tls_failed`,
2086
+ `auth_required`, `auth_failed`, `not_found`, `rejected` or `bad_reply` (see
2087
+ `VoiceAgentTestOutcome`; a value added later comes through as a plain string),
2088
+ and each step is `address`, `connect`, `login` or `reply`. The result also
2089
+ carries `status` (the HTTP or SIP status, or None), `latency_ms` and `signed`.
2090
+ It needs the `calls:write` scope and a live key, takes no `idempotency_key`,
2091
+ and a workspace can run 10 tests a minute (`RateLimitError` after that).
1993
2092
 
1994
2093
  ## Error Handling
1995
2094
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "4.5.0"
7
+ version = "4.6.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.5.0"
32
+ __version__ = "4.6.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -216,24 +216,42 @@ from .types import (
216
216
  CallRecordingStatus,
217
217
  CallStatus,
218
218
  CallTranscriptLine,
219
+ CallTransfer,
220
+ CallTransferFailureReason,
221
+ CallTransferStatus,
219
222
  CreateCallRequest,
220
223
  ListCallsOptions,
224
+ TransferCallRequest,
221
225
  # Voice configuration types
222
226
  CreateVoiceAgentRequest,
223
227
  DeletedVoiceAgent,
224
228
  EmergencyAddress,
229
+ SipMediaEncryption,
230
+ SipTransport,
231
+ TestVoiceAgentRequest,
225
232
  UpdateVoiceAgentRequest,
226
233
  UpdateVoiceNumberRequest,
227
234
  Voice,
228
235
  VoiceAgent,
236
+ VoiceAgentConnection,
237
+ VoiceAgentConnectionInput,
238
+ VoiceAgentCustomLlmConnectionInput,
239
+ VoiceAgentKind,
229
240
  VoiceAgentListResponse,
241
+ VoiceAgentSipConnectionInput,
242
+ VoiceAgentTest,
243
+ VoiceAgentTestOutcome,
244
+ VoiceAgentTestStep,
245
+ VoiceAgentTestStepName,
230
246
  VoiceAgentTools,
247
+ VoiceAgentWebsocketConnectionInput,
231
248
  VoiceListResponse,
232
249
  VoiceMode,
233
250
  VoiceNumber,
234
251
  VoiceNumberEmergencyAddress,
235
252
  VoiceNumberListResponse,
236
253
  VoiceNumberRates,
254
+ WebsocketAudioFormat,
237
255
  OptInPage,
238
256
  PricingTier,
239
257
  QuotaSettings,
@@ -466,24 +484,42 @@ __all__ = [
466
484
  "CallRecordingStatus",
467
485
  "CallStatus",
468
486
  "CallTranscriptLine",
487
+ "CallTransfer",
488
+ "CallTransferFailureReason",
489
+ "CallTransferStatus",
469
490
  "CreateCallRequest",
470
491
  "ListCallsOptions",
492
+ "TransferCallRequest",
471
493
  # Voice configuration types
472
494
  "CreateVoiceAgentRequest",
473
495
  "DeletedVoiceAgent",
474
496
  "EmergencyAddress",
497
+ "SipMediaEncryption",
498
+ "SipTransport",
499
+ "TestVoiceAgentRequest",
475
500
  "UpdateVoiceAgentRequest",
476
501
  "UpdateVoiceNumberRequest",
477
502
  "Voice",
478
503
  "VoiceAgent",
504
+ "VoiceAgentConnection",
505
+ "VoiceAgentConnectionInput",
506
+ "VoiceAgentCustomLlmConnectionInput",
507
+ "VoiceAgentKind",
479
508
  "VoiceAgentListResponse",
509
+ "VoiceAgentSipConnectionInput",
510
+ "VoiceAgentTest",
511
+ "VoiceAgentTestOutcome",
512
+ "VoiceAgentTestStep",
513
+ "VoiceAgentTestStepName",
480
514
  "VoiceAgentTools",
515
+ "VoiceAgentWebsocketConnectionInput",
481
516
  "VoiceListResponse",
482
517
  "VoiceMode",
483
518
  "VoiceNumber",
484
519
  "VoiceNumberEmergencyAddress",
485
520
  "VoiceNumberListResponse",
486
521
  "VoiceNumberRates",
522
+ "WebsocketAudioFormat",
487
523
  # Links resources
488
524
  "LinksResource",
489
525
  "AsyncLinksResource",