sendly 3.8.1__py3-none-any.whl

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.
@@ -0,0 +1,1087 @@
1
+ """
2
+ Messages Resource
3
+
4
+ API resource for sending and managing SMS messages.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional, Union
8
+ from urllib.parse import quote
9
+
10
+ from pydantic import ValidationError as PydanticValidationError
11
+
12
+ from ..errors import SendlyError
13
+ from ..types import (
14
+ BatchListResponse,
15
+ BatchMessageResponse,
16
+ CancelledMessageResponse,
17
+ ListMessagesOptions,
18
+ Message,
19
+ MessageListResponse,
20
+ ScheduledMessage,
21
+ ScheduledMessageListResponse,
22
+ SendMessageRequest,
23
+ )
24
+ from ..utils.http import AsyncHttpClient, HttpClient
25
+ from ..utils.validation import (
26
+ validate_limit,
27
+ validate_message_id,
28
+ validate_message_text,
29
+ validate_phone_number,
30
+ validate_sender_id,
31
+ )
32
+
33
+
34
+ class MessagesResource:
35
+ """
36
+ Messages API resource (synchronous)
37
+
38
+ Example:
39
+ >>> client = Sendly('sk_live_v1_xxx')
40
+ >>> message = client.messages.send(to='+15551234567', text='Hello!')
41
+ >>> messages = client.messages.list(limit=10)
42
+ >>> msg = client.messages.get('msg_xxx')
43
+ """
44
+
45
+ def __init__(self, http: HttpClient):
46
+ self._http = http
47
+
48
+ def send(
49
+ self,
50
+ to: str,
51
+ text: str,
52
+ from_: Optional[str] = None,
53
+ message_type: Optional[str] = None,
54
+ **kwargs: Any,
55
+ ) -> Message:
56
+ """
57
+ Send an SMS message
58
+
59
+ Args:
60
+ to: Destination phone number in E.164 format (e.g., +15551234567)
61
+ text: Message content
62
+ from_: Optional sender ID or phone number
63
+ message_type: Message type for compliance - 'marketing' (default, subject to quiet hours) or 'transactional' (24/7)
64
+
65
+ Returns:
66
+ The created message
67
+
68
+ Raises:
69
+ ValidationError: If the request is invalid
70
+ InsufficientCreditsError: If credit balance is too low
71
+ AuthenticationError: If the API key is invalid
72
+ RateLimitError: If rate limit is exceeded
73
+
74
+ Example:
75
+ >>> message = client.messages.send(
76
+ ... to='+15551234567',
77
+ ... text='Your code is: 123456'
78
+ ... )
79
+ >>> print(message.id)
80
+ >>> print(message.status)
81
+ """
82
+ # Validate inputs
83
+ validate_phone_number(to)
84
+ validate_message_text(text)
85
+ if from_:
86
+ validate_sender_id(from_)
87
+
88
+ # Build request body
89
+ body: Dict[str, Any] = {
90
+ "to": to,
91
+ "text": text,
92
+ }
93
+ if from_:
94
+ body["from"] = from_
95
+ if message_type:
96
+ body["messageType"] = message_type
97
+
98
+ # Make API request
99
+ data = self._http.request(
100
+ method="POST",
101
+ path="/messages",
102
+ body=body,
103
+ )
104
+
105
+ try:
106
+ return Message(**data)
107
+ except PydanticValidationError as e:
108
+ raise SendlyError(
109
+ message=f"Invalid API response format: {e}",
110
+ code="invalid_response",
111
+ status_code=200,
112
+ ) from e
113
+
114
+ def list(
115
+ self,
116
+ limit: Optional[int] = None,
117
+ **kwargs: Any,
118
+ ) -> MessageListResponse:
119
+ """
120
+ List sent messages
121
+
122
+ Args:
123
+ limit: Maximum number of messages to return (1-100, default 50)
124
+
125
+ Returns:
126
+ Paginated list of messages
127
+
128
+ Raises:
129
+ AuthenticationError: If the API key is invalid
130
+ RateLimitError: If rate limit is exceeded
131
+
132
+ Example:
133
+ >>> result = client.messages.list(limit=10)
134
+ >>> for msg in result.data:
135
+ ... print(f'{msg.to}: {msg.status}')
136
+ """
137
+ # Validate inputs
138
+ validate_limit(limit)
139
+
140
+ # Build query params
141
+ params: Dict[str, Any] = {}
142
+ if limit is not None:
143
+ params["limit"] = limit
144
+
145
+ # Make API request
146
+ data = self._http.request(
147
+ method="GET",
148
+ path="/messages",
149
+ params=params if params else None,
150
+ )
151
+
152
+ try:
153
+ return MessageListResponse(**data)
154
+ except PydanticValidationError as e:
155
+ raise SendlyError(
156
+ message=f"Invalid API response format: {e}",
157
+ code="invalid_response",
158
+ status_code=200,
159
+ ) from e
160
+
161
+ def get(self, id: str) -> Message:
162
+ """
163
+ Get a specific message by ID
164
+
165
+ Args:
166
+ id: Message ID
167
+
168
+ Returns:
169
+ The message details
170
+
171
+ Raises:
172
+ NotFoundError: If the message doesn't exist
173
+ AuthenticationError: If the API key is invalid
174
+ RateLimitError: If rate limit is exceeded
175
+
176
+ Example:
177
+ >>> message = client.messages.get('msg_xxx')
178
+ >>> print(message.status)
179
+ >>> print(message.delivered_at)
180
+ """
181
+ # Validate ID
182
+ validate_message_id(id)
183
+
184
+ # Make API request
185
+ data = self._http.request(
186
+ method="GET",
187
+ path=f"/messages/{quote(id, safe='')}",
188
+ )
189
+
190
+ try:
191
+ return Message(**data)
192
+ except PydanticValidationError as e:
193
+ raise SendlyError(
194
+ message=f"Invalid API response format: {e}",
195
+ code="invalid_response",
196
+ status_code=200,
197
+ ) from e
198
+
199
+ def list_all(
200
+ self,
201
+ batch_size: int = 100,
202
+ **kwargs: Any,
203
+ ):
204
+ """
205
+ Iterate through all messages with automatic pagination
206
+
207
+ Args:
208
+ batch_size: Number of messages to fetch per request (max 100)
209
+
210
+ Yields:
211
+ Message objects one at a time
212
+
213
+ Raises:
214
+ AuthenticationError: If the API key is invalid
215
+ RateLimitError: If rate limit is exceeded
216
+
217
+ Example:
218
+ >>> for message in client.messages.list_all():
219
+ ... print(f'{message.id}: {message.status}')
220
+ """
221
+ batch_size = min(batch_size, 100)
222
+ offset = 0
223
+
224
+ while True:
225
+ data = self._http.request(
226
+ method="GET",
227
+ path="/messages",
228
+ params={"limit": batch_size, "offset": offset},
229
+ )
230
+
231
+ try:
232
+ response = MessageListResponse(**data)
233
+ except PydanticValidationError as e:
234
+ raise SendlyError(
235
+ message=f"Invalid API response format: {e}",
236
+ code="invalid_response",
237
+ status_code=200,
238
+ ) from e
239
+
240
+ for message in response.data:
241
+ yield message
242
+
243
+ if len(response.data) < batch_size:
244
+ break
245
+
246
+ offset += batch_size
247
+
248
+ # =========================================================================
249
+ # Scheduled Messages
250
+ # =========================================================================
251
+
252
+ def schedule(
253
+ self,
254
+ to: str,
255
+ text: str,
256
+ scheduled_at: str,
257
+ from_: Optional[str] = None,
258
+ message_type: Optional[str] = None,
259
+ **kwargs: Any,
260
+ ) -> ScheduledMessage:
261
+ """
262
+ Schedule an SMS message for future delivery
263
+
264
+ Args:
265
+ to: Destination phone number in E.164 format
266
+ text: Message content
267
+ scheduled_at: When to send (ISO 8601, must be > 1 minute in future)
268
+ from_: Optional sender ID (for international destinations only)
269
+ message_type: Message type for compliance - 'marketing' (default, subject to quiet hours) or 'transactional' (24/7)
270
+
271
+ Returns:
272
+ The scheduled message
273
+
274
+ Example:
275
+ >>> scheduled = client.messages.schedule(
276
+ ... to='+15551234567',
277
+ ... text='Your appointment reminder!',
278
+ ... scheduled_at='2025-01-20T10:00:00Z'
279
+ ... )
280
+ >>> print(scheduled.id)
281
+ >>> print(scheduled.status)
282
+ """
283
+ validate_phone_number(to)
284
+ validate_message_text(text)
285
+ if from_:
286
+ validate_sender_id(from_)
287
+
288
+ body: Dict[str, Any] = {
289
+ "to": to,
290
+ "text": text,
291
+ "scheduledAt": scheduled_at,
292
+ }
293
+ if from_:
294
+ body["from"] = from_
295
+ if message_type:
296
+ body["messageType"] = message_type
297
+
298
+ data = self._http.request(
299
+ method="POST",
300
+ path="/messages/schedule",
301
+ body=body,
302
+ )
303
+
304
+ try:
305
+ return ScheduledMessage(**data)
306
+ except PydanticValidationError as e:
307
+ raise SendlyError(
308
+ message=f"Invalid API response format: {e}",
309
+ code="invalid_response",
310
+ status_code=200,
311
+ ) from e
312
+
313
+ def list_scheduled(
314
+ self,
315
+ limit: Optional[int] = None,
316
+ offset: Optional[int] = None,
317
+ status: Optional[str] = None,
318
+ **kwargs: Any,
319
+ ) -> ScheduledMessageListResponse:
320
+ """
321
+ List scheduled messages
322
+
323
+ Args:
324
+ limit: Maximum number of messages to return (1-100)
325
+ offset: Number of messages to skip
326
+ status: Filter by status
327
+
328
+ Returns:
329
+ Paginated list of scheduled messages
330
+ """
331
+ validate_limit(limit)
332
+
333
+ params: Dict[str, Any] = {}
334
+ if limit is not None:
335
+ params["limit"] = limit
336
+ if offset is not None:
337
+ params["offset"] = offset
338
+ if status is not None:
339
+ params["status"] = status
340
+
341
+ data = self._http.request(
342
+ method="GET",
343
+ path="/messages/scheduled",
344
+ params=params if params else None,
345
+ )
346
+
347
+ try:
348
+ return ScheduledMessageListResponse(**data)
349
+ except PydanticValidationError as e:
350
+ raise SendlyError(
351
+ message=f"Invalid API response format: {e}",
352
+ code="invalid_response",
353
+ status_code=200,
354
+ ) from e
355
+
356
+ def get_scheduled(self, id: str) -> ScheduledMessage:
357
+ """
358
+ Get a specific scheduled message by ID
359
+
360
+ Args:
361
+ id: Message ID
362
+
363
+ Returns:
364
+ The scheduled message details
365
+ """
366
+ validate_message_id(id)
367
+
368
+ data = self._http.request(
369
+ method="GET",
370
+ path=f"/messages/scheduled/{quote(id, safe='')}",
371
+ )
372
+
373
+ try:
374
+ return ScheduledMessage(**data)
375
+ except PydanticValidationError as e:
376
+ raise SendlyError(
377
+ message=f"Invalid API response format: {e}",
378
+ code="invalid_response",
379
+ status_code=200,
380
+ ) from e
381
+
382
+ def cancel_scheduled(self, id: str) -> CancelledMessageResponse:
383
+ """
384
+ Cancel a scheduled message
385
+
386
+ Args:
387
+ id: Message ID to cancel
388
+
389
+ Returns:
390
+ Cancellation confirmation with refunded credits
391
+ """
392
+ validate_message_id(id)
393
+
394
+ data = self._http.request(
395
+ method="DELETE",
396
+ path=f"/messages/scheduled/{quote(id, safe='')}",
397
+ )
398
+
399
+ try:
400
+ return CancelledMessageResponse(**data)
401
+ except PydanticValidationError as e:
402
+ raise SendlyError(
403
+ message=f"Invalid API response format: {e}",
404
+ code="invalid_response",
405
+ status_code=200,
406
+ ) from e
407
+
408
+ # =========================================================================
409
+ # Batch Messages
410
+ # =========================================================================
411
+
412
+ def send_batch(
413
+ self,
414
+ messages: List[Dict[str, str]],
415
+ from_: Optional[str] = None,
416
+ message_type: Optional[str] = None,
417
+ **kwargs: Any,
418
+ ) -> BatchMessageResponse:
419
+ """
420
+ Send multiple SMS messages in a single batch
421
+
422
+ Args:
423
+ messages: List of dicts with 'to' and 'text' keys (max 1000)
424
+ from_: Optional sender ID (for international destinations only)
425
+ message_type: Message type for compliance - 'marketing' (default, subject to quiet hours) or 'transactional' (24/7)
426
+
427
+ Returns:
428
+ Batch response with individual message results
429
+
430
+ Example:
431
+ >>> batch = client.messages.send_batch(
432
+ ... messages=[
433
+ ... {'to': '+15551234567', 'text': 'Hello User 1!'},
434
+ ... {'to': '+15559876543', 'text': 'Hello User 2!'}
435
+ ... ]
436
+ ... )
437
+ >>> print(batch.batch_id)
438
+ >>> print(batch.queued)
439
+ """
440
+ if not messages or not isinstance(messages, list):
441
+ raise SendlyError(
442
+ message="messages must be a non-empty list",
443
+ code="invalid_request",
444
+ status_code=400,
445
+ )
446
+
447
+ if len(messages) > 1000:
448
+ raise SendlyError(
449
+ message="Maximum 1000 messages per batch",
450
+ code="invalid_request",
451
+ status_code=400,
452
+ )
453
+
454
+ for msg in messages:
455
+ validate_phone_number(msg.get("to", ""))
456
+ validate_message_text(msg.get("text", ""))
457
+
458
+ if from_:
459
+ validate_sender_id(from_)
460
+
461
+ body: Dict[str, Any] = {"messages": messages}
462
+ if from_:
463
+ body["from"] = from_
464
+ if message_type:
465
+ body["messageType"] = message_type
466
+
467
+ data = self._http.request(
468
+ method="POST",
469
+ path="/messages/batch",
470
+ body=body,
471
+ )
472
+
473
+ try:
474
+ return BatchMessageResponse(**data)
475
+ except PydanticValidationError as e:
476
+ raise SendlyError(
477
+ message=f"Invalid API response format: {e}",
478
+ code="invalid_response",
479
+ status_code=200,
480
+ ) from e
481
+
482
+ def get_batch(self, batch_id: str) -> BatchMessageResponse:
483
+ """
484
+ Get batch status and results
485
+
486
+ Args:
487
+ batch_id: Batch ID
488
+
489
+ Returns:
490
+ Batch details with message results
491
+ """
492
+ if not batch_id or not batch_id.startswith("batch_"):
493
+ raise SendlyError(
494
+ message="Invalid batch ID format",
495
+ code="invalid_request",
496
+ status_code=400,
497
+ )
498
+
499
+ data = self._http.request(
500
+ method="GET",
501
+ path=f"/messages/batch/{quote(batch_id, safe='')}",
502
+ )
503
+
504
+ try:
505
+ return BatchMessageResponse(**data)
506
+ except PydanticValidationError as e:
507
+ raise SendlyError(
508
+ message=f"Invalid API response format: {e}",
509
+ code="invalid_response",
510
+ status_code=200,
511
+ ) from e
512
+
513
+ def list_batches(
514
+ self,
515
+ limit: Optional[int] = None,
516
+ offset: Optional[int] = None,
517
+ status: Optional[str] = None,
518
+ **kwargs: Any,
519
+ ) -> BatchListResponse:
520
+ """
521
+ List message batches
522
+
523
+ Args:
524
+ limit: Maximum number of batches to return (1-100)
525
+ offset: Number of batches to skip
526
+ status: Filter by status
527
+
528
+ Returns:
529
+ Paginated list of batches
530
+ """
531
+ validate_limit(limit)
532
+
533
+ params: Dict[str, Any] = {}
534
+ if limit is not None:
535
+ params["limit"] = limit
536
+ if offset is not None:
537
+ params["offset"] = offset
538
+ if status is not None:
539
+ params["status"] = status
540
+
541
+ data = self._http.request(
542
+ method="GET",
543
+ path="/messages/batches",
544
+ params=params if params else None,
545
+ )
546
+
547
+ try:
548
+ return BatchListResponse(**data)
549
+ except PydanticValidationError as e:
550
+ raise SendlyError(
551
+ message=f"Invalid API response format: {e}",
552
+ code="invalid_response",
553
+ status_code=200,
554
+ ) from e
555
+
556
+ def preview_batch(
557
+ self,
558
+ messages: List[Dict[str, str]],
559
+ from_: Optional[str] = None,
560
+ message_type: Optional[str] = None,
561
+ **kwargs: Any,
562
+ ) -> Dict[str, Any]:
563
+ """
564
+ Preview a batch without sending (dry run)
565
+
566
+ Args:
567
+ messages: List of dicts with 'to' and 'text' keys (max 1000)
568
+ from_: Optional sender ID (for international destinations only)
569
+ message_type: Message type: 'marketing' (default) or 'transactional'
570
+
571
+ Returns:
572
+ Preview showing what would happen if batch was sent
573
+
574
+ Example:
575
+ >>> preview = client.messages.preview_batch(
576
+ ... messages=[
577
+ ... {'to': '+15551234567', 'text': 'Hello User 1!'},
578
+ ... {'to': '+15559876543', 'text': 'Hello User 2!'}
579
+ ... ]
580
+ ... )
581
+ >>> print(preview['canSend'])
582
+ >>> print(preview['creditsNeeded'])
583
+ """
584
+ if not messages or not isinstance(messages, list):
585
+ raise SendlyError(
586
+ message="messages must be a non-empty list",
587
+ code="invalid_request",
588
+ status_code=400,
589
+ )
590
+
591
+ if len(messages) > 1000:
592
+ raise SendlyError(
593
+ message="Maximum 1000 messages per batch",
594
+ code="invalid_request",
595
+ status_code=400,
596
+ )
597
+
598
+ for msg in messages:
599
+ validate_phone_number(msg.get("to", ""))
600
+ validate_message_text(msg.get("text", ""))
601
+
602
+ if from_:
603
+ validate_sender_id(from_)
604
+
605
+ body: Dict[str, Any] = {"messages": messages}
606
+ if from_:
607
+ body["from"] = from_
608
+ if message_type:
609
+ body["messageType"] = message_type
610
+
611
+ return self._http.request(
612
+ method="POST",
613
+ path="/messages/batch/preview",
614
+ body=body,
615
+ )
616
+
617
+
618
+ class AsyncMessagesResource:
619
+ """
620
+ Messages API resource (asynchronous)
621
+
622
+ Example:
623
+ >>> async with AsyncSendly('sk_live_v1_xxx') as client:
624
+ ... message = await client.messages.send(to='+15551234567', text='Hello!')
625
+ ... messages = await client.messages.list(limit=10)
626
+ """
627
+
628
+ def __init__(self, http: AsyncHttpClient):
629
+ self._http = http
630
+
631
+ async def send(
632
+ self,
633
+ to: str,
634
+ text: str,
635
+ from_: Optional[str] = None,
636
+ message_type: Optional[str] = None,
637
+ **kwargs: Any,
638
+ ) -> Message:
639
+ """
640
+ Send an SMS message (async)
641
+
642
+ Args:
643
+ to: Destination phone number in E.164 format
644
+ text: Message content
645
+ from_: Optional sender ID or phone number
646
+ message_type: Message type for compliance - 'marketing' (default, subject to quiet hours) or 'transactional' (24/7)
647
+
648
+ Returns:
649
+ The created message
650
+
651
+ Example:
652
+ >>> message = await client.messages.send(
653
+ ... to='+15551234567',
654
+ ... text='Your code is: 123456'
655
+ ... )
656
+ """
657
+ # Validate inputs
658
+ validate_phone_number(to)
659
+ validate_message_text(text)
660
+ if from_:
661
+ validate_sender_id(from_)
662
+
663
+ # Build request body
664
+ body: Dict[str, Any] = {
665
+ "to": to,
666
+ "text": text,
667
+ }
668
+ if from_:
669
+ body["from"] = from_
670
+ if message_type:
671
+ body["messageType"] = message_type
672
+
673
+ # Make API request
674
+ data = await self._http.request(
675
+ method="POST",
676
+ path="/messages",
677
+ body=body,
678
+ )
679
+
680
+ try:
681
+ return Message(**data)
682
+ except PydanticValidationError as e:
683
+ raise SendlyError(
684
+ message=f"Invalid API response format: {e}",
685
+ code="invalid_response",
686
+ status_code=200,
687
+ ) from e
688
+
689
+ async def list(
690
+ self,
691
+ limit: Optional[int] = None,
692
+ **kwargs: Any,
693
+ ) -> MessageListResponse:
694
+ """
695
+ List sent messages (async)
696
+
697
+ Args:
698
+ limit: Maximum number of messages to return (1-100)
699
+
700
+ Returns:
701
+ Paginated list of messages
702
+
703
+ Example:
704
+ >>> result = await client.messages.list(limit=10)
705
+ >>> for msg in result.data:
706
+ ... print(f'{msg.to}: {msg.status}')
707
+ """
708
+ # Validate inputs
709
+ validate_limit(limit)
710
+
711
+ # Build query params
712
+ params: Dict[str, Any] = {}
713
+ if limit is not None:
714
+ params["limit"] = limit
715
+
716
+ # Make API request
717
+ data = await self._http.request(
718
+ method="GET",
719
+ path="/messages",
720
+ params=params if params else None,
721
+ )
722
+
723
+ try:
724
+ return MessageListResponse(**data)
725
+ except PydanticValidationError as e:
726
+ raise SendlyError(
727
+ message=f"Invalid API response format: {e}",
728
+ code="invalid_response",
729
+ status_code=200,
730
+ ) from e
731
+
732
+ async def get(self, id: str) -> Message:
733
+ """
734
+ Get a specific message by ID (async)
735
+
736
+ Args:
737
+ id: Message ID
738
+
739
+ Returns:
740
+ The message details
741
+
742
+ Example:
743
+ >>> message = await client.messages.get('msg_xxx')
744
+ >>> print(message.status)
745
+ """
746
+ # Validate ID
747
+ validate_message_id(id)
748
+
749
+ # Make API request
750
+ data = await self._http.request(
751
+ method="GET",
752
+ path=f"/messages/{quote(id, safe='')}",
753
+ )
754
+
755
+ try:
756
+ return Message(**data)
757
+ except PydanticValidationError as e:
758
+ raise SendlyError(
759
+ message=f"Invalid API response format: {e}",
760
+ code="invalid_response",
761
+ status_code=200,
762
+ ) from e
763
+
764
+ async def list_all(
765
+ self,
766
+ batch_size: int = 100,
767
+ **kwargs: Any,
768
+ ):
769
+ """
770
+ Iterate through all messages with automatic pagination (async)
771
+
772
+ Args:
773
+ batch_size: Number of messages to fetch per request (max 100)
774
+
775
+ Yields:
776
+ Message objects one at a time
777
+
778
+ Raises:
779
+ AuthenticationError: If the API key is invalid
780
+ RateLimitError: If rate limit is exceeded
781
+
782
+ Example:
783
+ >>> async for message in client.messages.list_all():
784
+ ... print(f'{message.id}: {message.status}')
785
+ """
786
+ batch_size = min(batch_size, 100)
787
+ offset = 0
788
+
789
+ while True:
790
+ data = await self._http.request(
791
+ method="GET",
792
+ path="/messages",
793
+ params={"limit": batch_size, "offset": offset},
794
+ )
795
+
796
+ try:
797
+ response = MessageListResponse(**data)
798
+ except PydanticValidationError as e:
799
+ raise SendlyError(
800
+ message=f"Invalid API response format: {e}",
801
+ code="invalid_response",
802
+ status_code=200,
803
+ ) from e
804
+
805
+ for message in response.data:
806
+ yield message
807
+
808
+ if len(response.data) < batch_size:
809
+ break
810
+
811
+ offset += batch_size
812
+
813
+ # =========================================================================
814
+ # Scheduled Messages
815
+ # =========================================================================
816
+
817
+ async def schedule(
818
+ self,
819
+ to: str,
820
+ text: str,
821
+ scheduled_at: str,
822
+ from_: Optional[str] = None,
823
+ message_type: Optional[str] = None,
824
+ **kwargs: Any,
825
+ ) -> ScheduledMessage:
826
+ """
827
+ Schedule an SMS message for future delivery (async)
828
+
829
+ Args:
830
+ to: Destination phone number in E.164 format
831
+ text: Message content
832
+ scheduled_at: When to send (ISO 8601, must be > 1 minute in future)
833
+ from_: Optional sender ID (for international destinations only)
834
+ message_type: Message type for compliance - 'marketing' (default, subject to quiet hours) or 'transactional' (24/7)
835
+
836
+ Returns:
837
+ The scheduled message
838
+ """
839
+ validate_phone_number(to)
840
+ validate_message_text(text)
841
+ if from_:
842
+ validate_sender_id(from_)
843
+
844
+ body: Dict[str, Any] = {
845
+ "to": to,
846
+ "text": text,
847
+ "scheduledAt": scheduled_at,
848
+ }
849
+ if from_:
850
+ body["from"] = from_
851
+ if message_type:
852
+ body["messageType"] = message_type
853
+
854
+ data = await self._http.request(
855
+ method="POST",
856
+ path="/messages/schedule",
857
+ body=body,
858
+ )
859
+
860
+ try:
861
+ return ScheduledMessage(**data)
862
+ except PydanticValidationError as e:
863
+ raise SendlyError(
864
+ message=f"Invalid API response format: {e}",
865
+ code="invalid_response",
866
+ status_code=200,
867
+ ) from e
868
+
869
+ async def list_scheduled(
870
+ self,
871
+ limit: Optional[int] = None,
872
+ offset: Optional[int] = None,
873
+ status: Optional[str] = None,
874
+ **kwargs: Any,
875
+ ) -> ScheduledMessageListResponse:
876
+ """List scheduled messages (async)"""
877
+ validate_limit(limit)
878
+
879
+ params: Dict[str, Any] = {}
880
+ if limit is not None:
881
+ params["limit"] = limit
882
+ if offset is not None:
883
+ params["offset"] = offset
884
+ if status is not None:
885
+ params["status"] = status
886
+
887
+ data = await self._http.request(
888
+ method="GET",
889
+ path="/messages/scheduled",
890
+ params=params if params else None,
891
+ )
892
+
893
+ try:
894
+ return ScheduledMessageListResponse(**data)
895
+ except PydanticValidationError as e:
896
+ raise SendlyError(
897
+ message=f"Invalid API response format: {e}",
898
+ code="invalid_response",
899
+ status_code=200,
900
+ ) from e
901
+
902
+ async def get_scheduled(self, id: str) -> ScheduledMessage:
903
+ """Get a specific scheduled message by ID (async)"""
904
+ validate_message_id(id)
905
+
906
+ data = await self._http.request(
907
+ method="GET",
908
+ path=f"/messages/scheduled/{quote(id, safe='')}",
909
+ )
910
+
911
+ try:
912
+ return ScheduledMessage(**data)
913
+ except PydanticValidationError as e:
914
+ raise SendlyError(
915
+ message=f"Invalid API response format: {e}",
916
+ code="invalid_response",
917
+ status_code=200,
918
+ ) from e
919
+
920
+ async def cancel_scheduled(self, id: str) -> CancelledMessageResponse:
921
+ """Cancel a scheduled message (async)"""
922
+ validate_message_id(id)
923
+
924
+ data = await self._http.request(
925
+ method="DELETE",
926
+ path=f"/messages/scheduled/{quote(id, safe='')}",
927
+ )
928
+
929
+ try:
930
+ return CancelledMessageResponse(**data)
931
+ except PydanticValidationError as e:
932
+ raise SendlyError(
933
+ message=f"Invalid API response format: {e}",
934
+ code="invalid_response",
935
+ status_code=200,
936
+ ) from e
937
+
938
+ # =========================================================================
939
+ # Batch Messages
940
+ # =========================================================================
941
+
942
+ async def send_batch(
943
+ self,
944
+ messages: List[Dict[str, str]],
945
+ from_: Optional[str] = None,
946
+ message_type: Optional[str] = None,
947
+ **kwargs: Any,
948
+ ) -> BatchMessageResponse:
949
+ """Send multiple SMS messages in a single batch (async)"""
950
+ if not messages or not isinstance(messages, list):
951
+ raise SendlyError(
952
+ message="messages must be a non-empty list",
953
+ code="invalid_request",
954
+ status_code=400,
955
+ )
956
+
957
+ if len(messages) > 1000:
958
+ raise SendlyError(
959
+ message="Maximum 1000 messages per batch",
960
+ code="invalid_request",
961
+ status_code=400,
962
+ )
963
+
964
+ for msg in messages:
965
+ validate_phone_number(msg.get("to", ""))
966
+ validate_message_text(msg.get("text", ""))
967
+
968
+ if from_:
969
+ validate_sender_id(from_)
970
+
971
+ body: Dict[str, Any] = {"messages": messages}
972
+ if from_:
973
+ body["from"] = from_
974
+ if message_type:
975
+ body["messageType"] = message_type
976
+
977
+ data = await self._http.request(
978
+ method="POST",
979
+ path="/messages/batch",
980
+ body=body,
981
+ )
982
+
983
+ try:
984
+ return BatchMessageResponse(**data)
985
+ except PydanticValidationError as e:
986
+ raise SendlyError(
987
+ message=f"Invalid API response format: {e}",
988
+ code="invalid_response",
989
+ status_code=200,
990
+ ) from e
991
+
992
+ async def get_batch(self, batch_id: str) -> BatchMessageResponse:
993
+ """Get batch status and results (async)"""
994
+ if not batch_id or not batch_id.startswith("batch_"):
995
+ raise SendlyError(
996
+ message="Invalid batch ID format",
997
+ code="invalid_request",
998
+ status_code=400,
999
+ )
1000
+
1001
+ data = await self._http.request(
1002
+ method="GET",
1003
+ path=f"/messages/batch/{quote(batch_id, safe='')}",
1004
+ )
1005
+
1006
+ try:
1007
+ return BatchMessageResponse(**data)
1008
+ except PydanticValidationError as e:
1009
+ raise SendlyError(
1010
+ message=f"Invalid API response format: {e}",
1011
+ code="invalid_response",
1012
+ status_code=200,
1013
+ ) from e
1014
+
1015
+ async def list_batches(
1016
+ self,
1017
+ limit: Optional[int] = None,
1018
+ offset: Optional[int] = None,
1019
+ status: Optional[str] = None,
1020
+ **kwargs: Any,
1021
+ ) -> BatchListResponse:
1022
+ """List message batches (async)"""
1023
+ validate_limit(limit)
1024
+
1025
+ params: Dict[str, Any] = {}
1026
+ if limit is not None:
1027
+ params["limit"] = limit
1028
+ if offset is not None:
1029
+ params["offset"] = offset
1030
+ if status is not None:
1031
+ params["status"] = status
1032
+
1033
+ data = await self._http.request(
1034
+ method="GET",
1035
+ path="/messages/batches",
1036
+ params=params if params else None,
1037
+ )
1038
+
1039
+ try:
1040
+ return BatchListResponse(**data)
1041
+ except PydanticValidationError as e:
1042
+ raise SendlyError(
1043
+ message=f"Invalid API response format: {e}",
1044
+ code="invalid_response",
1045
+ status_code=200,
1046
+ ) from e
1047
+
1048
+ async def preview_batch(
1049
+ self,
1050
+ messages: List[Dict[str, str]],
1051
+ from_: Optional[str] = None,
1052
+ message_type: Optional[str] = None,
1053
+ **kwargs: Any,
1054
+ ) -> Dict[str, Any]:
1055
+ """Preview a batch without sending (dry run) (async)"""
1056
+ if not messages or not isinstance(messages, list):
1057
+ raise SendlyError(
1058
+ message="messages must be a non-empty list",
1059
+ code="invalid_request",
1060
+ status_code=400,
1061
+ )
1062
+
1063
+ if len(messages) > 1000:
1064
+ raise SendlyError(
1065
+ message="Maximum 1000 messages per batch",
1066
+ code="invalid_request",
1067
+ status_code=400,
1068
+ )
1069
+
1070
+ for msg in messages:
1071
+ validate_phone_number(msg.get("to", ""))
1072
+ validate_message_text(msg.get("text", ""))
1073
+
1074
+ if from_:
1075
+ validate_sender_id(from_)
1076
+
1077
+ body: Dict[str, Any] = {"messages": messages}
1078
+ if from_:
1079
+ body["from"] = from_
1080
+ if message_type:
1081
+ body["messageType"] = message_type
1082
+
1083
+ return await self._http.request(
1084
+ method="POST",
1085
+ path="/messages/batch/preview",
1086
+ body=body,
1087
+ )