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.
- sendly/__init__.py +165 -0
- sendly/client.py +248 -0
- sendly/errors.py +169 -0
- sendly/resources/__init__.py +5 -0
- sendly/resources/account.py +264 -0
- sendly/resources/messages.py +1087 -0
- sendly/resources/webhooks.py +435 -0
- sendly/types.py +748 -0
- sendly/utils/__init__.py +26 -0
- sendly/utils/http.py +358 -0
- sendly/utils/validation.py +248 -0
- sendly/webhooks.py +245 -0
- sendly-3.8.1.dist-info/METADATA +589 -0
- sendly-3.8.1.dist-info/RECORD +15 -0
- sendly-3.8.1.dist-info/WHEEL +4 -0
|
@@ -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
|
+
)
|