@sendly/node 1.0.0

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.
package/dist/index.mjs ADDED
@@ -0,0 +1,1121 @@
1
+ // src/errors.ts
2
+ var SendlyError = class _SendlyError extends Error {
3
+ /**
4
+ * Machine-readable error code
5
+ */
6
+ code;
7
+ /**
8
+ * HTTP status code (if applicable)
9
+ */
10
+ statusCode;
11
+ /**
12
+ * Raw API response (if applicable)
13
+ */
14
+ response;
15
+ constructor(message, code, statusCode, response) {
16
+ super(message);
17
+ this.name = "SendlyError";
18
+ this.code = code;
19
+ this.statusCode = statusCode;
20
+ this.response = response;
21
+ if (Error.captureStackTrace) {
22
+ Error.captureStackTrace(this, this.constructor);
23
+ }
24
+ }
25
+ /**
26
+ * Create a SendlyError from an API response
27
+ */
28
+ static fromResponse(statusCode, response) {
29
+ const message = response.message || "An unknown error occurred";
30
+ const code = response.error || "internal_error";
31
+ switch (code) {
32
+ case "unauthorized":
33
+ case "invalid_auth_format":
34
+ case "invalid_key_format":
35
+ case "invalid_api_key":
36
+ case "key_revoked":
37
+ case "key_expired":
38
+ case "insufficient_permissions":
39
+ return new AuthenticationError(message, code, statusCode, response);
40
+ case "rate_limit_exceeded":
41
+ return new RateLimitError(
42
+ message,
43
+ response.retryAfter || 60,
44
+ statusCode,
45
+ response
46
+ );
47
+ case "insufficient_credits":
48
+ return new InsufficientCreditsError(
49
+ message,
50
+ response.creditsNeeded || 0,
51
+ response.currentBalance || 0,
52
+ statusCode,
53
+ response
54
+ );
55
+ case "invalid_request":
56
+ case "unsupported_destination":
57
+ return new ValidationError(message, code, statusCode, response);
58
+ case "not_found":
59
+ return new NotFoundError(message, statusCode, response);
60
+ default:
61
+ return new _SendlyError(message, code, statusCode, response);
62
+ }
63
+ }
64
+ };
65
+ var AuthenticationError = class extends SendlyError {
66
+ constructor(message, code = "unauthorized", statusCode, response) {
67
+ super(message, code, statusCode, response);
68
+ this.name = "AuthenticationError";
69
+ }
70
+ };
71
+ var RateLimitError = class extends SendlyError {
72
+ /**
73
+ * Seconds to wait before retrying
74
+ */
75
+ retryAfter;
76
+ constructor(message, retryAfter, statusCode, response) {
77
+ super(message, "rate_limit_exceeded", statusCode, response);
78
+ this.name = "RateLimitError";
79
+ this.retryAfter = retryAfter;
80
+ }
81
+ };
82
+ var InsufficientCreditsError = class extends SendlyError {
83
+ /**
84
+ * Credits needed for the operation
85
+ */
86
+ creditsNeeded;
87
+ /**
88
+ * Current credit balance
89
+ */
90
+ currentBalance;
91
+ constructor(message, creditsNeeded, currentBalance, statusCode, response) {
92
+ super(message, "insufficient_credits", statusCode, response);
93
+ this.name = "InsufficientCreditsError";
94
+ this.creditsNeeded = creditsNeeded;
95
+ this.currentBalance = currentBalance;
96
+ }
97
+ };
98
+ var ValidationError = class extends SendlyError {
99
+ constructor(message, code = "invalid_request", statusCode, response) {
100
+ super(message, code, statusCode, response);
101
+ this.name = "ValidationError";
102
+ }
103
+ };
104
+ var NotFoundError = class extends SendlyError {
105
+ constructor(message, statusCode, response) {
106
+ super(message, "not_found", statusCode, response);
107
+ this.name = "NotFoundError";
108
+ }
109
+ };
110
+ var NetworkError = class extends SendlyError {
111
+ constructor(message, cause) {
112
+ super(message, "internal_error");
113
+ this.name = "NetworkError";
114
+ this.cause = cause;
115
+ }
116
+ };
117
+ var TimeoutError = class extends SendlyError {
118
+ constructor(message = "Request timed out") {
119
+ super(message, "internal_error");
120
+ this.name = "TimeoutError";
121
+ }
122
+ };
123
+
124
+ // src/utils/http.ts
125
+ var DEFAULT_BASE_URL = "https://sendly.live/api/";
126
+ var DEFAULT_TIMEOUT = 3e4;
127
+ var DEFAULT_MAX_RETRIES = 3;
128
+ var HttpClient = class {
129
+ config;
130
+ rateLimitInfo;
131
+ constructor(config) {
132
+ this.config = {
133
+ apiKey: config.apiKey,
134
+ baseUrl: config.baseUrl || DEFAULT_BASE_URL,
135
+ timeout: config.timeout || DEFAULT_TIMEOUT,
136
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
137
+ };
138
+ if (!this.isValidApiKey(this.config.apiKey)) {
139
+ throw new Error(
140
+ "Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx"
141
+ );
142
+ }
143
+ const baseUrl = new URL(this.config.baseUrl);
144
+ if (baseUrl.protocol !== "https:" && !baseUrl.hostname.includes("localhost") && baseUrl.hostname !== "127.0.0.1") {
145
+ throw new Error(
146
+ "API key must only be transmitted over HTTPS. Use https:// or localhost for development."
147
+ );
148
+ }
149
+ }
150
+ /**
151
+ * Validate API key format
152
+ */
153
+ isValidApiKey(key) {
154
+ return /^sk_(test|live)_v1_[a-zA-Z0-9_-]+$/.test(key);
155
+ }
156
+ /**
157
+ * Get current rate limit info
158
+ */
159
+ getRateLimitInfo() {
160
+ return this.rateLimitInfo;
161
+ }
162
+ /**
163
+ * Check if we're using a test key
164
+ */
165
+ isTestMode() {
166
+ return this.config.apiKey.startsWith("sk_test_");
167
+ }
168
+ /**
169
+ * Make an HTTP request to the API
170
+ */
171
+ async request(options) {
172
+ const url = this.buildUrl(options.path, options.query);
173
+ const headers = this.buildHeaders(options.headers);
174
+ let lastError;
175
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
176
+ try {
177
+ const response = await this.executeRequest(url, {
178
+ method: options.method,
179
+ headers,
180
+ body: options.body ? JSON.stringify(options.body) : void 0
181
+ });
182
+ this.updateRateLimitInfo(response.headers);
183
+ const data = await this.parseResponse(response);
184
+ return data;
185
+ } catch (error) {
186
+ lastError = error;
187
+ if (error instanceof SendlyError) {
188
+ if (error.statusCode === 401 || error.statusCode === 403) {
189
+ throw error;
190
+ }
191
+ if (error.statusCode === 400 || error.statusCode === 404) {
192
+ throw error;
193
+ }
194
+ if (error.statusCode === 402) {
195
+ throw error;
196
+ }
197
+ if (error instanceof RateLimitError) {
198
+ throw error;
199
+ }
200
+ }
201
+ if (attempt < this.config.maxRetries) {
202
+ const backoffTime = this.calculateBackoff(attempt);
203
+ await this.sleep(backoffTime);
204
+ continue;
205
+ }
206
+ }
207
+ }
208
+ throw lastError || new NetworkError("Request failed after retries");
209
+ }
210
+ /**
211
+ * Execute the HTTP request
212
+ */
213
+ async executeRequest(url, init) {
214
+ const controller = new AbortController();
215
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
216
+ try {
217
+ const response = await fetch(url, {
218
+ ...init,
219
+ signal: controller.signal
220
+ });
221
+ return response;
222
+ } catch (error) {
223
+ if (error.name === "AbortError") {
224
+ throw new TimeoutError(
225
+ `Request timed out after ${this.config.timeout}ms`
226
+ );
227
+ }
228
+ throw new NetworkError(
229
+ `Network request failed: ${error.message}`,
230
+ error
231
+ );
232
+ } finally {
233
+ clearTimeout(timeoutId);
234
+ }
235
+ }
236
+ /**
237
+ * Parse the response body
238
+ */
239
+ async parseResponse(response) {
240
+ const contentType = response.headers.get("content-type");
241
+ let data;
242
+ if (contentType?.includes("application/json")) {
243
+ data = await response.json();
244
+ } else {
245
+ data = await response.text();
246
+ }
247
+ if (!response.ok) {
248
+ const errorResponse = data;
249
+ throw SendlyError.fromResponse(response.status, {
250
+ ...errorResponse,
251
+ error: errorResponse?.error || "internal_error",
252
+ message: errorResponse?.message || `HTTP ${response.status}`
253
+ });
254
+ }
255
+ return data;
256
+ }
257
+ /**
258
+ * Build the full URL with query parameters
259
+ */
260
+ buildUrl(path, query) {
261
+ const base = this.config.baseUrl.replace(/\/$/, "");
262
+ const cleanPath = path.startsWith("/") ? path.slice(1) : path;
263
+ const fullUrl = `${base}/${cleanPath}`;
264
+ const url = new URL(fullUrl);
265
+ if (query) {
266
+ Object.entries(query).forEach(([key, value]) => {
267
+ if (value !== void 0) {
268
+ url.searchParams.append(key, String(value));
269
+ }
270
+ });
271
+ }
272
+ return url.toString();
273
+ }
274
+ /**
275
+ * Build request headers
276
+ */
277
+ buildHeaders(additionalHeaders) {
278
+ return {
279
+ Authorization: `Bearer ${this.config.apiKey}`,
280
+ "Content-Type": "application/json",
281
+ Accept: "application/json",
282
+ "User-Agent": "@sendly/node/1.0.0",
283
+ ...additionalHeaders
284
+ };
285
+ }
286
+ /**
287
+ * Update rate limit info from response headers
288
+ */
289
+ updateRateLimitInfo(headers) {
290
+ const limit = headers.get("X-RateLimit-Limit");
291
+ const remaining = headers.get("X-RateLimit-Remaining");
292
+ const reset = headers.get("X-RateLimit-Reset");
293
+ if (limit && remaining && reset) {
294
+ this.rateLimitInfo = {
295
+ limit: parseInt(limit, 10),
296
+ remaining: parseInt(remaining, 10),
297
+ reset: parseInt(reset, 10)
298
+ };
299
+ }
300
+ }
301
+ /**
302
+ * Calculate exponential backoff time
303
+ */
304
+ calculateBackoff(attempt) {
305
+ const baseDelay = Math.pow(2, attempt) * 1e3;
306
+ const jitter = Math.random() * 500;
307
+ return Math.min(baseDelay + jitter, 3e4);
308
+ }
309
+ /**
310
+ * Sleep for a given number of milliseconds
311
+ */
312
+ sleep(ms) {
313
+ return new Promise((resolve) => setTimeout(resolve, ms));
314
+ }
315
+ };
316
+
317
+ // src/types.ts
318
+ var CREDITS_PER_SMS = {
319
+ domestic: 1,
320
+ tier1: 8,
321
+ tier2: 12,
322
+ tier3: 16
323
+ };
324
+ var SUPPORTED_COUNTRIES = {
325
+ domestic: ["US", "CA"],
326
+ tier1: [
327
+ "GB",
328
+ "PL",
329
+ "PT",
330
+ "RO",
331
+ "CZ",
332
+ "HU",
333
+ "CN",
334
+ "KR",
335
+ "IN",
336
+ "PH",
337
+ "TH",
338
+ "VN"
339
+ ],
340
+ tier2: [
341
+ "FR",
342
+ "ES",
343
+ "SE",
344
+ "NO",
345
+ "DK",
346
+ "FI",
347
+ "IE",
348
+ "JP",
349
+ "AU",
350
+ "NZ",
351
+ "SG",
352
+ "HK",
353
+ "MY",
354
+ "ID",
355
+ "BR",
356
+ "AR",
357
+ "CL",
358
+ "CO",
359
+ "ZA",
360
+ "GR"
361
+ ],
362
+ tier3: [
363
+ "DE",
364
+ "IT",
365
+ "NL",
366
+ "BE",
367
+ "AT",
368
+ "CH",
369
+ "MX",
370
+ "IL",
371
+ "AE",
372
+ "SA",
373
+ "EG",
374
+ "NG",
375
+ "KE",
376
+ "TW",
377
+ "PK",
378
+ "TR"
379
+ ]
380
+ };
381
+ var ALL_SUPPORTED_COUNTRIES = Object.values(SUPPORTED_COUNTRIES).flat();
382
+ var SANDBOX_TEST_NUMBERS = {
383
+ /** Always succeeds instantly */
384
+ SUCCESS: "+15550001234",
385
+ /** Succeeds after 10 second delay */
386
+ DELAYED: "+15550001010",
387
+ /** Fails with invalid_number error */
388
+ INVALID: "+15550001001",
389
+ /** Fails with carrier_rejected error after 2 seconds */
390
+ REJECTED: "+15550001002",
391
+ /** Fails with rate_limit_exceeded error */
392
+ RATE_LIMITED: "+15550001003"
393
+ };
394
+
395
+ // src/utils/validation.ts
396
+ function validatePhoneNumber(phone) {
397
+ const e164Regex = /^\+[1-9]\d{1,14}$/;
398
+ if (!phone) {
399
+ throw new ValidationError("Phone number is required");
400
+ }
401
+ if (!e164Regex.test(phone)) {
402
+ throw new ValidationError(
403
+ `Invalid phone number format: ${phone}. Expected E.164 format (e.g., +15551234567)`
404
+ );
405
+ }
406
+ }
407
+ function validateMessageText(text) {
408
+ if (!text) {
409
+ throw new ValidationError("Message text is required");
410
+ }
411
+ if (typeof text !== "string") {
412
+ throw new ValidationError("Message text must be a string");
413
+ }
414
+ if (text.length > 1600) {
415
+ console.warn(
416
+ `Message is ${text.length} characters. This will be split into ${Math.ceil(text.length / 160)} segments.`
417
+ );
418
+ }
419
+ }
420
+ function validateSenderId(from) {
421
+ if (!from) {
422
+ return;
423
+ }
424
+ if (from.startsWith("+")) {
425
+ validatePhoneNumber(from);
426
+ return;
427
+ }
428
+ const alphanumericRegex = /^[a-zA-Z0-9]{2,11}$/;
429
+ if (!alphanumericRegex.test(from)) {
430
+ throw new ValidationError(
431
+ `Invalid sender ID: ${from}. Must be 2-11 alphanumeric characters or a valid phone number.`
432
+ );
433
+ }
434
+ }
435
+ function validateLimit(limit) {
436
+ if (limit === void 0) {
437
+ return;
438
+ }
439
+ if (typeof limit !== "number" || !Number.isInteger(limit)) {
440
+ throw new ValidationError("Limit must be an integer");
441
+ }
442
+ if (limit < 1 || limit > 100) {
443
+ throw new ValidationError("Limit must be between 1 and 100");
444
+ }
445
+ }
446
+ function validateMessageId(id) {
447
+ if (!id) {
448
+ throw new ValidationError("Message ID is required");
449
+ }
450
+ if (typeof id !== "string") {
451
+ throw new ValidationError("Message ID must be a string");
452
+ }
453
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
454
+ const prefixedRegex = /^(msg|schd|batch)_[a-zA-Z0-9]+$/;
455
+ if (!uuidRegex.test(id) && !prefixedRegex.test(id)) {
456
+ throw new ValidationError(`Invalid message ID format: ${id}`);
457
+ }
458
+ }
459
+ function getCountryFromPhone(phone) {
460
+ const digits = phone.replace(/^\+/, "");
461
+ if (digits.startsWith("1") && digits.length === 11) {
462
+ return "US";
463
+ }
464
+ const countryPrefixes = {
465
+ "44": "GB",
466
+ "48": "PL",
467
+ "351": "PT",
468
+ "40": "RO",
469
+ "420": "CZ",
470
+ "36": "HU",
471
+ "86": "CN",
472
+ "82": "KR",
473
+ "91": "IN",
474
+ "63": "PH",
475
+ "66": "TH",
476
+ "84": "VN",
477
+ "33": "FR",
478
+ "34": "ES",
479
+ "46": "SE",
480
+ "47": "NO",
481
+ "45": "DK",
482
+ "358": "FI",
483
+ "353": "IE",
484
+ "81": "JP",
485
+ "61": "AU",
486
+ "64": "NZ",
487
+ "65": "SG",
488
+ "852": "HK",
489
+ "60": "MY",
490
+ "62": "ID",
491
+ "55": "BR",
492
+ "54": "AR",
493
+ "56": "CL",
494
+ "57": "CO",
495
+ "27": "ZA",
496
+ "30": "GR",
497
+ "49": "DE",
498
+ "39": "IT",
499
+ "31": "NL",
500
+ "32": "BE",
501
+ "43": "AT",
502
+ "41": "CH",
503
+ "52": "MX",
504
+ "972": "IL",
505
+ "971": "AE",
506
+ "966": "SA",
507
+ "20": "EG",
508
+ "234": "NG",
509
+ "254": "KE",
510
+ "886": "TW",
511
+ "92": "PK",
512
+ "90": "TR"
513
+ };
514
+ const sortedPrefixes = Object.keys(countryPrefixes).sort(
515
+ (a, b) => b.length - a.length
516
+ );
517
+ for (const prefix of sortedPrefixes) {
518
+ if (digits.startsWith(prefix)) {
519
+ return countryPrefixes[prefix];
520
+ }
521
+ }
522
+ return null;
523
+ }
524
+ function isCountrySupported(countryCode) {
525
+ return ALL_SUPPORTED_COUNTRIES.includes(countryCode.toUpperCase());
526
+ }
527
+ function calculateSegments(text) {
528
+ const isUnicode = /[^\x00-\x7F]/.test(text);
529
+ const singleLimit = isUnicode ? 70 : 160;
530
+ const multiLimit = isUnicode ? 67 : 153;
531
+ if (text.length <= singleLimit) {
532
+ return 1;
533
+ }
534
+ return Math.ceil(text.length / multiLimit);
535
+ }
536
+
537
+ // src/resources/messages.ts
538
+ var MessagesResource = class {
539
+ http;
540
+ constructor(http) {
541
+ this.http = http;
542
+ }
543
+ /**
544
+ * Send an SMS message
545
+ *
546
+ * @param request - Message details
547
+ * @returns The created message
548
+ *
549
+ * @example
550
+ * ```typescript
551
+ * const message = await sendly.messages.send({
552
+ * to: '+15551234567',
553
+ * text: 'Your verification code is: 123456'
554
+ * });
555
+ *
556
+ * console.log(message.id); // msg_xxx
557
+ * console.log(message.status); // 'queued'
558
+ * console.log(message.segments); // 1
559
+ * ```
560
+ *
561
+ * @throws {ValidationError} If the request is invalid
562
+ * @throws {InsufficientCreditsError} If credit balance is too low
563
+ * @throws {AuthenticationError} If the API key is invalid
564
+ * @throws {RateLimitError} If rate limit is exceeded
565
+ */
566
+ async send(request) {
567
+ validatePhoneNumber(request.to);
568
+ validateMessageText(request.text);
569
+ if (request.from) {
570
+ validateSenderId(request.from);
571
+ }
572
+ const message = await this.http.request({
573
+ method: "POST",
574
+ path: "/v1/messages",
575
+ body: {
576
+ to: request.to,
577
+ text: request.text,
578
+ ...request.from && { from: request.from }
579
+ }
580
+ });
581
+ return message;
582
+ }
583
+ /**
584
+ * List sent messages
585
+ *
586
+ * @param options - List options
587
+ * @returns Paginated list of messages
588
+ *
589
+ * @example
590
+ * ```typescript
591
+ * // Get last 50 messages (default)
592
+ * const { data: messages, count } = await sendly.messages.list();
593
+ *
594
+ * // Get last 10 messages
595
+ * const { data: messages } = await sendly.messages.list({ limit: 10 });
596
+ *
597
+ * // Iterate through messages
598
+ * for (const msg of messages) {
599
+ * console.log(`${msg.to}: ${msg.status}`);
600
+ * }
601
+ * ```
602
+ *
603
+ * @throws {AuthenticationError} If the API key is invalid
604
+ * @throws {RateLimitError} If rate limit is exceeded
605
+ */
606
+ async list(options = {}) {
607
+ validateLimit(options.limit);
608
+ const response = await this.http.request({
609
+ method: "GET",
610
+ path: "/v1/messages",
611
+ query: {
612
+ limit: options.limit,
613
+ offset: options.offset,
614
+ status: options.status
615
+ }
616
+ });
617
+ return response;
618
+ }
619
+ /**
620
+ * Get a specific message by ID
621
+ *
622
+ * @param id - Message ID
623
+ * @returns The message details
624
+ *
625
+ * @example
626
+ * ```typescript
627
+ * const message = await sendly.messages.get('msg_xxx');
628
+ *
629
+ * console.log(message.status); // 'delivered'
630
+ * console.log(message.deliveredAt); // '2025-01-15T10:30:00Z'
631
+ * ```
632
+ *
633
+ * @throws {NotFoundError} If the message doesn't exist
634
+ * @throws {AuthenticationError} If the API key is invalid
635
+ * @throws {RateLimitError} If rate limit is exceeded
636
+ */
637
+ async get(id) {
638
+ validateMessageId(id);
639
+ const message = await this.http.request({
640
+ method: "GET",
641
+ path: `/v1/messages/${encodeURIComponent(id)}`
642
+ });
643
+ return message;
644
+ }
645
+ /**
646
+ * Iterate through all messages with automatic pagination
647
+ *
648
+ * @param options - List options (limit is used as batch size)
649
+ * @yields Message objects one at a time
650
+ *
651
+ * @example
652
+ * ```typescript
653
+ * // Iterate through all messages
654
+ * for await (const message of sendly.messages.listAll()) {
655
+ * console.log(`${message.id}: ${message.status}`);
656
+ * }
657
+ *
658
+ * // With custom batch size
659
+ * for await (const message of sendly.messages.listAll({ limit: 100 })) {
660
+ * console.log(message.to);
661
+ * }
662
+ * ```
663
+ *
664
+ * @throws {AuthenticationError} If the API key is invalid
665
+ * @throws {RateLimitError} If rate limit is exceeded
666
+ */
667
+ async *listAll(options = {}) {
668
+ const batchSize = Math.min(options.limit || 100, 100);
669
+ let offset = 0;
670
+ let hasMore = true;
671
+ while (hasMore) {
672
+ const response = await this.http.request({
673
+ method: "GET",
674
+ path: "/v1/messages",
675
+ query: {
676
+ limit: batchSize,
677
+ offset
678
+ }
679
+ });
680
+ for (const message of response.data) {
681
+ yield message;
682
+ }
683
+ if (response.data.length < batchSize) {
684
+ hasMore = false;
685
+ } else {
686
+ offset += batchSize;
687
+ }
688
+ }
689
+ }
690
+ // ==========================================================================
691
+ // Scheduled Messages
692
+ // ==========================================================================
693
+ /**
694
+ * Schedule an SMS message for future delivery
695
+ *
696
+ * @param request - Schedule request details
697
+ * @returns The scheduled message
698
+ *
699
+ * @example
700
+ * ```typescript
701
+ * const scheduled = await sendly.messages.schedule({
702
+ * to: '+15551234567',
703
+ * text: 'Your appointment reminder!',
704
+ * scheduledAt: '2025-01-20T10:00:00Z'
705
+ * });
706
+ *
707
+ * console.log(scheduled.id); // msg_xxx
708
+ * console.log(scheduled.status); // 'scheduled'
709
+ * console.log(scheduled.scheduledAt); // '2025-01-20T10:00:00Z'
710
+ * ```
711
+ *
712
+ * @throws {ValidationError} If the request is invalid
713
+ * @throws {InsufficientCreditsError} If credit balance is too low
714
+ * @throws {AuthenticationError} If the API key is invalid
715
+ */
716
+ async schedule(request) {
717
+ validatePhoneNumber(request.to);
718
+ validateMessageText(request.text);
719
+ if (request.from) {
720
+ validateSenderId(request.from);
721
+ }
722
+ const scheduledTime = new Date(request.scheduledAt);
723
+ const now = /* @__PURE__ */ new Date();
724
+ const oneMinuteFromNow = new Date(now.getTime() + 60 * 1e3);
725
+ if (isNaN(scheduledTime.getTime())) {
726
+ throw new Error("Invalid scheduledAt format. Use ISO 8601 format.");
727
+ }
728
+ if (scheduledTime <= oneMinuteFromNow) {
729
+ throw new Error("scheduledAt must be at least 1 minute in the future.");
730
+ }
731
+ const scheduled = await this.http.request({
732
+ method: "POST",
733
+ path: "/v1/messages/schedule",
734
+ body: {
735
+ to: request.to,
736
+ text: request.text,
737
+ scheduledAt: request.scheduledAt,
738
+ ...request.from && { from: request.from }
739
+ }
740
+ });
741
+ return scheduled;
742
+ }
743
+ /**
744
+ * List scheduled messages
745
+ *
746
+ * @param options - List options
747
+ * @returns Paginated list of scheduled messages
748
+ *
749
+ * @example
750
+ * ```typescript
751
+ * const { data: scheduled } = await sendly.messages.listScheduled();
752
+ *
753
+ * for (const msg of scheduled) {
754
+ * console.log(`${msg.to}: ${msg.scheduledAt}`);
755
+ * }
756
+ * ```
757
+ */
758
+ async listScheduled(options = {}) {
759
+ validateLimit(options.limit);
760
+ const response = await this.http.request({
761
+ method: "GET",
762
+ path: "/v1/messages/scheduled",
763
+ query: {
764
+ limit: options.limit,
765
+ offset: options.offset,
766
+ status: options.status
767
+ }
768
+ });
769
+ return response;
770
+ }
771
+ /**
772
+ * Get a specific scheduled message by ID
773
+ *
774
+ * @param id - Message ID
775
+ * @returns The scheduled message details
776
+ *
777
+ * @example
778
+ * ```typescript
779
+ * const scheduled = await sendly.messages.getScheduled('msg_xxx');
780
+ * console.log(scheduled.scheduledAt);
781
+ * ```
782
+ */
783
+ async getScheduled(id) {
784
+ validateMessageId(id);
785
+ const scheduled = await this.http.request({
786
+ method: "GET",
787
+ path: `/v1/messages/scheduled/${encodeURIComponent(id)}`
788
+ });
789
+ return scheduled;
790
+ }
791
+ /**
792
+ * Cancel a scheduled message
793
+ *
794
+ * @param id - Message ID to cancel
795
+ * @returns Cancellation confirmation with refunded credits
796
+ *
797
+ * @example
798
+ * ```typescript
799
+ * const result = await sendly.messages.cancelScheduled('msg_xxx');
800
+ *
801
+ * console.log(result.status); // 'cancelled'
802
+ * console.log(result.creditsRefunded); // 1
803
+ * ```
804
+ *
805
+ * @throws {NotFoundError} If the message doesn't exist
806
+ * @throws {ValidationError} If the message is not cancellable
807
+ */
808
+ async cancelScheduled(id) {
809
+ validateMessageId(id);
810
+ const result = await this.http.request({
811
+ method: "DELETE",
812
+ path: `/v1/messages/scheduled/${encodeURIComponent(id)}`
813
+ });
814
+ return result;
815
+ }
816
+ // ==========================================================================
817
+ // Batch Messages
818
+ // ==========================================================================
819
+ /**
820
+ * Send multiple SMS messages in a single batch
821
+ *
822
+ * @param request - Batch request with array of messages
823
+ * @returns Batch response with individual message results
824
+ *
825
+ * @example
826
+ * ```typescript
827
+ * const batch = await sendly.messages.sendBatch({
828
+ * messages: [
829
+ * { to: '+15551234567', text: 'Hello User 1!' },
830
+ * { to: '+15559876543', text: 'Hello User 2!' }
831
+ * ]
832
+ * });
833
+ *
834
+ * console.log(batch.batchId); // batch_xxx
835
+ * console.log(batch.queued); // 2
836
+ * console.log(batch.creditsUsed); // 2
837
+ * ```
838
+ *
839
+ * @throws {ValidationError} If any message is invalid
840
+ * @throws {InsufficientCreditsError} If credit balance is too low
841
+ */
842
+ async sendBatch(request) {
843
+ if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
844
+ throw new Error("messages must be a non-empty array");
845
+ }
846
+ if (request.messages.length > 1e3) {
847
+ throw new Error("Maximum 1000 messages per batch");
848
+ }
849
+ for (const msg of request.messages) {
850
+ validatePhoneNumber(msg.to);
851
+ validateMessageText(msg.text);
852
+ }
853
+ if (request.from) {
854
+ validateSenderId(request.from);
855
+ }
856
+ const batch = await this.http.request({
857
+ method: "POST",
858
+ path: "/v1/messages/batch",
859
+ body: {
860
+ messages: request.messages,
861
+ ...request.from && { from: request.from }
862
+ }
863
+ });
864
+ return batch;
865
+ }
866
+ /**
867
+ * Get batch status and results
868
+ *
869
+ * @param batchId - Batch ID
870
+ * @returns Batch details with message results
871
+ *
872
+ * @example
873
+ * ```typescript
874
+ * const batch = await sendly.messages.getBatch('batch_xxx');
875
+ *
876
+ * console.log(batch.status); // 'completed'
877
+ * console.log(batch.sent); // 2
878
+ * console.log(batch.failed); // 0
879
+ * ```
880
+ */
881
+ async getBatch(batchId) {
882
+ if (!batchId || !batchId.startsWith("batch_")) {
883
+ throw new Error("Invalid batch ID format");
884
+ }
885
+ const batch = await this.http.request({
886
+ method: "GET",
887
+ path: `/v1/messages/batch/${encodeURIComponent(batchId)}`
888
+ });
889
+ return batch;
890
+ }
891
+ /**
892
+ * List message batches
893
+ *
894
+ * @param options - List options
895
+ * @returns Paginated list of batches
896
+ *
897
+ * @example
898
+ * ```typescript
899
+ * const { data: batches } = await sendly.messages.listBatches();
900
+ *
901
+ * for (const batch of batches) {
902
+ * console.log(`${batch.batchId}: ${batch.status}`);
903
+ * }
904
+ * ```
905
+ */
906
+ async listBatches(options = {}) {
907
+ validateLimit(options.limit);
908
+ const response = await this.http.request({
909
+ method: "GET",
910
+ path: "/v1/messages/batches",
911
+ query: {
912
+ limit: options.limit,
913
+ offset: options.offset,
914
+ status: options.status
915
+ }
916
+ });
917
+ return response;
918
+ }
919
+ };
920
+
921
+ // src/client.ts
922
+ var DEFAULT_BASE_URL2 = "https://sendly.live/api";
923
+ var DEFAULT_TIMEOUT2 = 3e4;
924
+ var DEFAULT_MAX_RETRIES2 = 3;
925
+ var Sendly = class {
926
+ /**
927
+ * Messages API resource
928
+ *
929
+ * @example
930
+ * ```typescript
931
+ * // Send a message
932
+ * await sendly.messages.send({ to: '+1555...', text: 'Hello!' });
933
+ *
934
+ * // List messages
935
+ * const { data } = await sendly.messages.list({ limit: 10 });
936
+ *
937
+ * // Get a message
938
+ * const msg = await sendly.messages.get('msg_xxx');
939
+ * ```
940
+ */
941
+ messages;
942
+ http;
943
+ config;
944
+ /**
945
+ * Create a new Sendly client
946
+ *
947
+ * @param configOrApiKey - API key string or configuration object
948
+ */
949
+ constructor(configOrApiKey) {
950
+ if (typeof configOrApiKey === "string") {
951
+ this.config = {
952
+ apiKey: configOrApiKey,
953
+ baseUrl: DEFAULT_BASE_URL2,
954
+ timeout: DEFAULT_TIMEOUT2,
955
+ maxRetries: DEFAULT_MAX_RETRIES2
956
+ };
957
+ } else {
958
+ this.config = {
959
+ apiKey: configOrApiKey.apiKey,
960
+ baseUrl: configOrApiKey.baseUrl || DEFAULT_BASE_URL2,
961
+ timeout: configOrApiKey.timeout || DEFAULT_TIMEOUT2,
962
+ maxRetries: configOrApiKey.maxRetries ?? DEFAULT_MAX_RETRIES2
963
+ };
964
+ }
965
+ this.http = new HttpClient({
966
+ apiKey: this.config.apiKey,
967
+ baseUrl: this.config.baseUrl,
968
+ timeout: this.config.timeout,
969
+ maxRetries: this.config.maxRetries
970
+ });
971
+ this.messages = new MessagesResource(this.http);
972
+ }
973
+ /**
974
+ * Check if the client is using a test API key
975
+ *
976
+ * @returns true if using a test key (sk_test_v1_xxx)
977
+ *
978
+ * @example
979
+ * ```typescript
980
+ * if (sendly.isTestMode()) {
981
+ * console.log('Running in test mode');
982
+ * }
983
+ * ```
984
+ */
985
+ isTestMode() {
986
+ return this.http.isTestMode();
987
+ }
988
+ /**
989
+ * Get current rate limit information
990
+ *
991
+ * Returns the rate limit info from the most recent API request.
992
+ *
993
+ * @returns Rate limit info or undefined if no requests have been made
994
+ *
995
+ * @example
996
+ * ```typescript
997
+ * await sendly.messages.send({ to: '+1555...', text: 'Hello!' });
998
+ *
999
+ * const rateLimit = sendly.getRateLimitInfo();
1000
+ * if (rateLimit) {
1001
+ * console.log(`${rateLimit.remaining}/${rateLimit.limit} requests remaining`);
1002
+ * console.log(`Resets in ${rateLimit.reset} seconds`);
1003
+ * }
1004
+ * ```
1005
+ */
1006
+ getRateLimitInfo() {
1007
+ return this.http.getRateLimitInfo();
1008
+ }
1009
+ /**
1010
+ * Get the configured base URL
1011
+ */
1012
+ getBaseUrl() {
1013
+ return this.config.baseUrl;
1014
+ }
1015
+ };
1016
+
1017
+ // src/utils/webhooks.ts
1018
+ import * as crypto from "crypto";
1019
+ var WebhookSignatureError = class extends Error {
1020
+ constructor(message = "Invalid webhook signature") {
1021
+ super(message);
1022
+ this.name = "WebhookSignatureError";
1023
+ }
1024
+ };
1025
+ function verifyWebhookSignature(payload, signature, secret) {
1026
+ if (!payload || !signature || !secret) {
1027
+ return false;
1028
+ }
1029
+ const expectedSignature = generateWebhookSignature(payload, secret);
1030
+ try {
1031
+ return crypto.timingSafeEqual(
1032
+ Buffer.from(signature),
1033
+ Buffer.from(expectedSignature)
1034
+ );
1035
+ } catch {
1036
+ return false;
1037
+ }
1038
+ }
1039
+ function parseWebhookEvent(payload, signature, secret) {
1040
+ if (!verifyWebhookSignature(payload, signature, secret)) {
1041
+ throw new WebhookSignatureError();
1042
+ }
1043
+ let event;
1044
+ try {
1045
+ event = JSON.parse(payload);
1046
+ } catch {
1047
+ throw new Error("Failed to parse webhook payload");
1048
+ }
1049
+ if (!event.id || !event.type || !event.createdAt) {
1050
+ throw new Error("Invalid webhook event structure");
1051
+ }
1052
+ return event;
1053
+ }
1054
+ function generateWebhookSignature(payload, secret) {
1055
+ const hmac = crypto.createHmac("sha256", secret);
1056
+ hmac.update(payload);
1057
+ return "sha256=" + hmac.digest("hex");
1058
+ }
1059
+ var Webhooks = class {
1060
+ secret;
1061
+ /**
1062
+ * Create a new Webhooks instance
1063
+ * @param secret - Your webhook secret from the Sendly dashboard
1064
+ */
1065
+ constructor(secret) {
1066
+ if (!secret) {
1067
+ throw new Error("Webhook secret is required");
1068
+ }
1069
+ this.secret = secret;
1070
+ }
1071
+ /**
1072
+ * Verify a webhook signature
1073
+ * @param payload - Raw request body
1074
+ * @param signature - X-Sendly-Signature header
1075
+ */
1076
+ verify(payload, signature) {
1077
+ return verifyWebhookSignature(payload, signature, this.secret);
1078
+ }
1079
+ /**
1080
+ * Parse and verify a webhook event
1081
+ * @param payload - Raw request body
1082
+ * @param signature - X-Sendly-Signature header
1083
+ */
1084
+ parse(payload, signature) {
1085
+ return parseWebhookEvent(payload, signature, this.secret);
1086
+ }
1087
+ /**
1088
+ * Generate a signature for testing
1089
+ * @param payload - Payload to sign
1090
+ */
1091
+ sign(payload) {
1092
+ return generateWebhookSignature(payload, this.secret);
1093
+ }
1094
+ };
1095
+ export {
1096
+ ALL_SUPPORTED_COUNTRIES,
1097
+ AuthenticationError,
1098
+ CREDITS_PER_SMS,
1099
+ InsufficientCreditsError,
1100
+ NetworkError,
1101
+ NotFoundError,
1102
+ RateLimitError,
1103
+ SANDBOX_TEST_NUMBERS,
1104
+ SUPPORTED_COUNTRIES,
1105
+ Sendly,
1106
+ SendlyError,
1107
+ TimeoutError,
1108
+ ValidationError,
1109
+ WebhookSignatureError,
1110
+ Webhooks,
1111
+ calculateSegments,
1112
+ Sendly as default,
1113
+ generateWebhookSignature,
1114
+ getCountryFromPhone,
1115
+ isCountrySupported,
1116
+ parseWebhookEvent,
1117
+ validateMessageText,
1118
+ validatePhoneNumber,
1119
+ validateSenderId,
1120
+ verifyWebhookSignature
1121
+ };