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.
Files changed (56) hide show
  1. {sendly-3.40.0 → sendly-4.0.0}/CHANGELOG.md +154 -0
  2. {sendly-3.40.0 → sendly-4.0.0}/PKG-INFO +43 -17
  3. {sendly-3.40.0 → sendly-4.0.0}/README.md +42 -16
  4. {sendly-3.40.0 → sendly-4.0.0}/pyproject.toml +1 -1
  5. {sendly-3.40.0 → sendly-4.0.0}/sendly/__init__.py +1 -1
  6. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/account.py +9 -8
  7. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/campaigns.py +17 -16
  8. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/contacts.py +19 -18
  9. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/rcs.py +13 -12
  10. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/templates.py +13 -12
  11. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/tendlc.py +9 -8
  12. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/verify.py +7 -6
  13. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/webhooks.py +21 -20
  14. {sendly-3.40.0 → sendly-4.0.0}/sendly/utils/http.py +1 -1
  15. {sendly-3.40.0 → sendly-4.0.0}/.github/header.svg +0 -0
  16. {sendly-3.40.0 → sendly-4.0.0}/.gitignore +0 -0
  17. {sendly-3.40.0 → sendly-4.0.0}/examples/async_example.py +0 -0
  18. {sendly-3.40.0 → sendly-4.0.0}/examples/error_handling.py +0 -0
  19. {sendly-3.40.0 → sendly-4.0.0}/examples/send_sms.py +0 -0
  20. {sendly-3.40.0 → sendly-4.0.0}/sendly/client.py +0 -0
  21. {sendly-3.40.0 → sendly-4.0.0}/sendly/errors.py +0 -0
  22. {sendly-3.40.0 → sendly-4.0.0}/sendly/py.typed +0 -0
  23. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/__init__.py +0 -0
  24. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/business_upgrade.py +0 -0
  25. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/conversations.py +0 -0
  26. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/drafts.py +0 -0
  27. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/enterprise.py +0 -0
  28. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/labels.py +0 -0
  29. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/links.py +0 -0
  30. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/media.py +0 -0
  31. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/messages.py +0 -0
  32. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/numbers.py +0 -0
  33. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/rules.py +0 -0
  34. {sendly-3.40.0 → sendly-4.0.0}/sendly/resources/whatsapp.py +0 -0
  35. {sendly-3.40.0 → sendly-4.0.0}/sendly/types.py +0 -0
  36. {sendly-3.40.0 → sendly-4.0.0}/sendly/utils/__init__.py +0 -0
  37. {sendly-3.40.0 → sendly-4.0.0}/sendly/utils/validation.py +0 -0
  38. {sendly-3.40.0 → sendly-4.0.0}/sendly/webhooks.py +0 -0
  39. {sendly-3.40.0 → sendly-4.0.0}/tests/conftest.py +0 -0
  40. {sendly-3.40.0 → sendly-4.0.0}/tests/test_account.py +0 -0
  41. {sendly-3.40.0 → sendly-4.0.0}/tests/test_async_client.py +0 -0
  42. {sendly-3.40.0 → sendly-4.0.0}/tests/test_client.py +0 -0
  43. {sendly-3.40.0 → sendly-4.0.0}/tests/test_errors.py +0 -0
  44. {sendly-3.40.0 → sendly-4.0.0}/tests/test_http_body_kwarg.py +0 -0
  45. {sendly-3.40.0 → sendly-4.0.0}/tests/test_idempotency.py +0 -0
  46. {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages.py +0 -0
  47. {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages.py.bak +0 -0
  48. {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages_batch.py +0 -0
  49. {sendly-3.40.0 → sendly-4.0.0}/tests/test_messages_schedule.py +0 -0
  50. {sendly-3.40.0 → sendly-4.0.0}/tests/test_numbers.py +0 -0
  51. {sendly-3.40.0 → sendly-4.0.0}/tests/test_rcs.py +0 -0
  52. {sendly-3.40.0 → sendly-4.0.0}/tests/test_rcs_registration.py +0 -0
  53. {sendly-3.40.0 → sendly-4.0.0}/tests/test_tendlc.py +0 -0
  54. {sendly-3.40.0 → sendly-4.0.0}/tests/test_validation.py +0 -0
  55. {sendly-3.40.0 → sendly-4.0.0}/tests/test_webhooks.py +0 -0
  56. {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.40.0
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 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
 
@@ -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
 
@@ -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.0.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.0.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -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: