sendly 4.2.0__tar.gz → 4.3.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 (76) hide show
  1. {sendly-4.2.0 → sendly-4.3.0}/CHANGELOG.md +64 -0
  2. sendly-4.3.0/PKG-INFO +2660 -0
  3. sendly-4.3.0/README.md +2625 -0
  4. {sendly-4.2.0 → sendly-4.3.0}/examples/async_example.py +3 -3
  5. {sendly-4.2.0 → sendly-4.3.0}/examples/error_handling.py +18 -3
  6. {sendly-4.2.0 → sendly-4.3.0}/examples/send_sms.py +3 -1
  7. {sendly-4.2.0 → sendly-4.3.0}/pyproject.toml +1 -1
  8. {sendly-4.2.0 → sendly-4.3.0}/sendly/__init__.py +15 -1
  9. {sendly-4.2.0 → sendly-4.3.0}/sendly/errors.py +64 -12
  10. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/account.py +143 -43
  11. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/business_upgrade.py +33 -12
  12. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/calls.py +6 -2
  13. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/campaigns.py +152 -72
  14. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/contacts.py +41 -6
  15. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/conversations.py +21 -26
  16. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/enterprise.py +223 -79
  17. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/media.py +21 -29
  18. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/messages.py +176 -54
  19. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/rules.py +8 -0
  20. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/templates.py +14 -14
  21. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/webhooks.py +134 -35
  22. sendly-4.3.0/sendly/resources/whatsapp.py +1297 -0
  23. {sendly-4.2.0 → sendly-4.3.0}/sendly/types.py +661 -61
  24. {sendly-4.2.0 → sendly-4.3.0}/sendly/utils/http.py +60 -31
  25. sendly-4.3.0/tests/test_account.py +646 -0
  26. {sendly-4.2.0 → sendly-4.3.0}/tests/test_calls.py +7 -0
  27. sendly-4.3.0/tests/test_campaigns.py +290 -0
  28. sendly-4.3.0/tests/test_contacts.py +136 -0
  29. sendly-4.3.0/tests/test_conversations.py +72 -0
  30. sendly-4.3.0/tests/test_enterprise.py +353 -0
  31. {sendly-4.2.0 → sendly-4.3.0}/tests/test_errors.py +133 -0
  32. sendly-4.3.0/tests/test_http_retry.py +660 -0
  33. {sendly-4.2.0 → sendly-4.3.0}/tests/test_idempotency.py +14 -16
  34. sendly-4.3.0/tests/test_media.py +110 -0
  35. {sendly-4.2.0 → sendly-4.3.0}/tests/test_messages_batch.py +3 -3
  36. sendly-4.3.0/tests/test_messages_wire.py +567 -0
  37. {sendly-4.2.0 → sendly-4.3.0}/tests/test_numbers.py +102 -0
  38. sendly-4.3.0/tests/test_path_ids.py +82 -0
  39. {sendly-4.2.0 → sendly-4.3.0}/tests/test_rcs.py +71 -0
  40. sendly-4.3.0/tests/test_rules.py +59 -0
  41. sendly-4.3.0/tests/test_templates.py +48 -0
  42. sendly-4.3.0/tests/test_webhooks_resource.py +363 -0
  43. sendly-4.3.0/tests/test_whatsapp_extras.py +1336 -0
  44. sendly-4.2.0/PKG-INFO +0 -1513
  45. sendly-4.2.0/README.md +0 -1478
  46. sendly-4.2.0/sendly/resources/whatsapp.py +0 -636
  47. sendly-4.2.0/tests/test_account.py +0 -190
  48. {sendly-4.2.0 → sendly-4.3.0}/.github/header.svg +0 -0
  49. {sendly-4.2.0 → sendly-4.3.0}/.gitignore +0 -0
  50. {sendly-4.2.0 → sendly-4.3.0}/sendly/client.py +0 -0
  51. {sendly-4.2.0 → sendly-4.3.0}/sendly/py.typed +0 -0
  52. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/__init__.py +0 -0
  53. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/drafts.py +0 -0
  54. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/labels.py +0 -0
  55. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/links.py +0 -0
  56. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/numbers.py +0 -0
  57. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/rcs.py +0 -0
  58. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/tendlc.py +0 -0
  59. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/verify.py +0 -0
  60. {sendly-4.2.0 → sendly-4.3.0}/sendly/resources/voice.py +0 -0
  61. {sendly-4.2.0 → sendly-4.3.0}/sendly/utils/__init__.py +0 -0
  62. {sendly-4.2.0 → sendly-4.3.0}/sendly/utils/validation.py +0 -0
  63. {sendly-4.2.0 → sendly-4.3.0}/sendly/webhooks.py +0 -0
  64. {sendly-4.2.0 → sendly-4.3.0}/tests/conftest.py +0 -0
  65. {sendly-4.2.0 → sendly-4.3.0}/tests/test_async_client.py +0 -0
  66. {sendly-4.2.0 → sendly-4.3.0}/tests/test_client.py +0 -0
  67. {sendly-4.2.0 → sendly-4.3.0}/tests/test_http_body_kwarg.py +0 -0
  68. {sendly-4.2.0 → sendly-4.3.0}/tests/test_messages.py +0 -0
  69. {sendly-4.2.0 → sendly-4.3.0}/tests/test_messages.py.bak +0 -0
  70. {sendly-4.2.0 → sendly-4.3.0}/tests/test_messages_schedule.py +0 -0
  71. {sendly-4.2.0 → sendly-4.3.0}/tests/test_rcs_registration.py +0 -0
  72. {sendly-4.2.0 → sendly-4.3.0}/tests/test_tendlc.py +0 -0
  73. {sendly-4.2.0 → sendly-4.3.0}/tests/test_validation.py +0 -0
  74. {sendly-4.2.0 → sendly-4.3.0}/tests/test_voice.py +0 -0
  75. {sendly-4.2.0 → sendly-4.3.0}/tests/test_webhooks.py +0 -0
  76. {sendly-4.2.0 → sendly-4.3.0}/tests/test_whatsapp.py +0 -0
