@textbee/sdk 0.2.0 → 0.3.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/README.md CHANGED
@@ -41,7 +41,7 @@ await textbee.sendSms({
41
41
  | --- | --- | --- |
42
42
  | `deviceId` | `string` | Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. |
43
43
  | `simSubscriptionId` | `number` | Which SIM sends the message on a multi-SIM phone. Omit it and the phone uses its configured preferred SIM, or the system default. |
44
- | `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future, up to 72 hours ahead. |
44
+ | `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future. |
45
45
 
46
46
  ```js
47
47
  await textbee.sendSms({
@@ -76,9 +76,9 @@ History is account-level: one call covers every device, and `deviceIds` narrows
76
76
  ```js
77
77
  // Paginated history across the whole account, filterable and searchable
78
78
  const { data, meta } = await textbee.getMessages({
79
- direction: 'received', // 'all' | 'sent' | 'received'
79
+ direction: 'sent', // 'all' | 'sent' | 'received'
80
80
  deviceIds: [deviceId], // omit for every device
81
- status: 'delivered', // delivery state; direction=sent + status=failed lists failed sends
81
+ status: 'delivered', // delivery state; status=failed lists failed sends
82
82
  search: 'invoice',
83
83
  from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone
84
84
  to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count
@@ -87,7 +87,7 @@ const { data, meta } = await textbee.getMessages({
87
87
  })
88
88
 
89
89
  // direction on each message is lowercase and feeds straight back into filters
90
- data.filter((m) => m.direction === 'received')
90
+ data.filter((m) => m.direction === 'sent')
91
91
 
92
92
  // Which recipients of a bulk send failed: filter by the batch a send returned
93
93
  const { smsBatchId } = await textbee.sendSms({ recipients, message })
@@ -102,10 +102,16 @@ for await (const message of textbee.iterateMessages({ direction: 'received', ord
102
102
  // A single message and its current status
103
103
  const sms = await textbee.getSms(deviceId, smsId)
104
104
 
105
- // A whole batch, using the smsBatchId returned by sendSms
105
+ // A whole batch, using the smsBatchId returned by sendSms. This needs the id
106
+ // of the device that sent it; when textbee picked the device, use
107
+ // getMessages({ smsBatchId }) instead.
106
108
  const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)
107
109
  ```
108
110
 
111
+ `getSms` and `getSmsBatch` return the `Sms` type. It differs from the `Message` that `getMessages` returns: `device` is the device id string, not an object with the phone's name and model, and `direction` is absent (read `type`).
112
+
113
+ `batch.status` and the batch counts come from the messages in the batch, so they change as each message reports. `successCount` counts messages sent or delivered, and `failureCount` counts failed ones. `pendingCount`, `dispatchedCount`, `sentCount`, `deliveredCount` and `unknownCount` break the rest down by state.
114
+
109
115
  ## Verifying webhooks
110
116
 
111
117
  textbee signs each webhook delivery with HMAC-SHA256 and sends the hex digest in the `X-Signature` header. Pass the raw request body, not a re-serialized object, whenever your framework gives you access to it.
@@ -209,14 +215,30 @@ try {
209
215
  ```js
210
216
  new Textbee({
211
217
  apiKey: 'your-api-key',
212
- baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances
218
+ baseUrl: 'https://api.textbee.dev/api/v1', // the default
213
219
  })
214
220
  ```
215
221
 
222
+ ### Self-hosted textbee
223
+
224
+ Point `baseUrl` at your own instance. The `/api/v1` suffix is optional, so all of these reach the same API:
225
+
226
+ ```js
227
+ new Textbee({ apiKey, baseUrl: 'https://sms.example.com' })
228
+ new Textbee({ apiKey, baseUrl: 'https://sms.example.com/api/v1' })
229
+ new Textbee({ apiKey, baseUrl: 'http://localhost:3001' })
230
+ ```
231
+
232
+ If the API sits under a path prefix, give the full path, for example `https://example.com/textbee/api/v1`. Any path other than `/api/v1` is used as given, so a proxy that maps `/sms` onto the API keeps working with `https://example.com/sms`. A value with no scheme gets `https://`. An invalid URL, or one with a username or password, throws a `TypeError` instead of falling back to the hosted API, so a typo never sends your key to textbee.dev.
233
+
216
234
  ## What is not covered yet
217
235
 
218
236
  The SDK focuses on sending and reading messages. Bulk send and a few device operations are still REST only, documented at [textbee.dev/docs](https://textbee.dev/docs).
219
237
 
238
+ ## Community and support
239
+
240
+ Questions or feedback? Join the community on [Discord](https://textbee.dev/discord) or email support@textbee.dev.
241
+
220
242
  ## License
221
243
 
222
- MIT. Part of the [textbee](https://github.com/vernu/textbee) project.
244
+ MIT. Part of the [textbee](https://github.com/textbee/textbee) project.
package/dist/index.cjs CHANGED
@@ -48,7 +48,7 @@ var TextbeeError = class extends Error {
48
48
 
49
49
  // src/client.ts
50
50
  var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
51
- var CLIENT_ID = `textbee-js/${"0.2.0"}`;
51
+ var CLIENT_ID = `textbee-js/${"0.3.0"}`;
52
52
  var Textbee = class {
53
53
  #apiKey;
54
54
  #baseUrl;
@@ -57,7 +57,7 @@ var Textbee = class {
57
57
  throw new TypeError("Textbee requires an apiKey");
58
58
  }
59
59
  this.#apiKey = options.apiKey;
60
- this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
60
+ this.#baseUrl = normalizeBaseUrl(options.baseUrl);
61
61
  }
62
62
  /** Send an SMS to one or more recipients. */
63
63
  async sendSms(request) {
@@ -128,9 +128,10 @@ var Textbee = class {
128
128
  }
129
129
  /**
130
130
  * Iterate every message matching the filters, following the pagination
131
- * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
132
- * forward when polling; keep the last message's `createdAt` (or track
133
- * `meta.nextCursor` via `getMessages`) to resume the next poll.
131
+ * cursor until the end. `limit` is the page size, not a cap on the total.
132
+ * Use `order: 'asc'` with a `from` bound to drain forward when polling.
133
+ * `from` is inclusive, so a poll that resumes from the last `createdAt` sees
134
+ * that message again; skip ids you have already handled.
134
135
  *
135
136
  * ```ts
136
137
  * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
@@ -154,7 +155,11 @@ var Textbee = class {
154
155
  );
155
156
  return payload.data;
156
157
  }
157
- /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
158
+ /**
159
+ * Fetch a batch and every message in it, by the `smsBatchId` a send returns.
160
+ * Needs the id of the device that sent the batch. When textbee picked the
161
+ * device, use `getMessages({ smsBatchId })` instead.
162
+ */
158
163
  async getSmsBatch(deviceId, smsBatchId) {
159
164
  const payload = await this.#request(
160
165
  "GET",
@@ -190,6 +195,44 @@ var Textbee = class {
190
195
  return payload;
191
196
  }
192
197
  };
198
+ function normalizeBaseUrl(raw) {
199
+ const value = raw?.trim();
200
+ if (!value) {
201
+ return DEFAULT_BASE_URL;
202
+ }
203
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`;
204
+ let url;
205
+ try {
206
+ url = new URL(withScheme);
207
+ } catch {
208
+ throw new TypeError(
209
+ "baseUrl is not a valid URL. Use your textbee api, for example https://sms.example.com. The /api/v1 suffix is added for you."
210
+ );
211
+ }
212
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
213
+ throw new TypeError(`baseUrl must be http or https, got "${url.protocol}"`);
214
+ }
215
+ if (url.username || url.password) {
216
+ throw new TypeError("baseUrl must not contain a username or password");
217
+ }
218
+ const segments = url.pathname.split("/").filter(Boolean);
219
+ let cut = -1;
220
+ for (let i = segments.length - 2; i >= 0; i--) {
221
+ if (segments[i].toLowerCase() === "api" && segments[i + 1].toLowerCase() === "v1") {
222
+ cut = i + 2;
223
+ break;
224
+ }
225
+ }
226
+ let path;
227
+ if (segments.length === 0) {
228
+ path = ["api", "v1"];
229
+ } else if (cut === -1) {
230
+ path = segments;
231
+ } else {
232
+ path = [...segments.slice(0, cut - 2), "api", "v1"];
233
+ }
234
+ return [url.origin, ...path].join("/");
235
+ }
193
236
  function toIsoString(value) {
194
237
  return value instanceof Date ? value.toISOString() : value;
195
238
  }
@@ -207,11 +250,15 @@ async function readBody(response) {
207
250
  function errorMessage(payload, status) {
208
251
  if (payload && typeof payload === "object") {
209
252
  const record = payload;
210
- if (typeof record.error === "string" && record.error) {
211
- return record.error;
212
- }
213
- if (typeof record.message === "string" && record.message) {
214
- return record.message;
253
+ const keys = "statusCode" in record ? ["message", "error"] : ["error", "message"];
254
+ for (const key of keys) {
255
+ const value = record[key];
256
+ if (typeof value === "string" && value) {
257
+ return value;
258
+ }
259
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string")) {
260
+ return value.join("; ");
261
+ }
215
262
  }
216
263
  }
217
264
  return `HTTP ${status}`;
package/dist/index.d.cts CHANGED
@@ -1,7 +1,15 @@
1
1
  interface TextbeeOptions {
2
2
  /** API key from the textbee dashboard. Sent as the `x-api-key` header. */
3
3
  apiKey: string;
4
- /** Override the API base URL. Defaults to `https://api.textbee.dev/api/v1`. */
4
+ /**
5
+ * Your self-hosted textbee API. Defaults to `https://api.textbee.dev/api/v1`.
6
+ *
7
+ * A bare origin such as `https://sms.example.com` gets `/api/v1` added. A URL
8
+ * that contains `/api/v1` is cut there, so a pasted endpoint URL still works.
9
+ * Any other path is used as given, for proxies that map a prefix onto the API.
10
+ * A value with no scheme gets `https://`. An invalid URL throws a `TypeError`
11
+ * rather than falling back, so a typo never sends your key to the public API.
12
+ */
5
13
  baseUrl?: string;
6
14
  }
7
15
  interface SendSmsRequest {
@@ -33,20 +41,32 @@ interface SendSmsRequest {
33
41
  /**
34
42
  * Send later instead of now, as an ISO 8601 string or a `Date`.
35
43
  *
36
- * Must be in the future, and up to 72 hours ahead. Omit for an immediate send.
44
+ * Must be in the future. Omit for an immediate send.
37
45
  */
38
46
  scheduledAt?: string | Date;
39
47
  }
48
+ /** A reason the send will probably fail, such as `SMS_PERMISSION_MISSING`. */
49
+ interface SendSmsWarning {
50
+ code: string;
51
+ message: string;
52
+ }
40
53
  /** Returned when the message was accepted for delivery and queued. */
41
54
  interface SendSmsQueuedResponse {
42
55
  success: true;
43
56
  message: string;
44
57
  smsBatchId: string;
45
58
  recipientCount: number;
59
+ /** When a large send, spread over several waves, should finish. */
60
+ estimatedCompletionAt?: string;
61
+ /** Present when the phone last reported a problem, such as the SMS permission off. */
62
+ warning?: SendSmsWarning;
46
63
  }
47
64
  /** Returned when the message was dispatched to the device immediately. */
48
65
  interface SendSmsImmediateResponse {
49
66
  successCount: number;
67
+ failureCount?: number;
68
+ /** Present when the phone last reported a problem, such as the SMS permission off. */
69
+ warning?: SendSmsWarning;
50
70
  [key: string]: unknown;
51
71
  }
52
72
  /**
@@ -110,6 +130,7 @@ interface Device {
110
130
  }
111
131
  interface MessageDevice {
112
132
  _id: string;
133
+ name?: string;
113
134
  brand?: string;
114
135
  model?: string;
115
136
  buildId?: string;
@@ -129,7 +150,7 @@ interface Message {
129
150
  direction?: 'sent' | 'received';
130
151
  /** Message channel. Currently always `sms`; absent means `sms`. */
131
152
  channel?: string;
132
- /** The sending or receiving device, populated by the history endpoints. */
153
+ /** The sending or receiving device. */
133
154
  device?: MessageDevice;
134
155
  /** Lowercase, and absent on messages stored before status tracking. */
135
156
  status?: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
@@ -148,16 +169,35 @@ interface Message {
148
169
  createdAt?: string;
149
170
  updatedAt?: string;
150
171
  }
172
+ /**
173
+ * A send batch. The status and counts are calculated from the batch's
174
+ * messages, so they change as each message reports.
175
+ */
151
176
  interface SmsBatch {
152
177
  _id: string;
153
178
  message: string;
154
179
  recipientCount: number;
155
180
  recipientPreview?: string;
181
+ /** Messages sent or delivered: `sentCount + deliveredCount`. */
156
182
  successCount: number;
183
+ /** Messages that failed. */
157
184
  failureCount: number;
185
+ /** Messages not yet handed to the phone. */
186
+ pendingCount?: number;
187
+ /** Messages handed to the phone with no result yet. */
188
+ dispatchedCount?: number;
189
+ /** Messages sent with no delivery report yet. */
190
+ sentCount?: number;
191
+ /** Messages with a delivery report. */
192
+ deliveredCount?: number;
193
+ /** Messages with no report from the phone within 20 minutes. */
194
+ unknownCount?: number;
158
195
  status: 'pending' | 'processing' | 'completed' | 'partial_success' | 'failed' | 'unknown';
159
196
  error?: string;
197
+ /** When the batch reached its current final status. Absent while processing. */
160
198
  completedAt?: string;
199
+ /** When the status and counts were last calculated. */
200
+ statusCheckedAt?: string;
161
201
  createdAt?: string;
162
202
  updatedAt?: string;
163
203
  }
@@ -175,13 +215,21 @@ interface MessageListMeta {
175
215
  nextCursor?: string | null;
176
216
  hasMore?: boolean;
177
217
  }
218
+ /**
219
+ * A message as `getSms` and `getSmsBatch` return it: `device` is the id
220
+ * string, and `direction` is absent (read `type`).
221
+ */
222
+ interface Sms extends Omit<Message, 'device' | 'direction'> {
223
+ /** The id of the sending or receiving device. */
224
+ device?: string;
225
+ }
178
226
  interface MessageList {
179
227
  data: Message[];
180
228
  meta: MessageListMeta;
181
229
  }
182
230
  interface SmsBatchResult {
183
231
  batch: SmsBatch;
184
- messages: Message[];
232
+ messages: Sms[];
185
233
  }
186
234
  /** Event names textbee sends in a webhook payload. */
187
235
  type WebhookEvent = 'MESSAGE_RECEIVED' | 'MESSAGE_SENT' | 'MESSAGE_DELIVERED' | 'MESSAGE_FAILED' | 'UNKNOWN_STATE' | 'SMS_STATUS_UPDATED';
@@ -204,9 +252,10 @@ declare class Textbee {
204
252
  getMessages(options?: ListMessagesOptions): Promise<MessageList>;
205
253
  /**
206
254
  * Iterate every message matching the filters, following the pagination
207
- * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
208
- * forward when polling; keep the last message's `createdAt` (or track
209
- * `meta.nextCursor` via `getMessages`) to resume the next poll.
255
+ * cursor until the end. `limit` is the page size, not a cap on the total.
256
+ * Use `order: 'asc'` with a `from` bound to drain forward when polling.
257
+ * `from` is inclusive, so a poll that resumes from the last `createdAt` sees
258
+ * that message again; skip ids you have already handled.
210
259
  *
211
260
  * ```ts
212
261
  * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
@@ -216,8 +265,12 @@ declare class Textbee {
216
265
  */
217
266
  iterateMessages(options?: IterateMessagesOptions): AsyncGenerator<Message, void, undefined>;
218
267
  /** Fetch a single message, including its delivery status. */
219
- getSms(deviceId: string, smsId: string): Promise<Message>;
220
- /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
268
+ getSms(deviceId: string, smsId: string): Promise<Sms>;
269
+ /**
270
+ * Fetch a batch and every message in it, by the `smsBatchId` a send returns.
271
+ * Needs the id of the device that sent the batch. When textbee picked the
272
+ * device, use `getMessages({ smsBatchId })` instead.
273
+ */
221
274
  getSmsBatch(deviceId: string, smsBatchId: string): Promise<SmsBatchResult>;
222
275
  }
223
276
 
@@ -347,4 +400,4 @@ interface VerifyWebhookSignatureOptions {
347
400
  */
348
401
  declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
349
402
 
350
- export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
403
+ export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SendSmsWarning, type Sms, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  interface TextbeeOptions {
2
2
  /** API key from the textbee dashboard. Sent as the `x-api-key` header. */
3
3
  apiKey: string;
4
- /** Override the API base URL. Defaults to `https://api.textbee.dev/api/v1`. */
4
+ /**
5
+ * Your self-hosted textbee API. Defaults to `https://api.textbee.dev/api/v1`.
6
+ *
7
+ * A bare origin such as `https://sms.example.com` gets `/api/v1` added. A URL
8
+ * that contains `/api/v1` is cut there, so a pasted endpoint URL still works.
9
+ * Any other path is used as given, for proxies that map a prefix onto the API.
10
+ * A value with no scheme gets `https://`. An invalid URL throws a `TypeError`
11
+ * rather than falling back, so a typo never sends your key to the public API.
12
+ */
5
13
  baseUrl?: string;
6
14
  }
7
15
  interface SendSmsRequest {
@@ -33,20 +41,32 @@ interface SendSmsRequest {
33
41
  /**
34
42
  * Send later instead of now, as an ISO 8601 string or a `Date`.
35
43
  *
36
- * Must be in the future, and up to 72 hours ahead. Omit for an immediate send.
44
+ * Must be in the future. Omit for an immediate send.
37
45
  */
38
46
  scheduledAt?: string | Date;
39
47
  }
48
+ /** A reason the send will probably fail, such as `SMS_PERMISSION_MISSING`. */
49
+ interface SendSmsWarning {
50
+ code: string;
51
+ message: string;
52
+ }
40
53
  /** Returned when the message was accepted for delivery and queued. */
41
54
  interface SendSmsQueuedResponse {
42
55
  success: true;
43
56
  message: string;
44
57
  smsBatchId: string;
45
58
  recipientCount: number;
59
+ /** When a large send, spread over several waves, should finish. */
60
+ estimatedCompletionAt?: string;
61
+ /** Present when the phone last reported a problem, such as the SMS permission off. */
62
+ warning?: SendSmsWarning;
46
63
  }
47
64
  /** Returned when the message was dispatched to the device immediately. */
48
65
  interface SendSmsImmediateResponse {
49
66
  successCount: number;
67
+ failureCount?: number;
68
+ /** Present when the phone last reported a problem, such as the SMS permission off. */
69
+ warning?: SendSmsWarning;
50
70
  [key: string]: unknown;
51
71
  }
52
72
  /**
@@ -110,6 +130,7 @@ interface Device {
110
130
  }
111
131
  interface MessageDevice {
112
132
  _id: string;
133
+ name?: string;
113
134
  brand?: string;
114
135
  model?: string;
115
136
  buildId?: string;
@@ -129,7 +150,7 @@ interface Message {
129
150
  direction?: 'sent' | 'received';
130
151
  /** Message channel. Currently always `sms`; absent means `sms`. */
131
152
  channel?: string;
132
- /** The sending or receiving device, populated by the history endpoints. */
153
+ /** The sending or receiving device. */
133
154
  device?: MessageDevice;
134
155
  /** Lowercase, and absent on messages stored before status tracking. */
135
156
  status?: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
@@ -148,16 +169,35 @@ interface Message {
148
169
  createdAt?: string;
149
170
  updatedAt?: string;
150
171
  }
172
+ /**
173
+ * A send batch. The status and counts are calculated from the batch's
174
+ * messages, so they change as each message reports.
175
+ */
151
176
  interface SmsBatch {
152
177
  _id: string;
153
178
  message: string;
154
179
  recipientCount: number;
155
180
  recipientPreview?: string;
181
+ /** Messages sent or delivered: `sentCount + deliveredCount`. */
156
182
  successCount: number;
183
+ /** Messages that failed. */
157
184
  failureCount: number;
185
+ /** Messages not yet handed to the phone. */
186
+ pendingCount?: number;
187
+ /** Messages handed to the phone with no result yet. */
188
+ dispatchedCount?: number;
189
+ /** Messages sent with no delivery report yet. */
190
+ sentCount?: number;
191
+ /** Messages with a delivery report. */
192
+ deliveredCount?: number;
193
+ /** Messages with no report from the phone within 20 minutes. */
194
+ unknownCount?: number;
158
195
  status: 'pending' | 'processing' | 'completed' | 'partial_success' | 'failed' | 'unknown';
159
196
  error?: string;
197
+ /** When the batch reached its current final status. Absent while processing. */
160
198
  completedAt?: string;
199
+ /** When the status and counts were last calculated. */
200
+ statusCheckedAt?: string;
161
201
  createdAt?: string;
162
202
  updatedAt?: string;
163
203
  }
@@ -175,13 +215,21 @@ interface MessageListMeta {
175
215
  nextCursor?: string | null;
176
216
  hasMore?: boolean;
177
217
  }
218
+ /**
219
+ * A message as `getSms` and `getSmsBatch` return it: `device` is the id
220
+ * string, and `direction` is absent (read `type`).
221
+ */
222
+ interface Sms extends Omit<Message, 'device' | 'direction'> {
223
+ /** The id of the sending or receiving device. */
224
+ device?: string;
225
+ }
178
226
  interface MessageList {
179
227
  data: Message[];
180
228
  meta: MessageListMeta;
181
229
  }
182
230
  interface SmsBatchResult {
183
231
  batch: SmsBatch;
184
- messages: Message[];
232
+ messages: Sms[];
185
233
  }
186
234
  /** Event names textbee sends in a webhook payload. */
187
235
  type WebhookEvent = 'MESSAGE_RECEIVED' | 'MESSAGE_SENT' | 'MESSAGE_DELIVERED' | 'MESSAGE_FAILED' | 'UNKNOWN_STATE' | 'SMS_STATUS_UPDATED';
@@ -204,9 +252,10 @@ declare class Textbee {
204
252
  getMessages(options?: ListMessagesOptions): Promise<MessageList>;
205
253
  /**
206
254
  * Iterate every message matching the filters, following the pagination
207
- * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
208
- * forward when polling; keep the last message's `createdAt` (or track
209
- * `meta.nextCursor` via `getMessages`) to resume the next poll.
255
+ * cursor until the end. `limit` is the page size, not a cap on the total.
256
+ * Use `order: 'asc'` with a `from` bound to drain forward when polling.
257
+ * `from` is inclusive, so a poll that resumes from the last `createdAt` sees
258
+ * that message again; skip ids you have already handled.
210
259
  *
211
260
  * ```ts
212
261
  * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
@@ -216,8 +265,12 @@ declare class Textbee {
216
265
  */
217
266
  iterateMessages(options?: IterateMessagesOptions): AsyncGenerator<Message, void, undefined>;
218
267
  /** Fetch a single message, including its delivery status. */
219
- getSms(deviceId: string, smsId: string): Promise<Message>;
220
- /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
268
+ getSms(deviceId: string, smsId: string): Promise<Sms>;
269
+ /**
270
+ * Fetch a batch and every message in it, by the `smsBatchId` a send returns.
271
+ * Needs the id of the device that sent the batch. When textbee picked the
272
+ * device, use `getMessages({ smsBatchId })` instead.
273
+ */
221
274
  getSmsBatch(deviceId: string, smsBatchId: string): Promise<SmsBatchResult>;
222
275
  }
223
276
 
@@ -347,4 +400,4 @@ interface VerifyWebhookSignatureOptions {
347
400
  */
348
401
  declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
349
402
 
350
- export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
403
+ export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SendSmsWarning, type Sms, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var TextbeeError = class extends Error {
14
14
 
15
15
  // src/client.ts
16
16
  var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
17
- var CLIENT_ID = `textbee-js/${"0.2.0"}`;
17
+ var CLIENT_ID = `textbee-js/${"0.3.0"}`;
18
18
  var Textbee = class {
19
19
  #apiKey;
20
20
  #baseUrl;
@@ -23,7 +23,7 @@ var Textbee = class {
23
23
  throw new TypeError("Textbee requires an apiKey");
24
24
  }
25
25
  this.#apiKey = options.apiKey;
26
- this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
26
+ this.#baseUrl = normalizeBaseUrl(options.baseUrl);
27
27
  }
28
28
  /** Send an SMS to one or more recipients. */
29
29
  async sendSms(request) {
@@ -94,9 +94,10 @@ var Textbee = class {
94
94
  }
95
95
  /**
96
96
  * Iterate every message matching the filters, following the pagination
97
- * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
98
- * forward when polling; keep the last message's `createdAt` (or track
99
- * `meta.nextCursor` via `getMessages`) to resume the next poll.
97
+ * cursor until the end. `limit` is the page size, not a cap on the total.
98
+ * Use `order: 'asc'` with a `from` bound to drain forward when polling.
99
+ * `from` is inclusive, so a poll that resumes from the last `createdAt` sees
100
+ * that message again; skip ids you have already handled.
100
101
  *
101
102
  * ```ts
102
103
  * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
@@ -120,7 +121,11 @@ var Textbee = class {
120
121
  );
121
122
  return payload.data;
122
123
  }
123
- /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
124
+ /**
125
+ * Fetch a batch and every message in it, by the `smsBatchId` a send returns.
126
+ * Needs the id of the device that sent the batch. When textbee picked the
127
+ * device, use `getMessages({ smsBatchId })` instead.
128
+ */
124
129
  async getSmsBatch(deviceId, smsBatchId) {
125
130
  const payload = await this.#request(
126
131
  "GET",
@@ -156,6 +161,44 @@ var Textbee = class {
156
161
  return payload;
157
162
  }
158
163
  };
164
+ function normalizeBaseUrl(raw) {
165
+ const value = raw?.trim();
166
+ if (!value) {
167
+ return DEFAULT_BASE_URL;
168
+ }
169
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`;
170
+ let url;
171
+ try {
172
+ url = new URL(withScheme);
173
+ } catch {
174
+ throw new TypeError(
175
+ "baseUrl is not a valid URL. Use your textbee api, for example https://sms.example.com. The /api/v1 suffix is added for you."
176
+ );
177
+ }
178
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
179
+ throw new TypeError(`baseUrl must be http or https, got "${url.protocol}"`);
180
+ }
181
+ if (url.username || url.password) {
182
+ throw new TypeError("baseUrl must not contain a username or password");
183
+ }
184
+ const segments = url.pathname.split("/").filter(Boolean);
185
+ let cut = -1;
186
+ for (let i = segments.length - 2; i >= 0; i--) {
187
+ if (segments[i].toLowerCase() === "api" && segments[i + 1].toLowerCase() === "v1") {
188
+ cut = i + 2;
189
+ break;
190
+ }
191
+ }
192
+ let path;
193
+ if (segments.length === 0) {
194
+ path = ["api", "v1"];
195
+ } else if (cut === -1) {
196
+ path = segments;
197
+ } else {
198
+ path = [...segments.slice(0, cut - 2), "api", "v1"];
199
+ }
200
+ return [url.origin, ...path].join("/");
201
+ }
159
202
  function toIsoString(value) {
160
203
  return value instanceof Date ? value.toISOString() : value;
161
204
  }
@@ -173,11 +216,15 @@ async function readBody(response) {
173
216
  function errorMessage(payload, status) {
174
217
  if (payload && typeof payload === "object") {
175
218
  const record = payload;
176
- if (typeof record.error === "string" && record.error) {
177
- return record.error;
178
- }
179
- if (typeof record.message === "string" && record.message) {
180
- return record.message;
219
+ const keys = "statusCode" in record ? ["message", "error"] : ["error", "message"];
220
+ for (const key of keys) {
221
+ const value = record[key];
222
+ if (typeof value === "string" && value) {
223
+ return value;
224
+ }
225
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string")) {
226
+ return value.join("; ");
227
+ }
181
228
  }
182
229
  }
183
230
  return `HTTP ${status}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textbee/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@9.14.2",