@textbee/sdk 0.2.0 → 0.3.1

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
@@ -8,6 +8,8 @@ Official JavaScript SDK for [textbee.dev](https://textbee.dev), the open source
8
8
 
9
9
  Zero dependencies, TypeScript types included, works in Node 18+, Bun, Deno, Cloudflare Workers, and Vercel Edge.
10
10
 
11
+ Writing Python? Use the official [Python SDK](https://github.com/textbee/textbee-python): `pip install textbee`.
12
+
11
13
  ## Install
12
14
 
13
15
  ```bash
@@ -41,7 +43,7 @@ await textbee.sendSms({
41
43
  | --- | --- | --- |
42
44
  | `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
45
  | `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. |
46
+ | `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future. |
45
47
 
46
48
  ```js
47
49
  await textbee.sendSms({
@@ -76,9 +78,9 @@ History is account-level: one call covers every device, and `deviceIds` narrows
76
78
  ```js
77
79
  // Paginated history across the whole account, filterable and searchable
78
80
  const { data, meta } = await textbee.getMessages({
79
- direction: 'received', // 'all' | 'sent' | 'received'
81
+ direction: 'sent', // 'all' | 'sent' | 'received'
80
82
  deviceIds: [deviceId], // omit for every device
81
- status: 'delivered', // delivery state; direction=sent + status=failed lists failed sends
83
+ status: 'delivered', // delivery state; status=failed lists failed sends
82
84
  search: 'invoice',
83
85
  from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone
84
86
  to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count
@@ -87,7 +89,7 @@ const { data, meta } = await textbee.getMessages({
87
89
  })
88
90
 
89
91
  // direction on each message is lowercase and feeds straight back into filters
90
- data.filter((m) => m.direction === 'received')
92
+ data.filter((m) => m.direction === 'sent')
91
93
 
92
94
  // Which recipients of a bulk send failed: filter by the batch a send returned
93
95
  const { smsBatchId } = await textbee.sendSms({ recipients, message })
@@ -102,10 +104,16 @@ for await (const message of textbee.iterateMessages({ direction: 'received', ord
102
104
  // A single message and its current status
103
105
  const sms = await textbee.getSms(deviceId, smsId)
104
106
 
105
- // A whole batch, using the smsBatchId returned by sendSms
107
+ // A whole batch, using the smsBatchId returned by sendSms. This needs the id
108
+ // of the device that sent it; when textbee picked the device, use
109
+ // getMessages({ smsBatchId }) instead.
106
110
  const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)
107
111
  ```
108
112
 
113
+ `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`).
114
+
115
+ `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.
116
+
109
117
  ## Verifying webhooks
110
118
 
111
119
  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 +217,30 @@ try {
209
217
  ```js
210
218
  new Textbee({
211
219
  apiKey: 'your-api-key',
212
- baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances
220
+ baseUrl: 'https://api.textbee.dev/api/v1', // the default
213
221
  })
214
222
  ```
215
223
 
224
+ ### Self-hosted textbee
225
+
226
+ Point `baseUrl` at your own instance. The `/api/v1` suffix is optional, so all of these reach the same API:
227
+
228
+ ```js
229
+ new Textbee({ apiKey, baseUrl: 'https://sms.example.com' })
230
+ new Textbee({ apiKey, baseUrl: 'https://sms.example.com/api/v1' })
231
+ new Textbee({ apiKey, baseUrl: 'http://localhost:3001' })
232
+ ```
233
+
234
+ If the API sits under a path prefix, give the prefix: `https://example.com/textbee` reaches `https://example.com/textbee/api/v1`. The SDK adds `/api/v1` unless the path already contains it, the same rule the textbee MCP server uses for `TEXTBEE_BASE_URL`. 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.
235
+
216
236
  ## What is not covered yet
217
237
 
218
238
  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
239
 
240
+ ## Community and support
241
+
242
+ Questions or feedback? Join the community on [Discord](https://textbee.dev/discord) or email support@textbee.dev.
243
+
220
244
  ## License
221
245
 
222
- MIT. Part of the [textbee](https://github.com/vernu/textbee) project.
246
+ 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.1"}`;
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,42 @@ 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 (cut === -1) {
228
+ path = [...segments, "api", "v1"];
229
+ } else {
230
+ path = [...segments.slice(0, cut - 2), "api", "v1"];
231
+ }
232
+ return [url.origin, ...path].join("/");
233
+ }
193
234
  function toIsoString(value) {
194
235
  return value instanceof Date ? value.toISOString() : value;
195
236
  }
@@ -207,11 +248,15 @@ async function readBody(response) {
207
248
  function errorMessage(payload, status) {
208
249
  if (payload && typeof payload === "object") {
209
250
  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;
251
+ const keys = "statusCode" in record ? ["message", "error"] : ["error", "message"];
252
+ for (const key of keys) {
253
+ const value = record[key];
254
+ if (typeof value === "string" && value) {
255
+ return value;
256
+ }
257
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string")) {
258
+ return value.join("; ");
259
+ }
215
260
  }
216
261
  }
217
262
  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
+ * `/api/v1` is added unless the path already contains it, so
8
+ * `https://sms.example.com` and `https://example.com/textbee` both work. A URL
9
+ * that contains `/api/v1` is cut there, so a pasted endpoint URL still works.
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
+ * `/api/v1` is added unless the path already contains it, so
8
+ * `https://sms.example.com` and `https://example.com/textbee` both work. A URL
9
+ * that contains `/api/v1` is cut there, so a pasted endpoint URL still works.
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.1"}`;
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,42 @@ 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 (cut === -1) {
194
+ path = [...segments, "api", "v1"];
195
+ } else {
196
+ path = [...segments.slice(0, cut - 2), "api", "v1"];
197
+ }
198
+ return [url.origin, ...path].join("/");
199
+ }
159
200
  function toIsoString(value) {
160
201
  return value instanceof Date ? value.toISOString() : value;
161
202
  }
@@ -173,11 +214,15 @@ async function readBody(response) {
173
214
  function errorMessage(payload, status) {
174
215
  if (payload && typeof payload === "object") {
175
216
  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;
217
+ const keys = "statusCode" in record ? ["message", "error"] : ["error", "message"];
218
+ for (const key of keys) {
219
+ const value = record[key];
220
+ if (typeof value === "string" && value) {
221
+ return value;
222
+ }
223
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string")) {
224
+ return value.join("; ");
225
+ }
181
226
  }
182
227
  }
183
228
  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.1",
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",