@@ -1,5 +1,69 @@
1
1
  # sendly (Python)
2
2
 
3
+ ## 4.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Options and fields the API already supported, now in the Python SDK.**
8
+ - `account.create_api_key(name, type='live', scopes=[...])`: keyword-only `type` (`'test'` or `'live'`) and `scopes`. Without `type` the request body is unchanged and the API creates a test key. A live key needs a verified business and a credit balance; the API answers 403 `verification_required` or 402 `credits_required` otherwise.
9
+ - `account.get_credit_transactions(type='refund')` filters by type. `TransactionType` gains `TRANSFER`, `ADMIN_GRANT` and `ADMIN_SEED`, and a type this version does not know is kept instead of raising: its `value` is the string the API sent and its `name` is `UNKNOWN`. Auto-recharges are recorded as `PURCHASE`; `ADJUSTMENT` is never recorded.
10
+ - `Account` gains `organization`, `credits`, `verification`, `api_key` and `limits`. `ApiKey` gains `is_active` and `revoked_at`.
11
+ - `webhooks.get_deliveries(id, limit=..., offset=..., status=...)`, where `status` is a `DeliveryStatus` or its value. `WebhookSecretRotation` gains `secret` (the same value as `new_secret`), `success`, `id`, `new_secret_version`, `grace_period_hours` and `rotated_at`.
12
+ - `messages.list()` takes `offset`, `status`, `direction`, `to`, `page`, `q` and `sandbox`, and `list_all()` takes `offset`, `status`, `direction`, `to`, `q` and `sandbox`; `status` is a `MessageStatus` or its value. `MessageListResponse` gains `pagination` (`MessageListPagination`: `total`, `limit`, `offset`, `page`, `total_pages`, `has_more`); `count` is the size of the page.
13
+ - `messages.send_batch()` and `preview_batch()` accept up to 10,000 messages, the API's limit, exported as `MAX_BATCH_MESSAGES`.
14
+ - `GroupMessageResponse.recipients` lists each `GroupRecipient` with its delivery status on a live group send.
15
+ - `SenderType` gains `EXPLICIT`, and `ScheduledMessageStatus` gains `DELIVERED` and `BOUNCED`. Both keep a value they do not know the same way as `TransactionType`, with the API's string as its `value`. None of the three has an `UNKNOWN` member to compare with, as `MessageStatus` does: check `.name == 'UNKNOWN'`.
16
+ - `RcsAgent.stage` and `RcsMessageDetails.fallback_reason`.
17
+ - `campaigns.send(id, from_=...)` returns a `CampaignSendResult`. `CampaignPreview` gains `opted_out_count`, `invalid_count` and `sample_recipients`, and `CampaignStatus` gains `COMPLETED`, what a sent campaign becomes.
18
+ - `rules.update(id, enabled=False)` switches a rule off, and `Rule` gains `enabled`.
19
+ - `TemplatePreview` gains `character_count` and `segment_count`.
20
+ - Enterprise: `workspaces.inherit_verification(id, source_id, purchase_new_number=True)` orders the workspace its own toll-free number. `analytics.credits()` returns `total_balance`, `total_lifetime`, `total_used` and `workspace_count`. `EnterpriseWorkspaceDetail` gains `verification_type`, `key_count` and `created_at`. `webhooks.set(url, events=..., workspaces=...)`, and `EnterpriseWebhook` gains `signing_secret` (returned once, by the first `set()`), `events` and `workspaces`. The async client gains `workspaces.resubmit_verification()`.
21
+ - `OwnedNumber.reported_monthly_cost_cents` is the monthly cost as the API reported it, or None when the number has no recorded price.
22
+ - New types: `CampaignSendResult`, `GroupRecipient`, `MessageListPagination` and `UpdatedContact`.
23
+ - **WhatsApp sender extras and adding a number by code.** Sync and async clients.
24
+ - `whatsapp.senders.upload_profile_photo(phone_number, file, content_type='image/jpeg')` uploads a JPEG or PNG of up to 5 MB (a binary file or bytes, sent as the multipart field `file`), and `delete_profile_photo(phone_number)` removes it. Both return the `WhatsAppSenderProfile`.
25
+ - `whatsapp.senders.get_conversational_components(phone_number)` and `update_conversational_components(phone_number, ice_breakers=..., commands=...)` read and replace the ice breakers (up to 4) and "/" commands (up to 30) as a `WhatsAppConversationalComponents`. `commands` takes dicts or the `WhatsAppCommand` objects a read returned. Each list you pass replaces the stored one and `[]` clears it; passing neither raises `ValidationError` before anything is sent.
26
+ - `whatsapp.senders.set_calling(phone_number, enabled=True)` switches WhatsApp calling on or off and returns a `WhatsAppCallingSettings`. Turning it on needs calls switched on for the number (`voice_not_enabled` otherwise). There is no API for placing WhatsApp calls.
27
+ - `WhatsAppSender` gains `business_account_id`, `business_name` (None while `pending`, or when the account has no business name on file), `calling_enabled` and `outbound_calling_allowed` (False for +1, +20, +84 and +234 numbers).
28
+ - `whatsapp.signup.create(phone_number, business_account_id=..., verification_method=..., display_name=...)` adds a number to a WhatsApp Business Account the workspace already connected, without the Facebook step. Same $19 fee, refunded if it fails. It returns a `WhatsAppSignup` with status `verifying` and no `connect_url`; `create(phone_number)` still returns a `WhatsAppSignupSession`, and overloads type both. An empty `business_account_id`, or `verification_method` or `display_name` without one, raises `ValidationError` before anything is sent.
29
+ - `whatsapp.signup.verify(id, code)` submits the 6-digit code and returns the `active` signup; `whatsapp.signup.resend(id, verification_method=None)` asks for a new code (at least 30 seconds apart; leaving out the method sends a text).
30
+ - `WhatsAppSignup` gains `verification_method`, `verification_attempts_remaining` and, from `get()`, `verification_code`, the code read from the number's texts or None. Signups can be `verifying`, and fail with `verification_start_failed`, `verification_failed` or `verification_expired`. `ApiErrorResponse` gains `attempts_remaining`, set on a wrong code.
31
+ - `signup.create()` with a `business_account_id`, `signup.verify()` and `senders.upload_profile_photo()` raise a 5xx, a timeout or a network error at once instead of retrying it: a retried add-by-code start can begin a new $19 signup, every code submission uses one of the 5 attempts (and a 502 `whatsapp_activation_pending` means WhatsApp already accepted the code), and the API keeps no idempotency record for a code submission or a photo upload. Only a 429 the API never ran is still waited out and retried, when its wait is 60 seconds or less: `rate_limit_exceeded`, `provision_rate_limit` or `too_many_concurrent_verifications` for `signup.create()` and `signup.verify()`, and only `too_many_concurrent_verifications` for the photo upload. `signup.resend()` and every other call keep the usual retry policy.
32
+ - `Call` gains `channel` (`phone`, `whatsapp` or `browser`, kept as a string so a value added later is not lost; see the new `CallChannel` enum), and the `call.started`, `call.completed` and `call.recording.ready` webhook objects carry it too.
33
+ - New types: `WhatsAppConversationalComponents`, `WhatsAppCommand`, `WhatsAppCallingSettings` and `CallChannel`.
34
+
35
+ ### Patch Changes
36
+
37
+ - **Methods that failed on every call now work.** Each was checked against the handler it calls.
38
+ - `account.get()` raised a validation error: the API nests `id`, `email` and `createdAt` under `user`.
39
+ - `account.get_credit_transactions()` iterated the keys of the `{transactions}` envelope and raised `AttributeError`, and rows with a `transfer` or `admin_grant` type or no description would not have parsed.
40
+ - `webhooks.get_deliveries()` iterated the keys of the `{deliveries, pagination}` envelope and raised `AttributeError`.
41
+ - `webhooks.rotate_secret()` raised after the API had rotated, so the new secret, shown only once, was lost. If a rotation response still cannot be read, the error keeps the body, secret included, on `e.response.model_extra`.
42
+ - `conversations.add_labels()` raised `invalid_response` after the labels were applied, and `conversations.remove_label()` raised `TypeError` on the empty 204 after the label was removed.
43
+ - `messages.send()` raised `invalid_response` after a live message was sent and charged, when the API reported `senderType` `explicit` (a number of yours). `messages.send_group()` did the same after every live group send, and `list_scheduled()` and `get_scheduled()` failed once a scheduled message was delivered or bounced.
44
+ - `numbers.list()` failed for any workspace with a number that has no recorded price, country or type, such as the toll-free number provisioned with a verification; `get()` and `update()` failed for that number. Such a number reads `monthly_cost_cents` 0 and an empty `country_code` or `phone_number_type`, and `reported_monthly_cost_cents` is None when no price is recorded.
45
+ - `campaigns.send()` raised `KeyError('id')` after the campaign was sent, and `campaigns.preview()` raised `KeyError('id')`.
46
+ - `media.upload()` sent its multipart body labelled `application/json`, so every upload failed.
47
+ - `contacts.update()` raised a validation error after saving, because the response has no `created_at`. It returns an `UpdatedContact` with the fields the response carries.
48
+ - `templates.preview()` raised `KeyError('id')`: the API returns `template_id` and `rendered_text`.
49
+ - `enterprise.webhooks.get()` raised pydantic's `ValidationError` when no webhook was set; it raises `NotFoundError` instead. The async `enterprise.workspaces.submit_verification()` sent a snake_case body the API never reads, so a first submit always failed.
50
+ - **An id of `..`, `.` or an empty string is refused before anything is sent.** Quoting an id leaves its dots alone, so the URL resolved `..` as a step up a level and sent the request to the parent endpoint: `enterprise.workspaces.revoke_key('ws_1', '..')` sent `DELETE /enterprise/workspaces/ws_1` and deleted the workspace, and `contacts.lists.remove_contact(list_id, '..')` deleted the list. Every method, sync and async, uploads included, now raises `ValidationError` for such an id without making a request. Ids that merely contain dots, such as `tpl.v2` or `...`, are sent as before.
51
+ - **Values that were wrong on every call.** `messages.list_scheduled()` and `list_batches()` sent an enum member passed as `status` by its name, such as `status=ScheduledMessageStatus.SCHEDULED`, which matched nothing; they send its value. `account.get_api_key()` reported `is_revoked=False` and `permissions=[]` for every key; they are read from `isActive`, `revokedAt` and `scopes`. `webhooks.test()` left `status_code` and `response_time_ms` None; they are read from the test delivery. `enterprise.workspaces.get()` left `verification_status`, `toll_free_number`, `business_name` and `credit_balance` at None or 0. `campaigns.get()`, `list()`, `create()`, `update()`, `schedule()`, `cancel()` and `clone()` read the counts and dates from snake_case keys the API does not send, so every count was 0 and `scheduled_at`, `started_at` and `completed_at` were None; they are read from the camelCase keys the API sends.
52
+ - **Only a response that can change is retried.** An ordinary rate limit (`rate_limit_exceeded`) still waits out the body's `retryAfter` when that is 60 seconds or less. A longer one is raised at once with its `retry_after`. The workspace provisioning limit (`provision_rate_limit`, 120 a minute) is waited out the same way; its hourly limit (1,000) is raised at once, unless a minute or less is left in the hour. It is now a `RateLimitError`, where it was a plain `SendlyError` that was sent again at once. Before, `verify.send()` and `verify.resend()` against the per-phone limit (5 codes per 10 minutes) or the daily limit (20 per day) blocked for up to 10 minutes or a day on each attempt. `verify.resend()` then sent a code nobody was waiting for; `verify.send()` retried under the same idempotency key, so it got the recorded 429 back and raised it. The two key-check 429s are described below. 408, 425 and 5xx responses are retried after the same exponential backoff as timeouts and network errors (5xx used to be retried at once). Every other status is raised on the first attempt: a 307, a 409 conflict, a 422 or a 429 `max_attempts_exceeded` from `verify.check()` used to be sent again, up to three more times. While `drafts.approve()` still answered with a redirect, the retried 307 surfaced as a misleading 404 `Draft not found or not pending`, because the first request had already used up the draft.
53
+ - **A retried 5xx keeps its idempotency key.** After a 5xx the client sent the retry with a new auto-generated key, a leftover from when the API recorded server errors under the key. The API has not recorded a 5xx since August, so the retry runs again under the same key either way. A new key only lost protection in one case: when the API had finished the request and recorded its answer but a gateway returned the 5xx, a retry with a new key sent the message again. The retry now carries the same key, so that case returns the recorded answer instead.
54
+ - **The two 429s from API key checks.** A 429 `too_many_concurrent_verifications`, returned while too many first-time key checks run at once from one address, is retried after its `retryAfter` (1 second; never a wait over a minute) with the same idempotency key, because the request never ran. This includes uploads (`media.upload()`, `business_upgrade.start()` and `resubmit()`, and `enterprise.upload_verification_document()`). If the retries run out it is a `RateLimitError`, where it was a plain `SendlyError` sent again at once with no wait (uploads raised it at once). A 429 `too_many_failed_key_attempts` means repeated wrong API keys from one address locked the account out for a while: it is raised at once as a `RateLimitError` whose `code` is `too_many_failed_key_attempts` and whose `retry_after` says when the lockout ends, which can be up to 5 minutes. It is not retried; it was a plain `SendlyError` sent again at once, up to three more times (uploads raised it at once). Fix the key, then wait `retry_after` seconds, since until the lockout ends requests from that address can be refused even with the right key. `RateLimitError` takes an optional `code` keyword. An ordinary `rate_limit_exceeded` is still waited out and retried when its `retryAfter` is 60 seconds or less, as described above.
55
+ - **Errors keep their code and message.** Many endpoints answer `{"error": "<sentence>"}` or `{"error": "not_found"}` with no `message`, and a failed `webhooks.test()` answers `{success, message}`; all of them surfaced as `internal_error` with the body as the message. A code-shaped `error` is kept; a sentence, or no `error`, gets a code from the status (`invalid_request`, `unauthorized`, `insufficient_credits`, `forbidden`, `not_found`, `conflict`, `rate_limit_exceeded`, otherwise `internal_error`), and the message is `message`, then the sentence, then `HTTP <status>`. `validation_error` and `invalid_code` errors are `ValidationError`s. `e.response` keeps the rest of the body.
56
+ - **Client-side checks raise the documented error type.** The RCS check for exactly one of `text` or `card` and the check that a WhatsApp send has content raise `ValidationError` instead of the base `SendlyError`, with the same code, `invalid_request`. `webhooks.create()` still raises `ValueError` for a URL that is not HTTPS or empty events, and its docstring now says so instead of `ValidationError`.
57
+ - **Docs that described behaviour the API does not have.** `webhooks.rotate_secret()` no longer says the old secret keeps working for 24 hours: deliveries are signed with the new secret as soon as the rotation returns, so have your endpoint accept both secrets while you deploy the new one. `webhooks.backfill()` no longer says to dedupe on `data.object.id`: synthesized events reuse the original event id, so dedupe on `event.id`. The `Message` descriptions no longer say sends omit `direction`, `segments` and `credits_used`. `calls.create()` lists `from_number_required` and `from_number_not_supported`, `VerificationStatus` says `INVALID` is never returned, `CampaignStatus` says `SENT` and `PAUSED` are never returned, and the `messages.preview_batch()` example reads keys the preview returns instead of `canSend`. `media.upload()` names the errors an unsupported file raises: `invalid_file` when `content_type` is a JPEG, PNG or GIF type but the content is not, and `internal_error` (HTTP 500) for any other `content_type` or a file over 600 KB. `Account.name`, `CreditTransaction.message_id`, `Campaign.template_id` and `CampaignPreview.breakdown` say the API never sends them, so they are always None, and `TemplatePreview.variables` says it is always empty. `campaigns.create()` and `update()` say `contact_list_ids` takes one contact list ID in a one-element list: a campaign targets one list, and the API answers 400 `invalid_request` to more than one.
58
+ - **WhatsApp docs match the API.** The field descriptions and docstrings now say that the API never sends the `expired` signup status, that a closed window returns its past `expires_at` rather than None, that a media send returns its caption as `text`, how in-window replies are priced (1 credit for the first 1,000 per sending number each month, then the destination's utility price), which roles and scopes connecting and editing need, the `waba_mismatch` and `registration_timeout` failure reasons, `template_header_variable_unsupported`, `whatsapp_unavailable` (503), `whatsapp_signup_limit_reached` (429), and `whatsapp_send_failed` as a final 422 or a retried 502. Nothing changes at runtime.
59
+ - **WhatsApp send failures.** A 502 `whatsapp_send_failed` means the message provably never reached the carrier, so it was not sent and is safe to send again; the SDK still retries it under the same idempotency key. The new 409 `whatsapp_send_unconfirmed` means the outcome is unknown: the message was marked failed and refunded but may still be delivered, so check before sending it again (it could arrive twice). It is not retried automatically. The `messages.send()` docstring and the README say so.
60
+
61
+ **Worth knowing before you upgrade.**
62
+
63
+ - Four methods are annotated with a new return type, because the old one described a response the endpoint never sends and every call raised: `conversations.add_labels()` returns `LabelListResponse`, `conversations.remove_label()` returns `None`, `campaigns.send()` returns `CampaignSendResult` (read the campaign itself with `campaigns.get()`), and `contacts.update()` returns `UpdatedContact`, which has no `opted_out`, `lists` or `created_at` because the update response does not carry them (read those with `contacts.get()`). A type checker will flag code written against the old types.
64
+ - Fields that the API leaves out or sends as null are now `Optional`: `CreditTransaction.description`, `TemplatePreview.name`, `CampaignPreview.estimated_segments`, and `WebhookSecretRotation.webhook` and `old_secret_expires_at`, which the API never sends and are always None.
65
+ - Errors from bodies without a code were `internal_error`; they now carry the status-derived code above and the matching subclass (`ValidationError`, `NotFoundError`, `AuthenticationError`, `InsufficientCreditsError`, `RateLimitError`). A failed `webhooks.test()` still raises, now as a `ValidationError` with the API's message. Code that compared `e.code == 'internal_error'` for these responses needs the new code. Every error is still a `SendlyError`.
66
+
3
67
  ## 4.2.0
4
68
 
5
69
  ### Minor Changes