sendly 3.40.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-3.40.0 → sendly-4.1.0}/CHANGELOG.md +165 -0
  2. {sendly-3.40.0 → sendly-4.1.0}/PKG-INFO +126 -17
  3. {sendly-3.40.0 → sendly-4.1.0}/README.md +125 -16
  4. {sendly-3.40.0 → sendly-4.1.0}/pyproject.toml +1 -1
  5. {sendly-3.40.0 → sendly-4.1.0}/sendly/__init__.py +33 -1
  6. {sendly-3.40.0 → sendly-4.1.0}/sendly/client.py +3 -0
  7. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/__init__.py +3 -0
  8. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/account.py +9 -8
  9. sendly-4.1.0/sendly/resources/calls.py +360 -0
  10. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/campaigns.py +17 -16
  11. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/contacts.py +19 -18
  12. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/rcs.py +13 -12
  13. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/templates.py +13 -12
  14. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/tendlc.py +9 -8
  15. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/verify.py +7 -6
  16. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/webhooks.py +21 -20
  17. {sendly-3.40.0 → sendly-4.1.0}/sendly/types.py +228 -0
  18. {sendly-3.40.0 → sendly-4.1.0}/sendly/utils/http.py +1 -1
  19. sendly-4.1.0/tests/test_calls.py +1047 -0
  20. {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages.py +23 -1
  21. {sendly-3.40.0 → sendly-4.1.0}/.github/header.svg +0 -0
  22. {sendly-3.40.0 → sendly-4.1.0}/.gitignore +0 -0
  23. {sendly-3.40.0 → sendly-4.1.0}/examples/async_example.py +0 -0
  24. {sendly-3.40.0 → sendly-4.1.0}/examples/error_handling.py +0 -0
  25. {sendly-3.40.0 → sendly-4.1.0}/examples/send_sms.py +0 -0
  26. {sendly-3.40.0 → sendly-4.1.0}/sendly/errors.py +0 -0
  27. {sendly-3.40.0 → sendly-4.1.0}/sendly/py.typed +0 -0
  28. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/business_upgrade.py +0 -0
  29. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/conversations.py +0 -0
  30. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/drafts.py +0 -0
  31. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/enterprise.py +0 -0
  32. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/labels.py +0 -0
  33. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/links.py +0 -0
  34. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/media.py +0 -0
  35. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/messages.py +0 -0
  36. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/numbers.py +0 -0
  37. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/rules.py +0 -0
  38. {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/whatsapp.py +0 -0
  39. {sendly-3.40.0 → sendly-4.1.0}/sendly/utils/__init__.py +0 -0
  40. {sendly-3.40.0 → sendly-4.1.0}/sendly/utils/validation.py +0 -0
  41. {sendly-3.40.0 → sendly-4.1.0}/sendly/webhooks.py +0 -0
  42. {sendly-3.40.0 → sendly-4.1.0}/tests/conftest.py +0 -0
  43. {sendly-3.40.0 → sendly-4.1.0}/tests/test_account.py +0 -0
  44. {sendly-3.40.0 → sendly-4.1.0}/tests/test_async_client.py +0 -0
  45. {sendly-3.40.0 → sendly-4.1.0}/tests/test_client.py +0 -0
  46. {sendly-3.40.0 → sendly-4.1.0}/tests/test_errors.py +0 -0
  47. {sendly-3.40.0 → sendly-4.1.0}/tests/test_http_body_kwarg.py +0 -0
  48. {sendly-3.40.0 → sendly-4.1.0}/tests/test_idempotency.py +0 -0
  49. {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages.py.bak +0 -0
  50. {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages_batch.py +0 -0
  51. {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages_schedule.py +0 -0
  52. {sendly-3.40.0 → sendly-4.1.0}/tests/test_numbers.py +0 -0
  53. {sendly-3.40.0 → sendly-4.1.0}/tests/test_rcs.py +0 -0
  54. {sendly-3.40.0 → sendly-4.1.0}/tests/test_rcs_registration.py +0 -0
  55. {sendly-3.40.0 → sendly-4.1.0}/tests/test_tendlc.py +0 -0
  56. {sendly-3.40.0 → sendly-4.1.0}/tests/test_validation.py +0 -0
  57. {sendly-3.40.0 → sendly-4.1.0}/tests/test_webhooks.py +0 -0
  58. {sendly-3.40.0 → sendly-4.1.0}/tests/test_whatsapp.py +0 -0
@@ -1,5 +1,170 @@
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
+
14
+ ## 4.0.0
15
+
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**.
17
+
18
+ Webhook events are no longer decoded as messages. Every SDK used to force a webhook's
19
+ `data.object` into a message-shaped type. That is correct for `message.*` and wrong for
20
+ every lifecycle event — `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`,
21
+ `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*`, `draft.*`,
22
+ `verification.*` — which carry a different object entirely. This release exposes the payload
23
+ as it arrived and makes the wrong read fail instead of returning a plausible-looking zero.
24
+
25
+ ### Breaking Changes
26
+
27
+ - **`event.data` is now `None` for every event that is not a `message.*` event.** It used to
28
+ be a fully-populated `WebhookMessageData` whatever arrived, assembled by reading message
29
+ field names off a payload that never carried them. Parsing an `rcs_agent.live` event
30
+ returned `id=''`, `status=''`, `to=''`, `from_=''`, `segments=1`, `credits_used=0`,
31
+ `direction='outbound'` — every one of those invented by the SDK — and **raised no error**.
32
+ A handler that logged `event.data.credits_used` recorded a `0` that meant nothing, and a
33
+ handler that branched on `event.data.status` took the empty-string branch forever.
34
+
35
+ Reading a message field off a lifecycle event now raises
36
+ `AttributeError: 'NoneType' object has no attribute 'id'`. **That is the point of this
37
+ release, not an accident.** The read was always wrong; it is now loud instead of silent,
38
+ so you find every affected call site the first time such an event arrives rather than
39
+ trusting a zero indefinitely.
40
+
41
+ Your code today, on any non-`message.*` event:
42
+
43
+ ```python
44
+ event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
45
+
46
+ if event.type == 'rcs_agent.live':
47
+ agent_id = event.data.id # was always '' - the payload has no `id` at all
48
+ stage = event.data.status # was always ''
49
+ ```
50
+
51
+ After upgrading, that raises. Read the payload off `event.object`, which carries
52
+ `data.object` verbatim:
53
+
54
+ ```python
55
+ event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
56
+
57
+ if event.type == 'rcs_agent.live':
58
+ agent_id = event.object['agent_id']
59
+ stage = event.object['stage']
60
+ ```
61
+
62
+ The edit is mechanical: on a lifecycle event, every `event.data.<field>` becomes
63
+ `event.object['<key>']`, with the key spelled the way the API sends it rather than the way
64
+ `WebhookMessageData` spelled it. There was no `event.object` before this release, so no
65
+ correct version of this code existed to migrate from — check what your handler assumed
66
+ rather than translating it field for field.
67
+
68
+ If a handler touches `event.data` in several branches, guard the message-only path once.
69
+ `if event.data is not None:` both narrows the type for a checker and marks the branch:
70
+
71
+ ```python
72
+ if event.data is not None:
73
+ record_delivery(event.data.id, event.data.status)
74
+ else:
75
+ handle_lifecycle(event.type, event.object)
76
+ ```
77
+
78
+ `message.*` handlers otherwise need no change: `event.data` is still the message view
79
+ there.
80
+
81
+ - **`contact.auto_flagged` reported the wrong id, and code keyed on it acted on the wrong
82
+ record.** The payload is a contact — `{id, phone_number, invalid_reason, source,
83
+ message_id, error_code}` — so the old decode put the *contact* id in `event.data.id`, and
84
+ `event.data.message_id` (an alias for `id`) returned that same contact id. The message
85
+ that actually failed was unreachable. If you wrote `mark_bounced(event.data.id)`, it has
86
+ been handing a contact id to something that expects a message id:
87
+
88
+ ```python
89
+ # before - `event.data.id` is the CONTACT id, `event.data.message_id` is the same value
90
+ if event.type == 'contact.auto_flagged':
91
+ mark_bounced(event.data.id)
92
+
93
+ # after - the two ids are distinct and never swapped
94
+ if event.type == 'contact.auto_flagged':
95
+ flag_contact(event.object['id'])
96
+ mark_bounced(event.object['message_id'])
97
+ ```
98
+
99
+ Audit anything this handler wrote before you upgrade; the bad writes are already on disk.
100
+
101
+ - **`message.opt_in` and `message.opt_out` also return `event.data is None`.** They share the
102
+ `message.` prefix but carry an opt-out record (`phone_number`, `keyword`, `from_number`,
103
+ `timestamp`), not a message, so the old decode produced an all-default message here too.
104
+ Read them from `event.object`. Use `is_message_event(event.type)` rather than testing the
105
+ prefix yourself — it knows about these two.
106
+
107
+ - **`WebhookEventType` is an enum, not a `Literal` union.** `sendly.webhooks` used to define
108
+ its own `Literal[...]` of event-type strings, drifting from the `WebhookEventType` enum in
109
+ `sendly.types`. There is now one definition: `sendly.webhooks.WebhookEventType` re-exports
110
+ the enum, and `sendly.webhooks.WEBHOOK_EVENT_TYPES` is the tuple of its values. Runtime
111
+ comparisons are unaffected (it is a `str` enum, so `event.type == 'message.delivered'` and
112
+ `event.type == WebhookEventType.MESSAGE_DELIVERED` are both `True`), but an annotation like
113
+ `event_type: WebhookEventType = 'message.delivered'` no longer type-checks. Use
114
+ `WebhookEventType.MESSAGE_DELIVERED`, or annotate as `str`.
115
+
116
+ - **`message.queued` and `message.undelivered` are gone.** The API has never emitted either
117
+ one and rejects both with a `400` on subscribe, so any `events` list containing them was
118
+ subscribing to nothing and any handler branch on them was dead. They are no longer in
119
+ `WebhookEventType` or `WEBHOOK_EVENT_TYPES`; `WebhookEventType.MESSAGE_QUEUED` now raises
120
+ `AttributeError`. Delete them from your `webhooks.create()` / `webhooks.update()` calls.
121
+ `'queued'` and `'undelivered'` are still valid message *statuses* on `event.data.status` —
122
+ only the event types were removed.
123
+
124
+ - **`WebhookMessageData` fields no longer carry invented defaults.** Every field is now
125
+ `Optional` and defaults to `None`. On a `message.*` payload that omitted a key you used to
126
+ get `''` for `id` / `status` / `to` / `from_`, `1` for `segments`, `0` for `credits_used`
127
+ and `'outbound'` for `direction`; you now get `None`, so an absent value is
128
+ distinguishable from a real one. Code that did `if not event.data.to:` still works; code
129
+ that did `event.data.to.startswith('+')` or `event.data.segments + 1` unguarded will now
130
+ raise on a sparse payload. `WebhookVerificationData` lost its invented defaults the same
131
+ way (`delivery_status='queued'`, `attempts=0`, `max_attempts=3` are all `None` now).
132
+
133
+ ### Minor Changes
134
+
135
+ - **`event.object`** — `data.object` exactly as it arrived, on every event type. Keys are
136
+ verbatim (`camelCase` stays `camelCase`), JSON `null` stays `None`, and nothing the payload
137
+ did not carry is added. `event.raw_object` is an alias for it.
138
+ - **`event.object_as(cls)`** — decode the payload into a shape of your own: a dataclass, a
139
+ pydantic v2 model, or `dict`. `event.object_as()` with no argument returns a plain dict
140
+ copy. Only keys the payload actually carried are passed through, so a field it did not send
141
+ keeps its default rather than being invented, and a trailing underscore maps a reserved
142
+ word (`from_` reads `from`).
143
+ - **`is_message_event(event_type)`** — whether an event type carries a message-shaped
144
+ `data.object`. Takes a `WebhookEventType` or a raw string, including one this SDK version
145
+ has never heard of.
146
+ - **`WebhookVerificationData`** — a ready-made shape for `verification.*` payloads.
147
+ `parse_event()` does not produce it for you; pass it to
148
+ `event.object_as(WebhookVerificationData)` when you want it.
149
+ - **An event type this SDK version does not know is delivered, not rejected.** `event.type`
150
+ keeps the raw string and `event.object` still carries the payload, so a new event released
151
+ after this version reaches your handler.
152
+ - `WebhookEventType` gains the types that were missing everywhere: `conversation.*`,
153
+ `draft.*`, `rcs_brand.*`, `rcs_agent.*`, `whatsapp_account.*`, `whatsapp_template.*` and
154
+ `call.*`. There was previously no typed way to subscribe to RCS, WhatsApp or voice events.
155
+
156
+ ### Patch Changes
157
+
158
+ - A `data.object` that is not a JSON object now fails with a message that says so —
159
+ `Failed to parse webhook payload: Invalid event structure: data.object must be an object,
160
+ got str` — instead of surfacing an internal `'str' object has no attribute 'get'`. It
161
+ raised before too; only the message changed.
162
+
163
+
164
+ ### Security
165
+
166
+ - **Path parameters are percent-encoded.** Every id you pass is now encoded (`urllib.parse.quote(..., safe="")`) before it goes into the request path. An id containing `/`, `?` or `#` used to change which endpoint the request reached: an id of `../../account/keys` left its collection and hit another endpoint carrying your API key. Ordinary ids are sent byte-for-byte as before.
167
+
3
168
  ## 3.38.0
4
169
 
5
170
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 3.40.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
@@ -379,7 +379,7 @@ client.webhooks.delete('whk_xxx')
379
379
  ### Verifying Webhook Signatures
380
380
 
381
381
  ```python
382
- from sendly import Webhooks
382
+ from sendly import Webhooks, WebhookSignatureError
383
383
 
384
384
  WEBHOOK_SECRET = 'your_webhook_secret'
385
385
 
@@ -392,16 +392,19 @@ def handle_webhook():
392
392
 
393
393
  try:
394
394
  event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
395
-
396
- if event.type == 'message.delivered':
397
- print(f'Message {event.data.id} delivered')
398
- elif event.type == 'message.failed':
399
- print(f'Message {event.data.id} failed: {event.data.error_code}')
400
-
401
- return 'OK', 200
402
- except Exception as e:
395
+ except WebhookSignatureError as e:
403
396
  print(f'Invalid signature: {e}')
404
397
  return 'Invalid signature', 400
398
+
399
+ if event.type == 'message.delivered':
400
+ print(f'Message {event.data.id} delivered')
401
+ elif event.type == 'message.failed':
402
+ print(f'Message {event.data.id} failed: {event.data.error_code}')
403
+ elif event.type == 'rcs_agent.live':
404
+ # A lifecycle event: event.data is None, the payload is on event.object
405
+ print(f"Agent {event.object['agent_id']} is live")
406
+
407
+ return 'OK', 200
405
408
  ```
406
409
 
407
410
  ### Reading the Event Payload
@@ -414,11 +417,15 @@ nothing the payload did not carry is added.
414
417
  `message.*` event, because RCS, WhatsApp, voice (`call.*`), 10DLC
415
418
  (`brand.*` / `campaign.*` / `assignment.*`), `number.*`, `port*`, `contact*`,
416
419
  `conversation.*`, `draft.*` and `verification.*` payloads are not
417
- message-shaped.
420
+ message-shaped. `message.opt_in` and `message.opt_out` are `None` too — they
421
+ share the prefix but carry an opt-out record (`phone_number`, `keyword`,
422
+ `from_number`, `timestamp`), not a message.
418
423
 
419
424
  ```python
420
425
  from dataclasses import dataclass
421
- from sendly import Webhooks
426
+ from typing import Optional
427
+
428
+ from sendly import Webhooks, is_message_event
422
429
 
423
430
  event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
424
431
 
@@ -429,6 +436,12 @@ else:
429
436
  # lifecycle event: read the raw object
430
437
  print(event.object)
431
438
 
439
+ # is_message_event() answers the same question from an event type alone, before
440
+ # you have an event in hand - filtering a subscription, routing a queue
441
+ is_message_event('message.delivered') # True
442
+ is_message_event('rcs_agent.live') # False
443
+ is_message_event('message.opt_out') # False - an opt-out record, not a message
444
+
432
445
  # A contact flagged by a send failure: `id` is the contact, `message_id` is
433
446
  # the message that failed. They are never swapped.
434
447
  if event.type == 'contact.auto_flagged':
@@ -441,22 +454,35 @@ if event.type == 'call.started':
441
454
  # for PSTN legs, so treat them as optional rather than assuming either.
442
455
  caller = event.object.get('from')
443
456
 
444
- # Or decode into a shape of your own (dataclass, pydantic model, or dict)
457
+ # Or decode into a shape of your own (dataclass, pydantic model, or dict).
458
+ # Only keys the payload actually carried are passed through, so a field it did
459
+ # not send keeps its default instead of being invented. A trailing underscore
460
+ # maps a reserved word: `from_` reads `from`.
445
461
  @dataclass
446
462
  class AgentLive:
447
- agent_id: str = None
448
- name: str = None
449
- stage: str = None
463
+ agent_id: Optional[str] = None
464
+ name: Optional[str] = None
465
+ stage: Optional[str] = None
450
466
 
451
467
  if event.type == 'rcs_agent.live':
452
468
  agent = event.object_as(AgentLive)
453
469
  print(agent.agent_id, agent.stage)
454
470
 
455
- raw = event.object_as() # plain dict copy
471
+ raw = event.object_as() # plain dict copy
472
+ same = event.raw_object # alias for event.object
456
473
  ```
457
474
 
475
+ `verification.*` payloads have a ready-made shape — pass `WebhookVerificationData` to
476
+ `event.object_as()` rather than writing your own.
477
+
458
478
  Event types live in one place, `sendly.types.WebhookEventType`; `sendly.WebhookEventType`
459
479
  re-exports that enum, and `sendly.webhooks.WEBHOOK_EVENT_TYPES` is the tuple of its values.
480
+ An event type this SDK version has not heard of is still delivered rather than rejected:
481
+ `event.type` keeps the raw string and `event.object` still carries the payload.
482
+
483
+ `message.queued` and `message.undelivered` are not in that enum. The API has never emitted
484
+ either one and rejects both with a `400` on subscribe, so 4.0.0 removed them. `'queued'` and
485
+ `'undelivered'` remain valid message *statuses* on `event.data.status`.
460
486
 
461
487
  ## Account & Credits
462
488
 
@@ -887,6 +913,67 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
887
913
  `request_launch()` replays return the original response without notifying the
888
914
  reviewers again.
889
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
+
890
977
  ## Error Handling
891
978
 
892
979
  The SDK provides typed exception classes:
@@ -1181,6 +1268,28 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
1181
1268
 
1182
1269
  Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
1183
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
+
1184
1293
  ## Enterprise
1185
1294
 
1186
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_*`).
@@ -344,7 +344,7 @@ client.webhooks.delete('whk_xxx')
344
344
  ### Verifying Webhook Signatures
345
345
 
346
346
  ```python
347
- from sendly import Webhooks
347
+ from sendly import Webhooks, WebhookSignatureError
348
348
 
349
349
  WEBHOOK_SECRET = 'your_webhook_secret'
350
350
 
@@ -357,16 +357,19 @@ def handle_webhook():
357
357
 
358
358
  try:
359
359
  event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
360
-
361
- if event.type == 'message.delivered':
362
- print(f'Message {event.data.id} delivered')
363
- elif event.type == 'message.failed':
364
- print(f'Message {event.data.id} failed: {event.data.error_code}')
365
-
366
- return 'OK', 200
367
- except Exception as e:
360
+ except WebhookSignatureError as e:
368
361
  print(f'Invalid signature: {e}')
369
362
  return 'Invalid signature', 400
363
+
364
+ if event.type == 'message.delivered':
365
+ print(f'Message {event.data.id} delivered')
366
+ elif event.type == 'message.failed':
367
+ print(f'Message {event.data.id} failed: {event.data.error_code}')
368
+ elif event.type == 'rcs_agent.live':
369
+ # A lifecycle event: event.data is None, the payload is on event.object
370
+ print(f"Agent {event.object['agent_id']} is live")
371
+
372
+ return 'OK', 200
370
373
  ```
371
374
 
372
375
  ### Reading the Event Payload
@@ -379,11 +382,15 @@ nothing the payload did not carry is added.
379
382
  `message.*` event, because RCS, WhatsApp, voice (`call.*`), 10DLC
380
383
  (`brand.*` / `campaign.*` / `assignment.*`), `number.*`, `port*`, `contact*`,
381
384
  `conversation.*`, `draft.*` and `verification.*` payloads are not
382
- message-shaped.
385
+ message-shaped. `message.opt_in` and `message.opt_out` are `None` too — they
386
+ share the prefix but carry an opt-out record (`phone_number`, `keyword`,
387
+ `from_number`, `timestamp`), not a message.
383
388
 
384
389
  ```python
385
390
  from dataclasses import dataclass
386
- from sendly import Webhooks
391
+ from typing import Optional
392
+
393
+ from sendly import Webhooks, is_message_event
387
394
 
388
395
  event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
389
396
 
@@ -394,6 +401,12 @@ else:
394
401
  # lifecycle event: read the raw object
395
402
  print(event.object)
396
403
 
404
+ # is_message_event() answers the same question from an event type alone, before
405
+ # you have an event in hand - filtering a subscription, routing a queue
406
+ is_message_event('message.delivered') # True
407
+ is_message_event('rcs_agent.live') # False
408
+ is_message_event('message.opt_out') # False - an opt-out record, not a message
409
+
397
410
  # A contact flagged by a send failure: `id` is the contact, `message_id` is
398
411
  # the message that failed. They are never swapped.
399
412
  if event.type == 'contact.auto_flagged':
@@ -406,22 +419,35 @@ if event.type == 'call.started':
406
419
  # for PSTN legs, so treat them as optional rather than assuming either.
407
420
  caller = event.object.get('from')
408
421
 
409
- # Or decode into a shape of your own (dataclass, pydantic model, or dict)
422
+ # Or decode into a shape of your own (dataclass, pydantic model, or dict).
423
+ # Only keys the payload actually carried are passed through, so a field it did
424
+ # not send keeps its default instead of being invented. A trailing underscore
425
+ # maps a reserved word: `from_` reads `from`.
410
426
  @dataclass
411
427
  class AgentLive:
412
- agent_id: str = None
413
- name: str = None
414
- stage: str = None
428
+ agent_id: Optional[str] = None
429
+ name: Optional[str] = None
430
+ stage: Optional[str] = None
415
431
 
416
432
  if event.type == 'rcs_agent.live':
417
433
  agent = event.object_as(AgentLive)
418
434
  print(agent.agent_id, agent.stage)
419
435
 
420
- raw = event.object_as() # plain dict copy
436
+ raw = event.object_as() # plain dict copy
437
+ same = event.raw_object # alias for event.object
421
438
  ```
422
439
 
440
+ `verification.*` payloads have a ready-made shape — pass `WebhookVerificationData` to
441
+ `event.object_as()` rather than writing your own.
442
+
423
443
  Event types live in one place, `sendly.types.WebhookEventType`; `sendly.WebhookEventType`
424
444
  re-exports that enum, and `sendly.webhooks.WEBHOOK_EVENT_TYPES` is the tuple of its values.
445
+ An event type this SDK version has not heard of is still delivered rather than rejected:
446
+ `event.type` keeps the raw string and `event.object` still carries the payload.
447
+
448
+ `message.queued` and `message.undelivered` are not in that enum. The API has never emitted
449
+ either one and rejects both with a `400` on subscribe, so 4.0.0 removed them. `'queued'` and
450
+ `'undelivered'` remain valid message *statuses* on `event.data.status`.
425
451
 
426
452
  ## Account & Credits
427
453
 
@@ -852,6 +878,67 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
852
878
  `request_launch()` replays return the original response without notifying the
853
879
  reviewers again.
854
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
+
855
942
  ## Error Handling
856
943
 
857
944
  The SDK provides typed exception classes:
@@ -1146,6 +1233,28 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
1146
1233
 
1147
1234
  Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
1148
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
+
1149
1258
  ## Enterprise
1150
1259
 
1151
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 = "3.40.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__ = "3.40.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",