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.
- {sendly-3.40.0 → sendly-4.1.0}/CHANGELOG.md +165 -0
- {sendly-3.40.0 → sendly-4.1.0}/PKG-INFO +126 -17
- {sendly-3.40.0 → sendly-4.1.0}/README.md +125 -16
- {sendly-3.40.0 → sendly-4.1.0}/pyproject.toml +1 -1
- {sendly-3.40.0 → sendly-4.1.0}/sendly/__init__.py +33 -1
- {sendly-3.40.0 → sendly-4.1.0}/sendly/client.py +3 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/__init__.py +3 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/account.py +9 -8
- sendly-4.1.0/sendly/resources/calls.py +360 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/campaigns.py +17 -16
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/contacts.py +19 -18
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/rcs.py +13 -12
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/templates.py +13 -12
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/tendlc.py +9 -8
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/verify.py +7 -6
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/webhooks.py +21 -20
- {sendly-3.40.0 → sendly-4.1.0}/sendly/types.py +228 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/utils/http.py +1 -1
- sendly-4.1.0/tests/test_calls.py +1047 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages.py +23 -1
- {sendly-3.40.0 → sendly-4.1.0}/.github/header.svg +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/.gitignore +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/examples/async_example.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/examples/error_handling.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/examples/send_sms.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/errors.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/py.typed +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/business_upgrade.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/conversations.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/drafts.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/enterprise.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/labels.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/links.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/media.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/messages.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/numbers.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/rules.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/resources/whatsapp.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/utils/__init__.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/utils/validation.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/sendly/webhooks.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/conftest.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_account.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_async_client.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_client.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_errors.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_http_body_kwarg.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_idempotency.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages.py.bak +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages_batch.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_messages_schedule.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_numbers.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_rcs.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_rcs_registration.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_tendlc.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_validation.py +0 -0
- {sendly-3.40.0 → sendly-4.1.0}/tests/test_webhooks.py +0 -0
- {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
|
+
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
|
|
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()
|
|
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
|
|
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()
|
|
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_*`).
|
|
@@ -29,7 +29,7 @@ Async Example:
|
|
|
29
29
|
>>> asyncio.run(main())
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "
|
|
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",
|