sendly 3.40.0__tar.gz → 4.0.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.0.0}/CHANGELOG.md +154 -0
- {sendly-3.40.0 → sendly-4.0.0}/PKG-INFO +43 -17
- {sendly-3.40.0 → sendly-4.0.0}/README.md +42 -16
- {sendly-3.40.0 → sendly-4.0.0}/pyproject.toml +1 -1
- {sendly-3.40.0 → sendly-4.0.0}/sendly/__init__.py +1 -1
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/account.py +9 -8
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/campaigns.py +17 -16
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/contacts.py +19 -18
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/rcs.py +13 -12
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/templates.py +13 -12
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/tendlc.py +9 -8
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/verify.py +7 -6
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/webhooks.py +21 -20
- {sendly-3.40.0 → sendly-4.0.0}/sendly/utils/http.py +1 -1
- {sendly-3.40.0 → sendly-4.0.0}/.github/header.svg +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/.gitignore +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/examples/async_example.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/examples/error_handling.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/examples/send_sms.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/client.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/errors.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/py.typed +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/__init__.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/business_upgrade.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/conversations.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/drafts.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/enterprise.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/labels.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/links.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/media.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/messages.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/numbers.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/rules.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/whatsapp.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/types.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/utils/__init__.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/utils/validation.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/sendly/webhooks.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/conftest.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_account.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_async_client.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_client.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_errors.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_http_body_kwarg.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_idempotency.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages.py.bak +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages_batch.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages_schedule.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_numbers.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_rcs.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_rcs_registration.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_tendlc.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_validation.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_webhooks.py +0 -0
- {sendly-3.40.0 → sendly-4.0.0}/tests/test_whatsapp.py +0 -0
|
@@ -1,5 +1,159 @@
|
|
|
1
1
|
# sendly (Python)
|
|
2
2
|
|
|
3
|
+
## 4.0.0
|
|
4
|
+
|
|
5
|
+
**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**.
|
|
6
|
+
|
|
7
|
+
Webhook events are no longer decoded as messages. Every SDK used to force a webhook's
|
|
8
|
+
`data.object` into a message-shaped type. That is correct for `message.*` and wrong for
|
|
9
|
+
every lifecycle event — `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`,
|
|
10
|
+
`assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*`, `draft.*`,
|
|
11
|
+
`verification.*` — which carry a different object entirely. This release exposes the payload
|
|
12
|
+
as it arrived and makes the wrong read fail instead of returning a plausible-looking zero.
|
|
13
|
+
|
|
14
|
+
### Breaking Changes
|
|
15
|
+
|
|
16
|
+
- **`event.data` is now `None` for every event that is not a `message.*` event.** It used to
|
|
17
|
+
be a fully-populated `WebhookMessageData` whatever arrived, assembled by reading message
|
|
18
|
+
field names off a payload that never carried them. Parsing an `rcs_agent.live` event
|
|
19
|
+
returned `id=''`, `status=''`, `to=''`, `from_=''`, `segments=1`, `credits_used=0`,
|
|
20
|
+
`direction='outbound'` — every one of those invented by the SDK — and **raised no error**.
|
|
21
|
+
A handler that logged `event.data.credits_used` recorded a `0` that meant nothing, and a
|
|
22
|
+
handler that branched on `event.data.status` took the empty-string branch forever.
|
|
23
|
+
|
|
24
|
+
Reading a message field off a lifecycle event now raises
|
|
25
|
+
`AttributeError: 'NoneType' object has no attribute 'id'`. **That is the point of this
|
|
26
|
+
release, not an accident.** The read was always wrong; it is now loud instead of silent,
|
|
27
|
+
so you find every affected call site the first time such an event arrives rather than
|
|
28
|
+
trusting a zero indefinitely.
|
|
29
|
+
|
|
30
|
+
Your code today, on any non-`message.*` event:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
|
|
34
|
+
|
|
35
|
+
if event.type == 'rcs_agent.live':
|
|
36
|
+
agent_id = event.data.id # was always '' - the payload has no `id` at all
|
|
37
|
+
stage = event.data.status # was always ''
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
After upgrading, that raises. Read the payload off `event.object`, which carries
|
|
41
|
+
`data.object` verbatim:
|
|
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.object['agent_id']
|
|
48
|
+
stage = event.object['stage']
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The edit is mechanical: on a lifecycle event, every `event.data.<field>` becomes
|
|
52
|
+
`event.object['<key>']`, with the key spelled the way the API sends it rather than the way
|
|
53
|
+
`WebhookMessageData` spelled it. There was no `event.object` before this release, so no
|
|
54
|
+
correct version of this code existed to migrate from — check what your handler assumed
|
|
55
|
+
rather than translating it field for field.
|
|
56
|
+
|
|
57
|
+
If a handler touches `event.data` in several branches, guard the message-only path once.
|
|
58
|
+
`if event.data is not None:` both narrows the type for a checker and marks the branch:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
if event.data is not None:
|
|
62
|
+
record_delivery(event.data.id, event.data.status)
|
|
63
|
+
else:
|
|
64
|
+
handle_lifecycle(event.type, event.object)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`message.*` handlers otherwise need no change: `event.data` is still the message view
|
|
68
|
+
there.
|
|
69
|
+
|
|
70
|
+
- **`contact.auto_flagged` reported the wrong id, and code keyed on it acted on the wrong
|
|
71
|
+
record.** The payload is a contact — `{id, phone_number, invalid_reason, source,
|
|
72
|
+
message_id, error_code}` — so the old decode put the *contact* id in `event.data.id`, and
|
|
73
|
+
`event.data.message_id` (an alias for `id`) returned that same contact id. The message
|
|
74
|
+
that actually failed was unreachable. If you wrote `mark_bounced(event.data.id)`, it has
|
|
75
|
+
been handing a contact id to something that expects a message id:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# before - `event.data.id` is the CONTACT id, `event.data.message_id` is the same value
|
|
79
|
+
if event.type == 'contact.auto_flagged':
|
|
80
|
+
mark_bounced(event.data.id)
|
|
81
|
+
|
|
82
|
+
# after - the two ids are distinct and never swapped
|
|
83
|
+
if event.type == 'contact.auto_flagged':
|
|
84
|
+
flag_contact(event.object['id'])
|
|
85
|
+
mark_bounced(event.object['message_id'])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Audit anything this handler wrote before you upgrade; the bad writes are already on disk.
|
|
89
|
+
|
|
90
|
+
- **`message.opt_in` and `message.opt_out` also return `event.data is None`.** They share the
|
|
91
|
+
`message.` prefix but carry an opt-out record (`phone_number`, `keyword`, `from_number`,
|
|
92
|
+
`timestamp`), not a message, so the old decode produced an all-default message here too.
|
|
93
|
+
Read them from `event.object`. Use `is_message_event(event.type)` rather than testing the
|
|
94
|
+
prefix yourself — it knows about these two.
|
|
95
|
+
|
|
96
|
+
- **`WebhookEventType` is an enum, not a `Literal` union.** `sendly.webhooks` used to define
|
|
97
|
+
its own `Literal[...]` of event-type strings, drifting from the `WebhookEventType` enum in
|
|
98
|
+
`sendly.types`. There is now one definition: `sendly.webhooks.WebhookEventType` re-exports
|
|
99
|
+
the enum, and `sendly.webhooks.WEBHOOK_EVENT_TYPES` is the tuple of its values. Runtime
|
|
100
|
+
comparisons are unaffected (it is a `str` enum, so `event.type == 'message.delivered'` and
|
|
101
|
+
`event.type == WebhookEventType.MESSAGE_DELIVERED` are both `True`), but an annotation like
|
|
102
|
+
`event_type: WebhookEventType = 'message.delivered'` no longer type-checks. Use
|
|
103
|
+
`WebhookEventType.MESSAGE_DELIVERED`, or annotate as `str`.
|
|
104
|
+
|
|
105
|
+
- **`message.queued` and `message.undelivered` are gone.** The API has never emitted either
|
|
106
|
+
one and rejects both with a `400` on subscribe, so any `events` list containing them was
|
|
107
|
+
subscribing to nothing and any handler branch on them was dead. They are no longer in
|
|
108
|
+
`WebhookEventType` or `WEBHOOK_EVENT_TYPES`; `WebhookEventType.MESSAGE_QUEUED` now raises
|
|
109
|
+
`AttributeError`. Delete them from your `webhooks.create()` / `webhooks.update()` calls.
|
|
110
|
+
`'queued'` and `'undelivered'` are still valid message *statuses* on `event.data.status` —
|
|
111
|
+
only the event types were removed.
|
|
112
|
+
|
|
113
|
+
- **`WebhookMessageData` fields no longer carry invented defaults.** Every field is now
|
|
114
|
+
`Optional` and defaults to `None`. On a `message.*` payload that omitted a key you used to
|
|
115
|
+
get `''` for `id` / `status` / `to` / `from_`, `1` for `segments`, `0` for `credits_used`
|
|
116
|
+
and `'outbound'` for `direction`; you now get `None`, so an absent value is
|
|
117
|
+
distinguishable from a real one. Code that did `if not event.data.to:` still works; code
|
|
118
|
+
that did `event.data.to.startswith('+')` or `event.data.segments + 1` unguarded will now
|
|
119
|
+
raise on a sparse payload. `WebhookVerificationData` lost its invented defaults the same
|
|
120
|
+
way (`delivery_status='queued'`, `attempts=0`, `max_attempts=3` are all `None` now).
|
|
121
|
+
|
|
122
|
+
### Minor Changes
|
|
123
|
+
|
|
124
|
+
- **`event.object`** — `data.object` exactly as it arrived, on every event type. Keys are
|
|
125
|
+
verbatim (`camelCase` stays `camelCase`), JSON `null` stays `None`, and nothing the payload
|
|
126
|
+
did not carry is added. `event.raw_object` is an alias for it.
|
|
127
|
+
- **`event.object_as(cls)`** — decode the payload into a shape of your own: a dataclass, a
|
|
128
|
+
pydantic v2 model, or `dict`. `event.object_as()` with no argument returns a plain dict
|
|
129
|
+
copy. Only keys the payload actually carried are passed through, so a field it did not send
|
|
130
|
+
keeps its default rather than being invented, and a trailing underscore maps a reserved
|
|
131
|
+
word (`from_` reads `from`).
|
|
132
|
+
- **`is_message_event(event_type)`** — whether an event type carries a message-shaped
|
|
133
|
+
`data.object`. Takes a `WebhookEventType` or a raw string, including one this SDK version
|
|
134
|
+
has never heard of.
|
|
135
|
+
- **`WebhookVerificationData`** — a ready-made shape for `verification.*` payloads.
|
|
136
|
+
`parse_event()` does not produce it for you; pass it to
|
|
137
|
+
`event.object_as(WebhookVerificationData)` when you want it.
|
|
138
|
+
- **An event type this SDK version does not know is delivered, not rejected.** `event.type`
|
|
139
|
+
keeps the raw string and `event.object` still carries the payload, so a new event released
|
|
140
|
+
after this version reaches your handler.
|
|
141
|
+
- `WebhookEventType` gains the types that were missing everywhere: `conversation.*`,
|
|
142
|
+
`draft.*`, `rcs_brand.*`, `rcs_agent.*`, `whatsapp_account.*`, `whatsapp_template.*` and
|
|
143
|
+
`call.*`. There was previously no typed way to subscribe to RCS, WhatsApp or voice events.
|
|
144
|
+
|
|
145
|
+
### Patch Changes
|
|
146
|
+
|
|
147
|
+
- A `data.object` that is not a JSON object now fails with a message that says so —
|
|
148
|
+
`Failed to parse webhook payload: Invalid event structure: data.object must be an object,
|
|
149
|
+
got str` — instead of surfacing an internal `'str' object has no attribute 'get'`. It
|
|
150
|
+
raised before too; only the message changed.
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
### Security
|
|
154
|
+
|
|
155
|
+
- **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.
|
|
156
|
+
|
|
3
157
|
## 3.38.0
|
|
4
158
|
|
|
5
159
|
### Minor Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sendly
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.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
|
|
|
@@ -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
|
|
|
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
|
|
|
8
8
|
|
|
9
9
|
from ..types import Account, ApiKey, Credits, CreditTransaction
|
|
10
10
|
from ..utils.http import AsyncHttpClient, HttpClient
|
|
11
|
+
from urllib.parse import quote
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _transform_response(data: Dict[str, Any], key_map: Dict[str, str]) -> Dict[str, Any]:
|
|
@@ -141,7 +142,7 @@ class AccountResource:
|
|
|
141
142
|
Returns:
|
|
142
143
|
API key details
|
|
143
144
|
"""
|
|
144
|
-
response = self._http.request("GET", f"/account/keys/{key_id}")
|
|
145
|
+
response = self._http.request("GET", f"/account/keys/{quote(key_id, safe='')}")
|
|
145
146
|
return ApiKey(**_transform_response(response, API_KEY_MAP))
|
|
146
147
|
|
|
147
148
|
def get_api_key_usage(self, key_id: str) -> Dict[str, Any]:
|
|
@@ -154,7 +155,7 @@ class AccountResource:
|
|
|
154
155
|
Returns:
|
|
155
156
|
Usage statistics
|
|
156
157
|
"""
|
|
157
|
-
response = self._http.request("GET", f"/account/keys/{key_id}/usage")
|
|
158
|
+
response = self._http.request("GET", f"/account/keys/{quote(key_id, safe='')}/usage")
|
|
158
159
|
return response
|
|
159
160
|
|
|
160
161
|
def create_api_key(self, name: str, expires_at: Optional[str] = None) -> Dict[str, Any]:
|
|
@@ -192,7 +193,7 @@ class AccountResource:
|
|
|
192
193
|
if not key_id:
|
|
193
194
|
raise ValueError("API key ID is required")
|
|
194
195
|
|
|
195
|
-
self._http.request("PATCH", f"/account/keys/{key_id}/revoke", body={})
|
|
196
|
+
self._http.request("PATCH", f"/account/keys/{quote(key_id, safe='')}/revoke", body={})
|
|
196
197
|
|
|
197
198
|
def rotate_api_key(
|
|
198
199
|
self, key_id: str, grace_period_hours: Optional[int] = None
|
|
@@ -228,7 +229,7 @@ class AccountResource:
|
|
|
228
229
|
raise ValueError("grace_period_hours must be between 24 and 168")
|
|
229
230
|
body["gracePeriodHours"] = grace_period_hours
|
|
230
231
|
|
|
231
|
-
return self._http.request("POST", f"/account/keys/{key_id}/rotate", body=body)
|
|
232
|
+
return self._http.request("POST", f"/account/keys/{quote(key_id, safe='')}/rotate", body=body)
|
|
232
233
|
|
|
233
234
|
|
|
234
235
|
class AsyncAccountResource:
|
|
@@ -284,12 +285,12 @@ class AsyncAccountResource:
|
|
|
284
285
|
|
|
285
286
|
async def get_api_key(self, key_id: str) -> ApiKey:
|
|
286
287
|
"""Get a specific API key by ID."""
|
|
287
|
-
response = await self._http.request("GET", f"/account/keys/{key_id}")
|
|
288
|
+
response = await self._http.request("GET", f"/account/keys/{quote(key_id, safe='')}")
|
|
288
289
|
return ApiKey(**_transform_response(response, API_KEY_MAP))
|
|
289
290
|
|
|
290
291
|
async def get_api_key_usage(self, key_id: str) -> Dict[str, Any]:
|
|
291
292
|
"""Get usage statistics for an API key."""
|
|
292
|
-
response = await self._http.request("GET", f"/account/keys/{key_id}/usage")
|
|
293
|
+
response = await self._http.request("GET", f"/account/keys/{quote(key_id, safe='')}/usage")
|
|
293
294
|
return response
|
|
294
295
|
|
|
295
296
|
async def create_api_key(self, name: str, expires_at: Optional[str] = None) -> Dict[str, Any]:
|
|
@@ -323,7 +324,7 @@ class AsyncAccountResource:
|
|
|
323
324
|
if not key_id:
|
|
324
325
|
raise ValueError("API key ID is required")
|
|
325
326
|
|
|
326
|
-
await self._http.request("PATCH", f"/account/keys/{key_id}/revoke", body={})
|
|
327
|
+
await self._http.request("PATCH", f"/account/keys/{quote(key_id, safe='')}/revoke", body={})
|
|
327
328
|
|
|
328
329
|
async def rotate_api_key(
|
|
329
330
|
self, key_id: str, grace_period_hours: Optional[int] = None
|
|
@@ -355,5 +356,5 @@ class AsyncAccountResource:
|
|
|
355
356
|
body["gracePeriodHours"] = grace_period_hours
|
|
356
357
|
|
|
357
358
|
return await self._http.request(
|
|
358
|
-
"POST", f"/account/keys/{key_id}/rotate", body=body
|
|
359
|
+
"POST", f"/account/keys/{quote(key_id, safe='')}/rotate", body=body
|
|
359
360
|
)
|
|
@@ -10,6 +10,7 @@ from ..types import (
|
|
|
10
10
|
CampaignPreview,
|
|
11
11
|
)
|
|
12
12
|
from ..utils.http import AsyncHttpClient, HttpClient
|
|
13
|
+
from urllib.parse import quote
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class CampaignsResource:
|
|
@@ -91,7 +92,7 @@ class CampaignsResource:
|
|
|
91
92
|
|
|
92
93
|
def get(self, campaign_id: str) -> Campaign:
|
|
93
94
|
"""Get a campaign by ID"""
|
|
94
|
-
data = self._http.request("GET", f"/campaigns/{campaign_id}")
|
|
95
|
+
data = self._http.request("GET", f"/campaigns/{quote(campaign_id, safe='')}")
|
|
95
96
|
return self._transform_campaign(data)
|
|
96
97
|
|
|
97
98
|
def update(
|
|
@@ -114,19 +115,19 @@ class CampaignsResource:
|
|
|
114
115
|
if contact_list_ids is not None:
|
|
115
116
|
body["contactListIds"] = contact_list_ids
|
|
116
117
|
|
|
117
|
-
data = self._http.request("PATCH", f"/campaigns/{campaign_id}", body=body)
|
|
118
|
+
data = self._http.request("PATCH", f"/campaigns/{quote(campaign_id, safe='')}", body=body)
|
|
118
119
|
return self._transform_campaign(data)
|
|
119
120
|
|
|
120
121
|
def delete(self, campaign_id: str) -> None:
|
|
121
122
|
"""Delete a campaign (draft or cancelled only)"""
|
|
122
|
-
self._http.request("DELETE", f"/campaigns/{campaign_id}")
|
|
123
|
+
self._http.request("DELETE", f"/campaigns/{quote(campaign_id, safe='')}")
|
|
123
124
|
|
|
124
125
|
def preview(self, campaign_id: str) -> CampaignPreview:
|
|
125
126
|
"""Preview campaign before sending
|
|
126
127
|
|
|
127
128
|
Returns recipient count, credit estimate, and breakdown.
|
|
128
129
|
"""
|
|
129
|
-
data = self._http.request("GET", f"/campaigns/{campaign_id}/preview")
|
|
130
|
+
data = self._http.request("GET", f"/campaigns/{quote(campaign_id, safe='')}/preview")
|
|
130
131
|
return CampaignPreview(
|
|
131
132
|
id=data["id"],
|
|
132
133
|
recipient_count=data["recipient_count"],
|
|
@@ -139,7 +140,7 @@ class CampaignsResource:
|
|
|
139
140
|
|
|
140
141
|
def send(self, campaign_id: str) -> Campaign:
|
|
141
142
|
"""Send a campaign immediately"""
|
|
142
|
-
data = self._http.request("POST", f"/campaigns/{campaign_id}/send")
|
|
143
|
+
data = self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/send")
|
|
143
144
|
return self._transform_campaign(data)
|
|
144
145
|
|
|
145
146
|
def schedule(
|
|
@@ -159,17 +160,17 @@ class CampaignsResource:
|
|
|
159
160
|
if timezone:
|
|
160
161
|
body["timezone"] = timezone
|
|
161
162
|
|
|
162
|
-
data = self._http.request("POST", f"/campaigns/{campaign_id}/schedule", body=body)
|
|
163
|
+
data = self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/schedule", body=body)
|
|
163
164
|
return self._transform_campaign(data)
|
|
164
165
|
|
|
165
166
|
def cancel(self, campaign_id: str) -> Campaign:
|
|
166
167
|
"""Cancel a scheduled campaign"""
|
|
167
|
-
data = self._http.request("POST", f"/campaigns/{campaign_id}/cancel")
|
|
168
|
+
data = self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/cancel")
|
|
168
169
|
return self._transform_campaign(data)
|
|
169
170
|
|
|
170
171
|
def clone(self, campaign_id: str) -> Campaign:
|
|
171
172
|
"""Clone a campaign (creates new draft)"""
|
|
172
|
-
data = self._http.request("POST", f"/campaigns/{campaign_id}/clone")
|
|
173
|
+
data = self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/clone")
|
|
173
174
|
return self._transform_campaign(data)
|
|
174
175
|
|
|
175
176
|
def _transform_campaign(self, data: Dict[str, Any]) -> Campaign:
|
|
@@ -245,7 +246,7 @@ class AsyncCampaignsResource:
|
|
|
245
246
|
|
|
246
247
|
async def get(self, campaign_id: str) -> Campaign:
|
|
247
248
|
"""Get a campaign by ID"""
|
|
248
|
-
data = await self._http.request("GET", f"/campaigns/{campaign_id}")
|
|
249
|
+
data = await self._http.request("GET", f"/campaigns/{quote(campaign_id, safe='')}")
|
|
249
250
|
return self._transform_campaign(data)
|
|
250
251
|
|
|
251
252
|
async def update(
|
|
@@ -268,16 +269,16 @@ class AsyncCampaignsResource:
|
|
|
268
269
|
if contact_list_ids is not None:
|
|
269
270
|
body["contactListIds"] = contact_list_ids
|
|
270
271
|
|
|
271
|
-
data = await self._http.request("PATCH", f"/campaigns/{campaign_id}", body=body)
|
|
272
|
+
data = await self._http.request("PATCH", f"/campaigns/{quote(campaign_id, safe='')}", body=body)
|
|
272
273
|
return self._transform_campaign(data)
|
|
273
274
|
|
|
274
275
|
async def delete(self, campaign_id: str) -> None:
|
|
275
276
|
"""Delete a campaign (draft or cancelled only)"""
|
|
276
|
-
await self._http.request("DELETE", f"/campaigns/{campaign_id}")
|
|
277
|
+
await self._http.request("DELETE", f"/campaigns/{quote(campaign_id, safe='')}")
|
|
277
278
|
|
|
278
279
|
async def preview(self, campaign_id: str) -> CampaignPreview:
|
|
279
280
|
"""Preview campaign before sending"""
|
|
280
|
-
data = await self._http.request("GET", f"/campaigns/{campaign_id}/preview")
|
|
281
|
+
data = await self._http.request("GET", f"/campaigns/{quote(campaign_id, safe='')}/preview")
|
|
281
282
|
return CampaignPreview(
|
|
282
283
|
id=data["id"],
|
|
283
284
|
recipient_count=data["recipient_count"],
|
|
@@ -290,7 +291,7 @@ class AsyncCampaignsResource:
|
|
|
290
291
|
|
|
291
292
|
async def send(self, campaign_id: str) -> Campaign:
|
|
292
293
|
"""Send a campaign immediately"""
|
|
293
|
-
data = await self._http.request("POST", f"/campaigns/{campaign_id}/send")
|
|
294
|
+
data = await self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/send")
|
|
294
295
|
return self._transform_campaign(data)
|
|
295
296
|
|
|
296
297
|
async def schedule(
|
|
@@ -304,17 +305,17 @@ class AsyncCampaignsResource:
|
|
|
304
305
|
if timezone:
|
|
305
306
|
body["timezone"] = timezone
|
|
306
307
|
|
|
307
|
-
data = await self._http.request("POST", f"/campaigns/{campaign_id}/schedule", body=body)
|
|
308
|
+
data = await self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/schedule", body=body)
|
|
308
309
|
return self._transform_campaign(data)
|
|
309
310
|
|
|
310
311
|
async def cancel(self, campaign_id: str) -> Campaign:
|
|
311
312
|
"""Cancel a scheduled campaign"""
|
|
312
|
-
data = await self._http.request("POST", f"/campaigns/{campaign_id}/cancel")
|
|
313
|
+
data = await self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/cancel")
|
|
313
314
|
return self._transform_campaign(data)
|
|
314
315
|
|
|
315
316
|
async def clone(self, campaign_id: str) -> Campaign:
|
|
316
317
|
"""Clone a campaign (creates new draft)"""
|
|
317
|
-
data = await self._http.request("POST", f"/campaigns/{campaign_id}/clone")
|
|
318
|
+
data = await self._http.request("POST", f"/campaigns/{quote(campaign_id, safe='')}/clone")
|
|
318
319
|
return self._transform_campaign(data)
|
|
319
320
|
|
|
320
321
|
def _transform_campaign(self, data: Dict[str, Any]) -> Campaign:
|