sendly 3.33.0__tar.gz → 3.35.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 (45) hide show
  1. {sendly-3.33.0 → sendly-3.35.0}/CHANGELOG.md +7 -0
  2. {sendly-3.33.0 → sendly-3.35.0}/PKG-INFO +70 -1
  3. {sendly-3.33.0 → sendly-3.35.0}/README.md +69 -0
  4. {sendly-3.33.0 → sendly-3.35.0}/pyproject.toml +1 -1
  5. {sendly-3.33.0 → sendly-3.35.0}/sendly/__init__.py +25 -1
  6. {sendly-3.33.0 → sendly-3.35.0}/sendly/client.py +3 -0
  7. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/__init__.py +3 -0
  8. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/campaigns.py +6 -6
  9. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/contacts.py +16 -16
  10. sendly-3.35.0/sendly/resources/numbers.py +196 -0
  11. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/templates.py +10 -10
  12. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/webhooks.py +4 -4
  13. {sendly-3.33.0 → sendly-3.35.0}/sendly/types.py +171 -0
  14. {sendly-3.33.0 → sendly-3.35.0}/sendly/utils/http.py +1 -1
  15. sendly-3.35.0/tests/test_http_body_kwarg.py +160 -0
  16. sendly-3.35.0/tests/test_numbers.py +346 -0
  17. {sendly-3.33.0 → sendly-3.35.0}/.github/header.svg +0 -0
  18. {sendly-3.33.0 → sendly-3.35.0}/.gitignore +0 -0
  19. {sendly-3.33.0 → sendly-3.35.0}/examples/async_example.py +0 -0
  20. {sendly-3.33.0 → sendly-3.35.0}/examples/error_handling.py +0 -0
  21. {sendly-3.33.0 → sendly-3.35.0}/examples/send_sms.py +0 -0
  22. {sendly-3.33.0 → sendly-3.35.0}/sendly/errors.py +0 -0
  23. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/account.py +0 -0
  24. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/business_upgrade.py +0 -0
  25. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/conversations.py +0 -0
  26. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/drafts.py +0 -0
  27. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/enterprise.py +0 -0
  28. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/labels.py +0 -0
  29. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/media.py +0 -0
  30. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/messages.py +0 -0
  31. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/rules.py +0 -0
  32. {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/verify.py +0 -0
  33. {sendly-3.33.0 → sendly-3.35.0}/sendly/utils/__init__.py +0 -0
  34. {sendly-3.33.0 → sendly-3.35.0}/sendly/utils/validation.py +0 -0
  35. {sendly-3.33.0 → sendly-3.35.0}/sendly/webhooks.py +0 -0
  36. {sendly-3.33.0 → sendly-3.35.0}/tests/conftest.py +0 -0
  37. {sendly-3.33.0 → sendly-3.35.0}/tests/test_async_client.py +0 -0
  38. {sendly-3.33.0 → sendly-3.35.0}/tests/test_client.py +0 -0
  39. {sendly-3.33.0 → sendly-3.35.0}/tests/test_errors.py +0 -0
  40. {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages.py +0 -0
  41. {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages.py.bak +0 -0
  42. {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages_batch.py +0 -0
  43. {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages_schedule.py +0 -0
  44. {sendly-3.33.0 → sendly-3.35.0}/tests/test_validation.py +0 -0
  45. {sendly-3.33.0 → sendly-3.35.0}/tests/test_webhooks.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # sendly (Python)
2
2
 
3
+ ## 3.33.0
4
+
5
+ ### Patch Changes
6
+
7
+ - **Fixed a runtime `TypeError` on every write method across `contacts`, `campaigns`, `templates`, and `webhooks`.** Those resources called the internal HTTP client with a `json=` keyword, but `HttpClient.request()` / `AsyncHttpClient.request()` only accept `body=`. Every affected call site now passes `body=`, so creates/updates (e.g. `contacts.import_contacts`, `contacts.lookup`, `contacts.bulk_mark_valid`, `campaigns.create`, `templates.create`, `webhooks.create`) send their payloads correctly instead of raising.
8
+ - Reconciled the version to a single source of truth at `3.33.0`: `pyproject.toml`, `sendly.__version__`, and the `User-Agent` header (`SDK_VERSION` in `utils/http.py`, previously `1.0.5`) now all report `sendly-python/3.33.0`.
9
+
3
10
  ## 3.32.0
4
11
 
5
12
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sendly
3
- Version: 3.33.0
3
+ Version: 3.35.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
@@ -406,6 +406,57 @@ print(f"New key: {result['key']}") # Only shown once!
406
406
  client.account.revoke_api_key('key_xxx')
407
407
  ```
408
408
 
409
+ ## Phone Numbers
410
+
411
+ Search for and buy phone numbers. Prices on available numbers are already
412
+ customer-priced. Requires an API key with the `numbers:read` / `numbers:write`
413
+ scopes.
414
+
415
+ ```python
416
+ # List the countries where numbers are available
417
+ countries = client.numbers.list_countries()
418
+ for country in countries.countries:
419
+ print(f'{country.code} {country.name}: {country.number_types}')
420
+
421
+ # Search for available numbers (already customer-priced)
422
+ available = client.numbers.list_available(country='GB', type='mobile')
423
+ for num in available.numbers:
424
+ print(f'{num.phone_number} — {num.monthly_cost} {num.currency}')
425
+
426
+ # Optionally filter by a digit pattern
427
+ available = client.numbers.list_available(country='GB', type='mobile', contains='777')
428
+
429
+ # List the numbers you already own
430
+ owned = client.numbers.list()
431
+ for num in owned.numbers:
432
+ print(f'{num.phone_number} ({num.status})')
433
+
434
+ # Buy a number
435
+ chosen = available.numbers[0]
436
+ result = client.numbers.buy(
437
+ phone_number=chosen.phone_number,
438
+ country_code='GB',
439
+ phone_number_type='mobile',
440
+ monthly_cost=chosen.monthly_cost,
441
+ )
442
+
443
+ if result.status == 'provisioning':
444
+ print(f'Provisioning {result.number.phone_number}')
445
+ elif result.status in ('documents_required', 'payment_required'):
446
+ # Hand the user the hosted Sendly page + code, wait for them to finish,
447
+ # then call buy() again with the same arguments plus action_code set to
448
+ # the completed action's code.
449
+ print(f'Open {result.action.url} and enter code {result.action.code}')
450
+ # ...later, after the user completes the page...
451
+ result = client.numbers.buy(
452
+ phone_number=chosen.phone_number,
453
+ country_code='GB',
454
+ phone_number_type='mobile',
455
+ monthly_cost=chosen.monthly_cost,
456
+ action_code=result.action.code,
457
+ )
458
+ ```
459
+
409
460
  ## Error Handling
410
461
 
411
462
  The SDK provides typed exception classes:
@@ -594,6 +645,24 @@ List sent messages.
594
645
 
595
646
  Get a specific message by ID.
596
647
 
648
+ ### `client.numbers`
649
+
650
+ #### `list_countries() -> NumberCountriesResponse`
651
+
652
+ List the countries where numbers can be searched and purchased.
653
+
654
+ #### `list_available(country, type, contains=None) -> AvailableNumbersResponse`
655
+
656
+ Search for available numbers, already customer-priced.
657
+
658
+ #### `list() -> OwnedNumbersResponse`
659
+
660
+ List the numbers the account already owns.
661
+
662
+ #### `buy(phone_number, country_code, phone_number_type, monthly_cost, action_code=None) -> BuyNumberResponse`
663
+
664
+ Buy a number. Returns `status` of `provisioning`, `documents_required`, or `payment_required`; when documents/payment are required, `action` carries a hosted page URL + code to hand to the user before re-calling with `action_code`.
665
+
597
666
  ## Enterprise
598
667
 
599
668
  The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
@@ -371,6 +371,57 @@ print(f"New key: {result['key']}") # Only shown once!
371
371
  client.account.revoke_api_key('key_xxx')
372
372
  ```
373
373
 
374
+ ## Phone Numbers
375
+
376
+ Search for and buy phone numbers. Prices on available numbers are already
377
+ customer-priced. Requires an API key with the `numbers:read` / `numbers:write`
378
+ scopes.
379
+
380
+ ```python
381
+ # List the countries where numbers are available
382
+ countries = client.numbers.list_countries()
383
+ for country in countries.countries:
384
+ print(f'{country.code} {country.name}: {country.number_types}')
385
+
386
+ # Search for available numbers (already customer-priced)
387
+ available = client.numbers.list_available(country='GB', type='mobile')
388
+ for num in available.numbers:
389
+ print(f'{num.phone_number} — {num.monthly_cost} {num.currency}')
390
+
391
+ # Optionally filter by a digit pattern
392
+ available = client.numbers.list_available(country='GB', type='mobile', contains='777')
393
+
394
+ # List the numbers you already own
395
+ owned = client.numbers.list()
396
+ for num in owned.numbers:
397
+ print(f'{num.phone_number} ({num.status})')
398
+
399
+ # Buy a number
400
+ chosen = available.numbers[0]
401
+ result = client.numbers.buy(
402
+ phone_number=chosen.phone_number,
403
+ country_code='GB',
404
+ phone_number_type='mobile',
405
+ monthly_cost=chosen.monthly_cost,
406
+ )
407
+
408
+ if result.status == 'provisioning':
409
+ print(f'Provisioning {result.number.phone_number}')
410
+ elif result.status in ('documents_required', 'payment_required'):
411
+ # Hand the user the hosted Sendly page + code, wait for them to finish,
412
+ # then call buy() again with the same arguments plus action_code set to
413
+ # the completed action's code.
414
+ print(f'Open {result.action.url} and enter code {result.action.code}')
415
+ # ...later, after the user completes the page...
416
+ result = client.numbers.buy(
417
+ phone_number=chosen.phone_number,
418
+ country_code='GB',
419
+ phone_number_type='mobile',
420
+ monthly_cost=chosen.monthly_cost,
421
+ action_code=result.action.code,
422
+ )
423
+ ```
424
+
374
425
  ## Error Handling
375
426
 
376
427
  The SDK provides typed exception classes:
@@ -559,6 +610,24 @@ List sent messages.
559
610
 
560
611
  Get a specific message by ID.
561
612
 
613
+ ### `client.numbers`
614
+
615
+ #### `list_countries() -> NumberCountriesResponse`
616
+
617
+ List the countries where numbers can be searched and purchased.
618
+
619
+ #### `list_available(country, type, contains=None) -> AvailableNumbersResponse`
620
+
621
+ Search for available numbers, already customer-priced.
622
+
623
+ #### `list() -> OwnedNumbersResponse`
624
+
625
+ List the numbers the account already owns.
626
+
627
+ #### `buy(phone_number, country_code, phone_number_type, monthly_cost, action_code=None) -> BuyNumberResponse`
628
+
629
+ Buy a number. Returns `status` of `provisioning`, `documents_required`, or `payment_required`; when documents/payment are required, `action` carries a hosted page URL + code to hand to the user before re-calling with `action_code`.
630
+
562
631
  ## Enterprise
563
632
 
564
633
  The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "3.33.0"
7
+ version = "3.35.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.32.0"
32
+ __version__ = "3.33.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -52,6 +52,7 @@ from .resources.business_upgrade import (
52
52
  BusinessUpgradeResource,
53
53
  )
54
54
  from .resources.media import AsyncMediaResource, MediaResource
55
+ from .resources.numbers import AsyncNumbersResource, NumbersResource
55
56
 
56
57
  # Types
57
58
  from .types import (
@@ -115,6 +116,16 @@ from .types import (
115
116
  MessageAnalyticsDataPoint,
116
117
  MessageListResponse,
117
118
  MessageStatus,
119
+ # Numbers types
120
+ AvailableNumber,
121
+ AvailableNumbersResponse,
122
+ BuyNumberRef,
123
+ BuyNumberResponse,
124
+ NumberAction,
125
+ NumberCountriesResponse,
126
+ NumberCountry,
127
+ OwnedNumber,
128
+ OwnedNumbersResponse,
118
129
  OptInPage,
119
130
  PricingTier,
120
131
  QuotaSettings,
@@ -251,9 +262,22 @@ __all__ = [
251
262
  "CreateLabelRequest",
252
263
  "Label",
253
264
  "LabelListResponse",
265
+ # Numbers types
266
+ "AvailableNumber",
267
+ "AvailableNumbersResponse",
268
+ "BuyNumberRef",
269
+ "BuyNumberResponse",
270
+ "NumberAction",
271
+ "NumberCountriesResponse",
272
+ "NumberCountry",
273
+ "OwnedNumber",
274
+ "OwnedNumbersResponse",
254
275
  # Media resources
255
276
  "MediaResource",
256
277
  "AsyncMediaResource",
278
+ # Numbers resources
279
+ "NumbersResource",
280
+ "AsyncNumbersResource",
257
281
  # Business upgrade resources
258
282
  "BusinessUpgradeResource",
259
283
  "AsyncBusinessUpgradeResource",
@@ -19,6 +19,7 @@ from .resources.enterprise import AsyncEnterpriseResource, EnterpriseResource
19
19
  from .resources.labels import AsyncLabelsResource, LabelsResource
20
20
  from .resources.media import AsyncMediaResource, MediaResource
21
21
  from .resources.messages import AsyncMessagesResource, MessagesResource
22
+ from .resources.numbers import AsyncNumbersResource, NumbersResource
22
23
  from .resources.rules import AsyncRulesResource, RulesResource
23
24
  from .resources.templates import AsyncTemplatesResource, TemplatesResource
24
25
  from .resources.verify import AsyncVerifyResource, VerifyResource
@@ -117,6 +118,7 @@ class Sendly:
117
118
  self.campaigns = CampaignsResource(self._http)
118
119
  self.contacts = ContactsResource(self._http)
119
120
  self.media = MediaResource(self._http)
121
+ self.numbers = NumbersResource(self._http)
120
122
  self.enterprise = EnterpriseResource(self._http)
121
123
  self.labels = LabelsResource(self._http)
122
124
  self.drafts = DraftsResource(self._http)
@@ -255,6 +257,7 @@ class AsyncSendly:
255
257
  self.campaigns = AsyncCampaignsResource(self._http)
256
258
  self.contacts = AsyncContactsResource(self._http)
257
259
  self.media = AsyncMediaResource(self._http)
260
+ self.numbers = AsyncNumbersResource(self._http)
258
261
  self.enterprise = AsyncEnterpriseResource(self._http)
259
262
  self.labels = AsyncLabelsResource(self._http)
260
263
  self.drafts = AsyncDraftsResource(self._http)
@@ -8,6 +8,7 @@ from .enterprise import AsyncEnterpriseResource, EnterpriseResource
8
8
  from .labels import AsyncLabelsResource, LabelsResource
9
9
  from .media import AsyncMediaResource, MediaResource
10
10
  from .messages import AsyncMessagesResource, MessagesResource
11
+ from .numbers import AsyncNumbersResource, NumbersResource
11
12
  from .rules import AsyncRulesResource, RulesResource
12
13
  from .templates import AsyncTemplatesResource, TemplatesResource
13
14
  from .verify import AsyncVerifyResource, VerifyResource
@@ -29,6 +30,8 @@ __all__ = [
29
30
  "AsyncMediaResource",
30
31
  "MessagesResource",
31
32
  "AsyncMessagesResource",
33
+ "NumbersResource",
34
+ "AsyncNumbersResource",
32
35
  "VerifyResource",
33
36
  "AsyncVerifyResource",
34
37
  "TemplatesResource",
@@ -54,7 +54,7 @@ class CampaignsResource:
54
54
  if template_id:
55
55
  body["templateId"] = template_id
56
56
 
57
- data = self._http.request("POST", "/campaigns", json=body)
57
+ data = self._http.request("POST", "/campaigns", body=body)
58
58
  return self._transform_campaign(data)
59
59
 
60
60
  def list(
@@ -114,7 +114,7 @@ class CampaignsResource:
114
114
  if contact_list_ids is not None:
115
115
  body["contactListIds"] = contact_list_ids
116
116
 
117
- data = self._http.request("PATCH", f"/campaigns/{campaign_id}", json=body)
117
+ data = self._http.request("PATCH", f"/campaigns/{campaign_id}", body=body)
118
118
  return self._transform_campaign(data)
119
119
 
120
120
  def delete(self, campaign_id: str) -> None:
@@ -159,7 +159,7 @@ class CampaignsResource:
159
159
  if timezone:
160
160
  body["timezone"] = timezone
161
161
 
162
- data = self._http.request("POST", f"/campaigns/{campaign_id}/schedule", json=body)
162
+ data = self._http.request("POST", f"/campaigns/{campaign_id}/schedule", body=body)
163
163
  return self._transform_campaign(data)
164
164
 
165
165
  def cancel(self, campaign_id: str) -> Campaign:
@@ -217,7 +217,7 @@ class AsyncCampaignsResource:
217
217
  if template_id:
218
218
  body["templateId"] = template_id
219
219
 
220
- data = await self._http.request("POST", "/campaigns", json=body)
220
+ data = await self._http.request("POST", "/campaigns", body=body)
221
221
  return self._transform_campaign(data)
222
222
 
223
223
  async def list(
@@ -268,7 +268,7 @@ class AsyncCampaignsResource:
268
268
  if contact_list_ids is not None:
269
269
  body["contactListIds"] = contact_list_ids
270
270
 
271
- data = await self._http.request("PATCH", f"/campaigns/{campaign_id}", json=body)
271
+ data = await self._http.request("PATCH", f"/campaigns/{campaign_id}", body=body)
272
272
  return self._transform_campaign(data)
273
273
 
274
274
  async def delete(self, campaign_id: str) -> None:
@@ -304,7 +304,7 @@ class AsyncCampaignsResource:
304
304
  if timezone:
305
305
  body["timezone"] = timezone
306
306
 
307
- data = await self._http.request("POST", f"/campaigns/{campaign_id}/schedule", json=body)
307
+ data = await self._http.request("POST", f"/campaigns/{campaign_id}/schedule", body=body)
308
308
  return self._transform_campaign(data)
309
309
 
310
310
  async def cancel(self, campaign_id: str) -> Campaign:
@@ -51,7 +51,7 @@ class ContactListsResource:
51
51
  if description:
52
52
  body["description"] = description
53
53
 
54
- data = self._http.request("POST", "/contact-lists", json=body)
54
+ data = self._http.request("POST", "/contact-lists", body=body)
55
55
  return self._transform_list(data)
56
56
 
57
57
  def update(
@@ -68,7 +68,7 @@ class ContactListsResource:
68
68
  if description is not None:
69
69
  body["description"] = description
70
70
 
71
- data = self._http.request("PATCH", f"/contact-lists/{list_id}", json=body)
71
+ data = self._http.request("PATCH", f"/contact-lists/{list_id}", body=body)
72
72
  return self._transform_list(data)
73
73
 
74
74
  def delete(self, list_id: str) -> None:
@@ -84,7 +84,7 @@ class ContactListsResource:
84
84
  data = self._http.request(
85
85
  "POST",
86
86
  f"/contact-lists/{list_id}/contacts",
87
- json={"contact_ids": contact_ids},
87
+ body={"contact_ids": contact_ids},
88
88
  )
89
89
  return {"added_count": data["added_count"]}
90
90
 
@@ -194,7 +194,7 @@ class ContactsResource:
194
194
  if metadata:
195
195
  body["metadata"] = metadata
196
196
 
197
- data = self._http.request("POST", "/contacts", json=body)
197
+ data = self._http.request("POST", "/contacts", body=body)
198
198
  return self._transform_contact(data)
199
199
 
200
200
  def update(
@@ -214,7 +214,7 @@ class ContactsResource:
214
214
  if metadata is not None:
215
215
  body["metadata"] = metadata
216
216
 
217
- data = self._http.request("PATCH", f"/contacts/{contact_id}", json=body)
217
+ data = self._http.request("PATCH", f"/contacts/{contact_id}", body=body)
218
218
  return self._transform_contact(data)
219
219
 
220
220
  def delete(self, contact_id: str) -> None:
@@ -252,7 +252,7 @@ class ContactsResource:
252
252
  if ids and list_id:
253
253
  raise ValueError("bulk_mark_valid accepts 'ids' OR 'list_id', not both")
254
254
  body: Dict[str, Any] = {"ids": ids} if ids else {"listId": list_id}
255
- data = self._http.request("POST", "/contacts/bulk-mark-valid", json=body)
255
+ data = self._http.request("POST", "/contacts/bulk-mark-valid", body=body)
256
256
  return BulkMarkValidResponse(cleared=data.get("cleared", 0))
257
257
 
258
258
  def check_numbers(
@@ -279,7 +279,7 @@ class ContactsResource:
279
279
  return self._http.request(
280
280
  "POST",
281
281
  "/contacts/lookup",
282
- json={"listId": list_id, "force": force},
282
+ body={"listId": list_id, "force": force},
283
283
  )
284
284
 
285
285
  def import_contacts(
@@ -304,7 +304,7 @@ class ContactsResource:
304
304
  if opted_in_at:
305
305
  body["optedInAt"] = opted_in_at
306
306
 
307
- data = self._http.request("POST", "/contacts/import", json=body)
307
+ data = self._http.request("POST", "/contacts/import", body=body)
308
308
  return ImportContactsResponse(
309
309
  imported=data["imported"],
310
310
  skipped_duplicates=data["skippedDuplicates"],
@@ -379,7 +379,7 @@ class AsyncContactListsResource:
379
379
  if description:
380
380
  body["description"] = description
381
381
 
382
- data = await self._http.request("POST", "/contact-lists", json=body)
382
+ data = await self._http.request("POST", "/contact-lists", body=body)
383
383
  return self._transform_list(data)
384
384
 
385
385
  async def update(
@@ -396,7 +396,7 @@ class AsyncContactListsResource:
396
396
  if description is not None:
397
397
  body["description"] = description
398
398
 
399
- data = await self._http.request("PATCH", f"/contact-lists/{list_id}", json=body)
399
+ data = await self._http.request("PATCH", f"/contact-lists/{list_id}", body=body)
400
400
  return self._transform_list(data)
401
401
 
402
402
  async def delete(self, list_id: str) -> None:
@@ -408,7 +408,7 @@ class AsyncContactListsResource:
408
408
  data = await self._http.request(
409
409
  "POST",
410
410
  f"/contact-lists/{list_id}/contacts",
411
- json={"contact_ids": contact_ids},
411
+ body={"contact_ids": contact_ids},
412
412
  )
413
413
  return {"added_count": data["added_count"]}
414
414
 
@@ -495,7 +495,7 @@ class AsyncContactsResource:
495
495
  if metadata:
496
496
  body["metadata"] = metadata
497
497
 
498
- data = await self._http.request("POST", "/contacts", json=body)
498
+ data = await self._http.request("POST", "/contacts", body=body)
499
499
  return self._transform_contact(data)
500
500
 
501
501
  async def update(
@@ -515,7 +515,7 @@ class AsyncContactsResource:
515
515
  if metadata is not None:
516
516
  body["metadata"] = metadata
517
517
 
518
- data = await self._http.request("PATCH", f"/contacts/{contact_id}", json=body)
518
+ data = await self._http.request("PATCH", f"/contacts/{contact_id}", body=body)
519
519
  return self._transform_contact(data)
520
520
 
521
521
  async def delete(self, contact_id: str) -> None:
@@ -544,7 +544,7 @@ class AsyncContactsResource:
544
544
  raise ValueError("bulk_mark_valid accepts 'ids' OR 'list_id', not both")
545
545
  body: Dict[str, Any] = {"ids": ids} if ids else {"listId": list_id}
546
546
  data = await self._http.request(
547
- "POST", "/contacts/bulk-mark-valid", json=body
547
+ "POST", "/contacts/bulk-mark-valid", body=body
548
548
  )
549
549
  return BulkMarkValidResponse(cleared=data.get("cleared", 0))
550
550
 
@@ -562,7 +562,7 @@ class AsyncContactsResource:
562
562
  return await self._http.request(
563
563
  "POST",
564
564
  "/contacts/lookup",
565
- json={"listId": list_id, "force": force},
565
+ body={"listId": list_id, "force": force},
566
566
  )
567
567
 
568
568
  async def import_contacts(
@@ -587,7 +587,7 @@ class AsyncContactsResource:
587
587
  if opted_in_at:
588
588
  body["optedInAt"] = opted_in_at
589
589
 
590
- data = await self._http.request("POST", "/contacts/import", json=body)
590
+ data = await self._http.request("POST", "/contacts/import", body=body)
591
591
  return ImportContactsResponse(
592
592
  imported=data["imported"],
593
593
  skipped_duplicates=data["skippedDuplicates"],
@@ -0,0 +1,196 @@
1
+ """
2
+ Numbers Resource - Search, list, and buy phone numbers
3
+
4
+ Exposes the public numbers API: discover the countries Sendly sells numbers
5
+ in, search for available numbers, list the numbers you already own, and buy a
6
+ number. Pricing on available numbers is already customer-priced.
7
+
8
+ When a purchase needs documents or payment, ``buy()`` returns a response whose
9
+ ``status`` is ``documents_required`` or ``payment_required`` and whose
10
+ ``action`` carries a hosted Sendly page (``url``) plus a short ``code``. Hand
11
+ the user the URL + code; once they complete the page, call ``buy()`` again with
12
+ the SAME arguments PLUS ``action_code`` set to that code. Polling the action to
13
+ completion is the CLI's job, not the SDK's.
14
+ """
15
+
16
+ from typing import Any, Dict, Optional
17
+
18
+ from pydantic import ValidationError as PydanticValidationError
19
+
20
+ from ..errors import SendlyError
21
+ from ..types import (
22
+ AvailableNumbersResponse,
23
+ BuyNumberResponse,
24
+ NumberCountriesResponse,
25
+ OwnedNumbersResponse,
26
+ )
27
+ from ..utils.http import AsyncHttpClient, HttpClient
28
+
29
+
30
+ class NumbersResource:
31
+ """Numbers API resource (sync)
32
+
33
+ Example:
34
+ >>> countries = client.numbers.list_countries()
35
+ >>> available = client.numbers.list_available(country='GB', type='mobile')
36
+ >>> result = client.numbers.buy(
37
+ ... phone_number=available.numbers[0].phone_number,
38
+ ... country_code='GB',
39
+ ... phone_number_type='mobile',
40
+ ... monthly_cost=available.numbers[0].monthly_cost,
41
+ ... )
42
+ """
43
+
44
+ def __init__(self, http: HttpClient):
45
+ self._http = http
46
+
47
+ def list_countries(self) -> NumberCountriesResponse:
48
+ """List the countries where numbers can be searched and purchased."""
49
+ data = self._http.request(method="GET", path="/numbers/countries")
50
+ try:
51
+ return NumberCountriesResponse(**data)
52
+ except PydanticValidationError as e:
53
+ raise _invalid_response(e) from e
54
+
55
+ def list_available(
56
+ self,
57
+ country: str,
58
+ type: str,
59
+ contains: Optional[str] = None,
60
+ ) -> AvailableNumbersResponse:
61
+ """Search for available numbers, already priced for the customer.
62
+
63
+ Args:
64
+ country: ISO 3166-1 alpha-2 country code (e.g. ``GB``).
65
+ type: Number type to search for (e.g. ``mobile``).
66
+ contains: Optional digit pattern the number should contain.
67
+ """
68
+ params: Dict[str, Any] = {"country": country, "type": type}
69
+ if contains is not None:
70
+ params["contains"] = contains
71
+
72
+ data = self._http.request(method="GET", path="/numbers/available", params=params)
73
+ try:
74
+ return AvailableNumbersResponse(**data)
75
+ except PydanticValidationError as e:
76
+ raise _invalid_response(e) from e
77
+
78
+ def list(self) -> OwnedNumbersResponse:
79
+ """List the numbers the account already owns."""
80
+ data = self._http.request(method="GET", path="/numbers")
81
+ try:
82
+ return OwnedNumbersResponse(**data)
83
+ except PydanticValidationError as e:
84
+ raise _invalid_response(e) from e
85
+
86
+ def buy(
87
+ self,
88
+ phone_number: str,
89
+ country_code: str,
90
+ phone_number_type: str,
91
+ monthly_cost: str,
92
+ action_code: Optional[str] = None,
93
+ ) -> BuyNumberResponse:
94
+ """Buy a number.
95
+
96
+ Returns a response whose ``status`` is ``provisioning``,
97
+ ``documents_required``, or ``payment_required``. When documents or
98
+ payment are required, the ``action`` field carries a hosted Sendly page
99
+ URL + a short code; hand them to the user, then call ``buy()`` again
100
+ with the same arguments plus ``action_code`` set to the code of the
101
+ completed action.
102
+
103
+ Args:
104
+ phone_number: Number to buy, in E.164 format.
105
+ country_code: ISO 3166-1 alpha-2 country code.
106
+ phone_number_type: Number type (e.g. ``mobile``).
107
+ monthly_cost: Monthly cost as returned by :meth:`list_available`.
108
+ action_code: Code from a completed documents/payment action.
109
+ """
110
+ body: Dict[str, Any] = {
111
+ "phoneNumber": phone_number,
112
+ "countryCode": country_code,
113
+ "phoneNumberType": phone_number_type,
114
+ "monthlyCost": monthly_cost,
115
+ }
116
+ if action_code is not None:
117
+ body["actionCode"] = action_code
118
+
119
+ data = self._http.request(method="POST", path="/numbers/buy", body=body)
120
+ try:
121
+ return BuyNumberResponse(**data)
122
+ except PydanticValidationError as e:
123
+ raise _invalid_response(e) from e
124
+
125
+
126
+ class AsyncNumbersResource:
127
+ """Numbers API resource (async)"""
128
+
129
+ def __init__(self, http: AsyncHttpClient):
130
+ self._http = http
131
+
132
+ async def list_countries(self) -> NumberCountriesResponse:
133
+ """List the countries where numbers can be searched and purchased."""
134
+ data = await self._http.request(method="GET", path="/numbers/countries")
135
+ try:
136
+ return NumberCountriesResponse(**data)
137
+ except PydanticValidationError as e:
138
+ raise _invalid_response(e) from e
139
+
140
+ async def list_available(
141
+ self,
142
+ country: str,
143
+ type: str,
144
+ contains: Optional[str] = None,
145
+ ) -> AvailableNumbersResponse:
146
+ """Search for available numbers, already priced for the customer."""
147
+ params: Dict[str, Any] = {"country": country, "type": type}
148
+ if contains is not None:
149
+ params["contains"] = contains
150
+
151
+ data = await self._http.request(method="GET", path="/numbers/available", params=params)
152
+ try:
153
+ return AvailableNumbersResponse(**data)
154
+ except PydanticValidationError as e:
155
+ raise _invalid_response(e) from e
156
+
157
+ async def list(self) -> OwnedNumbersResponse:
158
+ """List the numbers the account already owns."""
159
+ data = await self._http.request(method="GET", path="/numbers")
160
+ try:
161
+ return OwnedNumbersResponse(**data)
162
+ except PydanticValidationError as e:
163
+ raise _invalid_response(e) from e
164
+
165
+ async def buy(
166
+ self,
167
+ phone_number: str,
168
+ country_code: str,
169
+ phone_number_type: str,
170
+ monthly_cost: str,
171
+ action_code: Optional[str] = None,
172
+ ) -> BuyNumberResponse:
173
+ """Buy a number. See :meth:`NumbersResource.buy`."""
174
+ body: Dict[str, Any] = {
175
+ "phoneNumber": phone_number,
176
+ "countryCode": country_code,
177
+ "phoneNumberType": phone_number_type,
178
+ "monthlyCost": monthly_cost,
179
+ }
180
+ if action_code is not None:
181
+ body["actionCode"] = action_code
182
+
183
+ data = await self._http.request(method="POST", path="/numbers/buy", body=body)
184
+ try:
185
+ return BuyNumberResponse(**data)
186
+ except PydanticValidationError as e:
187
+ raise _invalid_response(e) from e
188
+
189
+
190
+ def _invalid_response(e: PydanticValidationError) -> SendlyError:
191
+ """Wrap a pydantic schema error as a SendlyError, matching the SDK's idiom."""
192
+ return SendlyError(
193
+ message=f"Invalid API response format: {e}",
194
+ code="invalid_response",
195
+ status_code=200,
196
+ )