@textbee/sdk 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 - 2026 textbee.dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @textbee/sdk
2
+
3
+ Official JavaScript SDK for [textbee.dev](https://textbee.dev), the open source SMS gateway that turns an Android phone into an SMS API.
4
+
5
+ Zero dependencies, TypeScript types included, works in Node 18+, Bun, Deno, Cloudflare Workers, and Vercel Edge.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @textbee/sdk
11
+ ```
12
+
13
+ ```bash
14
+ pnpm add @textbee/sdk
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ Get an API key from the [textbee dashboard](https://textbee.dev/dashboard), then:
20
+
21
+ ```js
22
+ import { Textbee } from '@textbee/sdk'
23
+
24
+ const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })
25
+
26
+ await textbee.sendSms({
27
+ recipients: ['+251912345678'],
28
+ message: 'Hello from textbee!',
29
+ })
30
+ ```
31
+
32
+ ## Send options
33
+
34
+ `sendSms` needs only `message` and `recipients`. Everything else is optional.
35
+
36
+ | Option | Type | What it does |
37
+ | --- | --- | --- |
38
+ | `deviceId` | `string` | Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. |
39
+ | `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. |
40
+ | `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future, up to 72 hours ahead. |
41
+
42
+ ```js
43
+ await textbee.sendSms({
44
+ recipients: ['+251912345678'],
45
+ message: 'Your appointment is tomorrow at 9am',
46
+ deviceId: '65f0000000000000000000aa',
47
+ simSubscriptionId: 2,
48
+ scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
49
+ })
50
+ ```
51
+
52
+ ### Finding your simSubscriptionId
53
+
54
+ Open the textbee Android app, go to **Dashboard**, and find the **SIM Cards** section. Each SIM shows its subscription id with a copy button.
55
+
56
+ Be aware that this value is not validated. If the id does not match a SIM currently in the phone it is ignored, and the message goes out from the preferred or default SIM instead. Nothing errors, so confirm which SIM was used by checking the number the message arrived from.
57
+
58
+ ## Devices
59
+
60
+ ```js
61
+ const devices = await textbee.getDevices()
62
+ const device = await textbee.getDevice(deviceId)
63
+
64
+ // Change which device handles sends that omit deviceId
65
+ await textbee.setDefaultDevice(deviceId)
66
+ ```
67
+
68
+ ## Messages and delivery status
69
+
70
+ ```js
71
+ // Paginated history, filterable and searchable
72
+ const { data, meta } = await textbee.getMessages(deviceId, {
73
+ type: 'received', // 'all' | 'sent' | 'received'
74
+ page: 1,
75
+ limit: 50,
76
+ search: 'invoice',
77
+ })
78
+
79
+ // A single message and its current status
80
+ const sms = await textbee.getSms(deviceId, smsId)
81
+
82
+ // A whole batch, using the smsBatchId returned by sendSms
83
+ const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)
84
+ ```
85
+
86
+ ## Verifying webhooks
87
+
88
+ 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.
89
+
90
+ ```js
91
+ import { verifyWebhookSignature } from '@textbee/sdk'
92
+
93
+ app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (req, res) => {
94
+ const valid = await verifyWebhookSignature({
95
+ payload: req.body.toString('utf8'),
96
+ signature: req.get('x-signature'),
97
+ signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET,
98
+ })
99
+
100
+ if (!valid) return res.sendStatus(401)
101
+
102
+ const event = JSON.parse(req.body.toString('utf8'))
103
+ res.sendStatus(200)
104
+ })
105
+ ```
106
+
107
+ ## Errors
108
+
109
+ Any non-2xx response throws a `TextbeeError` carrying the status and the parsed body. Network failures reject with the underlying `fetch` error instead.
110
+
111
+ ```js
112
+ import { TextbeeError } from '@textbee/sdk'
113
+
114
+ try {
115
+ await textbee.sendSms({ recipients: ['+251912345678'], message: 'hi' })
116
+ } catch (error) {
117
+ if (error instanceof TextbeeError) {
118
+ console.error(error.status, error.message)
119
+ } else {
120
+ throw error
121
+ }
122
+ }
123
+ ```
124
+
125
+ ## Client options
126
+
127
+ ```js
128
+ new Textbee({
129
+ apiKey: 'your-api-key',
130
+ baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances
131
+ })
132
+ ```
133
+
134
+ ## What is not covered yet
135
+
136
+ v0.0.1 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).
137
+
138
+ ## License
139
+
140
+ MIT. Part of the [textbee](https://github.com/vernu/textbee) project.
package/dist/index.cjs ADDED
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ Textbee: () => Textbee,
24
+ TextbeeError: () => TextbeeError,
25
+ verifyWebhookSignature: () => verifyWebhookSignature
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/errors.ts
30
+ var TextbeeError = class extends Error {
31
+ /** HTTP status code returned by the API. */
32
+ status;
33
+ /** Parsed response body, or the raw text when the body was not JSON. */
34
+ body;
35
+ constructor(status, message, body) {
36
+ super(message);
37
+ this.name = "TextbeeError";
38
+ this.status = status;
39
+ this.body = body;
40
+ }
41
+ };
42
+
43
+ // src/client.ts
44
+ var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
45
+ var CLIENT_ID = `textbee-js/${"0.0.1"}`;
46
+ var Textbee = class {
47
+ #apiKey;
48
+ #baseUrl;
49
+ constructor(options) {
50
+ if (!options?.apiKey) {
51
+ throw new TypeError("Textbee requires an apiKey");
52
+ }
53
+ this.#apiKey = options.apiKey;
54
+ this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
55
+ }
56
+ /** Send an SMS to one or more recipients. */
57
+ async sendSms(request) {
58
+ const body = {
59
+ message: request.message,
60
+ recipients: request.recipients
61
+ };
62
+ if (request.deviceId !== void 0) {
63
+ body.deviceId = request.deviceId;
64
+ }
65
+ if (request.simSubscriptionId !== void 0) {
66
+ body.simSubscriptionId = request.simSubscriptionId;
67
+ }
68
+ if (request.scheduledAt !== void 0) {
69
+ body.scheduledAt = request.scheduledAt instanceof Date ? request.scheduledAt.toISOString() : request.scheduledAt;
70
+ }
71
+ const payload = await this.#request(
72
+ "POST",
73
+ "/gateway/send-sms",
74
+ { body }
75
+ );
76
+ return payload.data;
77
+ }
78
+ /** List the devices registered to this account. */
79
+ async getDevices() {
80
+ const payload = await this.#request(
81
+ "GET",
82
+ "/gateway/devices"
83
+ );
84
+ return payload.data;
85
+ }
86
+ /** Fetch a single device by id. */
87
+ async getDevice(deviceId) {
88
+ const payload = await this.#request(
89
+ "GET",
90
+ `/gateway/devices/${encodeURIComponent(deviceId)}`
91
+ );
92
+ return payload.data;
93
+ }
94
+ /** Make a device the default sender for requests that omit `deviceId`. */
95
+ async setDefaultDevice(deviceId) {
96
+ const payload = await this.#request(
97
+ "POST",
98
+ `/gateway/devices/${encodeURIComponent(deviceId)}/set-default`
99
+ );
100
+ return payload.data;
101
+ }
102
+ /** Page through a device's sent and received messages. */
103
+ async getMessages(deviceId, options = {}) {
104
+ return await this.#request(
105
+ "GET",
106
+ `/gateway/devices/${encodeURIComponent(deviceId)}/messages`,
107
+ {
108
+ query: {
109
+ type: options.type,
110
+ page: options.page,
111
+ limit: options.limit,
112
+ search: options.search
113
+ }
114
+ }
115
+ );
116
+ }
117
+ /** Fetch a single message, including its delivery status. */
118
+ async getSms(deviceId, smsId) {
119
+ const payload = await this.#request(
120
+ "GET",
121
+ `/gateway/devices/${encodeURIComponent(deviceId)}/sms/${encodeURIComponent(smsId)}`
122
+ );
123
+ return payload.data;
124
+ }
125
+ /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
126
+ async getSmsBatch(deviceId, smsBatchId) {
127
+ const payload = await this.#request(
128
+ "GET",
129
+ `/gateway/devices/${encodeURIComponent(deviceId)}/sms-batch/${encodeURIComponent(smsBatchId)}`
130
+ );
131
+ return payload.data;
132
+ }
133
+ async #request(method, path, init = {}) {
134
+ const url = new URL(`${this.#baseUrl}${path}`);
135
+ for (const [key, value] of Object.entries(init.query ?? {})) {
136
+ if (value !== void 0) {
137
+ url.searchParams.set(key, String(value));
138
+ }
139
+ }
140
+ const headers = {
141
+ "x-api-key": this.#apiKey,
142
+ "user-agent": CLIENT_ID,
143
+ "x-sdk-client": CLIENT_ID,
144
+ accept: "application/json"
145
+ };
146
+ if (init.body !== void 0) {
147
+ headers["content-type"] = "application/json";
148
+ }
149
+ const response = await fetch(url, {
150
+ method,
151
+ headers,
152
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
153
+ });
154
+ const payload = await readBody(response);
155
+ if (!response.ok) {
156
+ throw new TextbeeError(response.status, errorMessage(payload, response.status), payload);
157
+ }
158
+ return payload;
159
+ }
160
+ };
161
+ async function readBody(response) {
162
+ const text = await response.text();
163
+ if (!text) {
164
+ return void 0;
165
+ }
166
+ try {
167
+ return JSON.parse(text);
168
+ } catch {
169
+ return text;
170
+ }
171
+ }
172
+ function errorMessage(payload, status) {
173
+ if (payload && typeof payload === "object") {
174
+ const record = payload;
175
+ if (typeof record.error === "string" && record.error) {
176
+ return record.error;
177
+ }
178
+ if (typeof record.message === "string" && record.message) {
179
+ return record.message;
180
+ }
181
+ }
182
+ return `HTTP ${status}`;
183
+ }
184
+
185
+ // src/webhooks.ts
186
+ async function verifyWebhookSignature(options) {
187
+ const { payload, signature, signingSecret } = options;
188
+ if (!signature || !signingSecret) {
189
+ return false;
190
+ }
191
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
192
+ const expected = await hmacSha256Hex(signingSecret, body);
193
+ return constantTimeEqual(expected, signature.toLowerCase());
194
+ }
195
+ async function hmacSha256Hex(secret, message) {
196
+ const encoder = new TextEncoder();
197
+ const key = await crypto.subtle.importKey(
198
+ "raw",
199
+ encoder.encode(secret),
200
+ { name: "HMAC", hash: "SHA-256" },
201
+ false,
202
+ ["sign"]
203
+ );
204
+ const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
205
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
206
+ }
207
+ function constantTimeEqual(a, b) {
208
+ if (a.length !== b.length) {
209
+ return false;
210
+ }
211
+ let mismatch = 0;
212
+ for (let i = 0; i < a.length; i++) {
213
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
214
+ }
215
+ return mismatch === 0;
216
+ }
217
+ // Annotate the CommonJS export names for ESM import in node:
218
+ 0 && (module.exports = {
219
+ Textbee,
220
+ TextbeeError,
221
+ verifyWebhookSignature
222
+ });
@@ -0,0 +1,189 @@
1
+ interface TextbeeOptions {
2
+ /** API key from the textbee dashboard. Sent as the `x-api-key` header. */
3
+ apiKey: string;
4
+ /** Override the API base URL. Defaults to `https://api.textbee.dev/api/v1`. */
5
+ baseUrl?: string;
6
+ }
7
+ interface SendSmsRequest {
8
+ /** The message body. */
9
+ message: string;
10
+ /** Phone numbers to send to, in international format. */
11
+ recipients: string[];
12
+ /**
13
+ * Which phone sends the message. Get ids from `getDevices()`.
14
+ *
15
+ * Omit it and textbee picks the sender for you: your default device first,
16
+ * otherwise the enabled device with the most recent heartbeat. A malformed id
17
+ * fails with 400 rather than falling back, so a typo cannot send from the
18
+ * wrong phone.
19
+ */
20
+ deviceId?: string;
21
+ /**
22
+ * Which SIM sends the message on a multi-SIM phone.
23
+ *
24
+ * This value is not validated. An id that does not match a SIM currently in
25
+ * the phone is ignored, and the message goes out from the SIM configured in
26
+ * the app's settings, or the system default. Nothing errors, so confirm the
27
+ * SIM by checking which number the message arrived from.
28
+ *
29
+ * Find the id in the textbee Android app under Dashboard, "SIM Cards", where
30
+ * each SIM shows its number with a copy button.
31
+ */
32
+ simSubscriptionId?: number;
33
+ /**
34
+ * Send later instead of now, as an ISO 8601 string or a `Date`.
35
+ *
36
+ * Must be in the future, and up to 72 hours ahead. Omit for an immediate send.
37
+ */
38
+ scheduledAt?: string | Date;
39
+ }
40
+ /** Returned when the message was accepted for delivery and queued. */
41
+ interface SendSmsQueuedResponse {
42
+ success: true;
43
+ message: string;
44
+ smsBatchId: string;
45
+ recipientCount: number;
46
+ }
47
+ /** Returned when the message was dispatched to the device immediately. */
48
+ interface SendSmsImmediateResponse {
49
+ successCount: number;
50
+ [key: string]: unknown;
51
+ }
52
+ /**
53
+ * The send response shape depends on how the message was dispatched. Narrow it
54
+ * with `'smsBatchId' in result` before reading batch fields.
55
+ */
56
+ type SendSmsResponse = SendSmsQueuedResponse | SendSmsImmediateResponse;
57
+ interface GetMessagesOptions {
58
+ /** Filter by direction. Defaults to `all`. */
59
+ type?: 'all' | 'sent' | 'received';
60
+ /** 1-based page number. Defaults to 1. */
61
+ page?: number;
62
+ /** Items per page. Defaults to 50, capped at 100 by the API. */
63
+ limit?: number;
64
+ /** Free text match across the message body, recipient, and sender. */
65
+ search?: string;
66
+ }
67
+ interface Device {
68
+ _id: string;
69
+ name?: string;
70
+ brand?: string;
71
+ manufacturer?: string;
72
+ model?: string;
73
+ enabled: boolean;
74
+ isDefault: boolean;
75
+ lastHeartbeat?: string;
76
+ sentSMSCount: number;
77
+ receivedSMSCount: number;
78
+ os?: string;
79
+ osVersion?: string;
80
+ appVersionName?: string;
81
+ appVersionCode?: number;
82
+ createdAt?: string;
83
+ updatedAt?: string;
84
+ }
85
+ interface Message {
86
+ _id: string;
87
+ message: string;
88
+ type: 'sent' | 'received';
89
+ status: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
90
+ sender?: string;
91
+ recipient?: string;
92
+ requestedAt?: string;
93
+ dispatchedAt?: string;
94
+ sentAt?: string;
95
+ deliveredAt?: string;
96
+ failedAt?: string;
97
+ receivedAt?: string;
98
+ errorCode?: string;
99
+ errorMessage?: string;
100
+ simSubscriptionId?: number;
101
+ smsBatch?: string;
102
+ createdAt?: string;
103
+ updatedAt?: string;
104
+ }
105
+ interface SmsBatch {
106
+ _id: string;
107
+ message: string;
108
+ recipientCount: number;
109
+ recipientPreview?: string;
110
+ successCount: number;
111
+ failureCount: number;
112
+ status: 'pending' | 'processing' | 'completed' | 'partial_success' | 'failed' | 'unknown';
113
+ error?: string;
114
+ completedAt?: string;
115
+ createdAt?: string;
116
+ updatedAt?: string;
117
+ }
118
+ interface PaginationMeta {
119
+ page: number;
120
+ limit: number;
121
+ total: number;
122
+ totalPages: number;
123
+ }
124
+ interface MessagesPage {
125
+ data: Message[];
126
+ meta: PaginationMeta;
127
+ }
128
+ interface SmsBatchResult {
129
+ batch: SmsBatch;
130
+ messages: Message[];
131
+ }
132
+ /** Event names textbee sends in a webhook payload. */
133
+ type WebhookEvent = 'MESSAGE_RECEIVED' | 'MESSAGE_SENT' | 'MESSAGE_DELIVERED' | 'MESSAGE_FAILED' | 'UNKNOWN_STATE' | 'SMS_STATUS_UPDATED';
134
+
135
+ declare class Textbee {
136
+ #private;
137
+ constructor(options: TextbeeOptions);
138
+ /** Send an SMS to one or more recipients. */
139
+ sendSms(request: SendSmsRequest): Promise<SendSmsResponse>;
140
+ /** List the devices registered to this account. */
141
+ getDevices(): Promise<Device[]>;
142
+ /** Fetch a single device by id. */
143
+ getDevice(deviceId: string): Promise<Device>;
144
+ /** Make a device the default sender for requests that omit `deviceId`. */
145
+ setDefaultDevice(deviceId: string): Promise<Device>;
146
+ /** Page through a device's sent and received messages. */
147
+ getMessages(deviceId: string, options?: GetMessagesOptions): Promise<MessagesPage>;
148
+ /** Fetch a single message, including its delivery status. */
149
+ getSms(deviceId: string, smsId: string): Promise<Message>;
150
+ /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
151
+ getSmsBatch(deviceId: string, smsBatchId: string): Promise<SmsBatchResult>;
152
+ }
153
+
154
+ /**
155
+ * Thrown when the textbee API answers with a non-2xx status.
156
+ *
157
+ * Network failures are not wrapped: those reject with whatever `fetch` threw.
158
+ */
159
+ declare class TextbeeError extends Error {
160
+ /** HTTP status code returned by the API. */
161
+ readonly status: number;
162
+ /** Parsed response body, or the raw text when the body was not JSON. */
163
+ readonly body: unknown;
164
+ constructor(status: number, message: string, body?: unknown);
165
+ }
166
+
167
+ interface VerifyWebhookSignatureOptions {
168
+ /**
169
+ * The webhook request body. Prefer the raw body string.
170
+ *
171
+ * An object works too, but only because parsing and re-serializing usually
172
+ * preserves key order. Frameworks that rebuild the body can break that, so
173
+ * read the raw body when your server makes it available.
174
+ */
175
+ payload: string | object;
176
+ /** Value of the `X-Signature` request header. */
177
+ signature: string;
178
+ /** Signing secret shown with the webhook subscription in the dashboard. */
179
+ signingSecret: string;
180
+ }
181
+ /**
182
+ * Check that a webhook really came from textbee.
183
+ *
184
+ * textbee signs deliveries with HMAC-SHA256 over the request body and sends the
185
+ * hex digest in the `X-Signature` header. Comparison is constant time.
186
+ */
187
+ declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
188
+
189
+ export { type Device, type GetMessagesOptions, type Message, type MessagesPage, type PaginationMeta, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, verifyWebhookSignature };
@@ -0,0 +1,189 @@
1
+ interface TextbeeOptions {
2
+ /** API key from the textbee dashboard. Sent as the `x-api-key` header. */
3
+ apiKey: string;
4
+ /** Override the API base URL. Defaults to `https://api.textbee.dev/api/v1`. */
5
+ baseUrl?: string;
6
+ }
7
+ interface SendSmsRequest {
8
+ /** The message body. */
9
+ message: string;
10
+ /** Phone numbers to send to, in international format. */
11
+ recipients: string[];
12
+ /**
13
+ * Which phone sends the message. Get ids from `getDevices()`.
14
+ *
15
+ * Omit it and textbee picks the sender for you: your default device first,
16
+ * otherwise the enabled device with the most recent heartbeat. A malformed id
17
+ * fails with 400 rather than falling back, so a typo cannot send from the
18
+ * wrong phone.
19
+ */
20
+ deviceId?: string;
21
+ /**
22
+ * Which SIM sends the message on a multi-SIM phone.
23
+ *
24
+ * This value is not validated. An id that does not match a SIM currently in
25
+ * the phone is ignored, and the message goes out from the SIM configured in
26
+ * the app's settings, or the system default. Nothing errors, so confirm the
27
+ * SIM by checking which number the message arrived from.
28
+ *
29
+ * Find the id in the textbee Android app under Dashboard, "SIM Cards", where
30
+ * each SIM shows its number with a copy button.
31
+ */
32
+ simSubscriptionId?: number;
33
+ /**
34
+ * Send later instead of now, as an ISO 8601 string or a `Date`.
35
+ *
36
+ * Must be in the future, and up to 72 hours ahead. Omit for an immediate send.
37
+ */
38
+ scheduledAt?: string | Date;
39
+ }
40
+ /** Returned when the message was accepted for delivery and queued. */
41
+ interface SendSmsQueuedResponse {
42
+ success: true;
43
+ message: string;
44
+ smsBatchId: string;
45
+ recipientCount: number;
46
+ }
47
+ /** Returned when the message was dispatched to the device immediately. */
48
+ interface SendSmsImmediateResponse {
49
+ successCount: number;
50
+ [key: string]: unknown;
51
+ }
52
+ /**
53
+ * The send response shape depends on how the message was dispatched. Narrow it
54
+ * with `'smsBatchId' in result` before reading batch fields.
55
+ */
56
+ type SendSmsResponse = SendSmsQueuedResponse | SendSmsImmediateResponse;
57
+ interface GetMessagesOptions {
58
+ /** Filter by direction. Defaults to `all`. */
59
+ type?: 'all' | 'sent' | 'received';
60
+ /** 1-based page number. Defaults to 1. */
61
+ page?: number;
62
+ /** Items per page. Defaults to 50, capped at 100 by the API. */
63
+ limit?: number;
64
+ /** Free text match across the message body, recipient, and sender. */
65
+ search?: string;
66
+ }
67
+ interface Device {
68
+ _id: string;
69
+ name?: string;
70
+ brand?: string;
71
+ manufacturer?: string;
72
+ model?: string;
73
+ enabled: boolean;
74
+ isDefault: boolean;
75
+ lastHeartbeat?: string;
76
+ sentSMSCount: number;
77
+ receivedSMSCount: number;
78
+ os?: string;
79
+ osVersion?: string;
80
+ appVersionName?: string;
81
+ appVersionCode?: number;
82
+ createdAt?: string;
83
+ updatedAt?: string;
84
+ }
85
+ interface Message {
86
+ _id: string;
87
+ message: string;
88
+ type: 'sent' | 'received';
89
+ status: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
90
+ sender?: string;
91
+ recipient?: string;
92
+ requestedAt?: string;
93
+ dispatchedAt?: string;
94
+ sentAt?: string;
95
+ deliveredAt?: string;
96
+ failedAt?: string;
97
+ receivedAt?: string;
98
+ errorCode?: string;
99
+ errorMessage?: string;
100
+ simSubscriptionId?: number;
101
+ smsBatch?: string;
102
+ createdAt?: string;
103
+ updatedAt?: string;
104
+ }
105
+ interface SmsBatch {
106
+ _id: string;
107
+ message: string;
108
+ recipientCount: number;
109
+ recipientPreview?: string;
110
+ successCount: number;
111
+ failureCount: number;
112
+ status: 'pending' | 'processing' | 'completed' | 'partial_success' | 'failed' | 'unknown';
113
+ error?: string;
114
+ completedAt?: string;
115
+ createdAt?: string;
116
+ updatedAt?: string;
117
+ }
118
+ interface PaginationMeta {
119
+ page: number;
120
+ limit: number;
121
+ total: number;
122
+ totalPages: number;
123
+ }
124
+ interface MessagesPage {
125
+ data: Message[];
126
+ meta: PaginationMeta;
127
+ }
128
+ interface SmsBatchResult {
129
+ batch: SmsBatch;
130
+ messages: Message[];
131
+ }
132
+ /** Event names textbee sends in a webhook payload. */
133
+ type WebhookEvent = 'MESSAGE_RECEIVED' | 'MESSAGE_SENT' | 'MESSAGE_DELIVERED' | 'MESSAGE_FAILED' | 'UNKNOWN_STATE' | 'SMS_STATUS_UPDATED';
134
+
135
+ declare class Textbee {
136
+ #private;
137
+ constructor(options: TextbeeOptions);
138
+ /** Send an SMS to one or more recipients. */
139
+ sendSms(request: SendSmsRequest): Promise<SendSmsResponse>;
140
+ /** List the devices registered to this account. */
141
+ getDevices(): Promise<Device[]>;
142
+ /** Fetch a single device by id. */
143
+ getDevice(deviceId: string): Promise<Device>;
144
+ /** Make a device the default sender for requests that omit `deviceId`. */
145
+ setDefaultDevice(deviceId: string): Promise<Device>;
146
+ /** Page through a device's sent and received messages. */
147
+ getMessages(deviceId: string, options?: GetMessagesOptions): Promise<MessagesPage>;
148
+ /** Fetch a single message, including its delivery status. */
149
+ getSms(deviceId: string, smsId: string): Promise<Message>;
150
+ /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
151
+ getSmsBatch(deviceId: string, smsBatchId: string): Promise<SmsBatchResult>;
152
+ }
153
+
154
+ /**
155
+ * Thrown when the textbee API answers with a non-2xx status.
156
+ *
157
+ * Network failures are not wrapped: those reject with whatever `fetch` threw.
158
+ */
159
+ declare class TextbeeError extends Error {
160
+ /** HTTP status code returned by the API. */
161
+ readonly status: number;
162
+ /** Parsed response body, or the raw text when the body was not JSON. */
163
+ readonly body: unknown;
164
+ constructor(status: number, message: string, body?: unknown);
165
+ }
166
+
167
+ interface VerifyWebhookSignatureOptions {
168
+ /**
169
+ * The webhook request body. Prefer the raw body string.
170
+ *
171
+ * An object works too, but only because parsing and re-serializing usually
172
+ * preserves key order. Frameworks that rebuild the body can break that, so
173
+ * read the raw body when your server makes it available.
174
+ */
175
+ payload: string | object;
176
+ /** Value of the `X-Signature` request header. */
177
+ signature: string;
178
+ /** Signing secret shown with the webhook subscription in the dashboard. */
179
+ signingSecret: string;
180
+ }
181
+ /**
182
+ * Check that a webhook really came from textbee.
183
+ *
184
+ * textbee signs deliveries with HMAC-SHA256 over the request body and sends the
185
+ * hex digest in the `X-Signature` header. Comparison is constant time.
186
+ */
187
+ declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
188
+
189
+ export { type Device, type GetMessagesOptions, type Message, type MessagesPage, type PaginationMeta, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, verifyWebhookSignature };
package/dist/index.js ADDED
@@ -0,0 +1,193 @@
1
+ // src/errors.ts
2
+ var TextbeeError = class extends Error {
3
+ /** HTTP status code returned by the API. */
4
+ status;
5
+ /** Parsed response body, or the raw text when the body was not JSON. */
6
+ body;
7
+ constructor(status, message, body) {
8
+ super(message);
9
+ this.name = "TextbeeError";
10
+ this.status = status;
11
+ this.body = body;
12
+ }
13
+ };
14
+
15
+ // src/client.ts
16
+ var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
17
+ var CLIENT_ID = `textbee-js/${"0.0.1"}`;
18
+ var Textbee = class {
19
+ #apiKey;
20
+ #baseUrl;
21
+ constructor(options) {
22
+ if (!options?.apiKey) {
23
+ throw new TypeError("Textbee requires an apiKey");
24
+ }
25
+ this.#apiKey = options.apiKey;
26
+ this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
27
+ }
28
+ /** Send an SMS to one or more recipients. */
29
+ async sendSms(request) {
30
+ const body = {
31
+ message: request.message,
32
+ recipients: request.recipients
33
+ };
34
+ if (request.deviceId !== void 0) {
35
+ body.deviceId = request.deviceId;
36
+ }
37
+ if (request.simSubscriptionId !== void 0) {
38
+ body.simSubscriptionId = request.simSubscriptionId;
39
+ }
40
+ if (request.scheduledAt !== void 0) {
41
+ body.scheduledAt = request.scheduledAt instanceof Date ? request.scheduledAt.toISOString() : request.scheduledAt;
42
+ }
43
+ const payload = await this.#request(
44
+ "POST",
45
+ "/gateway/send-sms",
46
+ { body }
47
+ );
48
+ return payload.data;
49
+ }
50
+ /** List the devices registered to this account. */
51
+ async getDevices() {
52
+ const payload = await this.#request(
53
+ "GET",
54
+ "/gateway/devices"
55
+ );
56
+ return payload.data;
57
+ }
58
+ /** Fetch a single device by id. */
59
+ async getDevice(deviceId) {
60
+ const payload = await this.#request(
61
+ "GET",
62
+ `/gateway/devices/${encodeURIComponent(deviceId)}`
63
+ );
64
+ return payload.data;
65
+ }
66
+ /** Make a device the default sender for requests that omit `deviceId`. */
67
+ async setDefaultDevice(deviceId) {
68
+ const payload = await this.#request(
69
+ "POST",
70
+ `/gateway/devices/${encodeURIComponent(deviceId)}/set-default`
71
+ );
72
+ return payload.data;
73
+ }
74
+ /** Page through a device's sent and received messages. */
75
+ async getMessages(deviceId, options = {}) {
76
+ return await this.#request(
77
+ "GET",
78
+ `/gateway/devices/${encodeURIComponent(deviceId)}/messages`,
79
+ {
80
+ query: {
81
+ type: options.type,
82
+ page: options.page,
83
+ limit: options.limit,
84
+ search: options.search
85
+ }
86
+ }
87
+ );
88
+ }
89
+ /** Fetch a single message, including its delivery status. */
90
+ async getSms(deviceId, smsId) {
91
+ const payload = await this.#request(
92
+ "GET",
93
+ `/gateway/devices/${encodeURIComponent(deviceId)}/sms/${encodeURIComponent(smsId)}`
94
+ );
95
+ return payload.data;
96
+ }
97
+ /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
98
+ async getSmsBatch(deviceId, smsBatchId) {
99
+ const payload = await this.#request(
100
+ "GET",
101
+ `/gateway/devices/${encodeURIComponent(deviceId)}/sms-batch/${encodeURIComponent(smsBatchId)}`
102
+ );
103
+ return payload.data;
104
+ }
105
+ async #request(method, path, init = {}) {
106
+ const url = new URL(`${this.#baseUrl}${path}`);
107
+ for (const [key, value] of Object.entries(init.query ?? {})) {
108
+ if (value !== void 0) {
109
+ url.searchParams.set(key, String(value));
110
+ }
111
+ }
112
+ const headers = {
113
+ "x-api-key": this.#apiKey,
114
+ "user-agent": CLIENT_ID,
115
+ "x-sdk-client": CLIENT_ID,
116
+ accept: "application/json"
117
+ };
118
+ if (init.body !== void 0) {
119
+ headers["content-type"] = "application/json";
120
+ }
121
+ const response = await fetch(url, {
122
+ method,
123
+ headers,
124
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
125
+ });
126
+ const payload = await readBody(response);
127
+ if (!response.ok) {
128
+ throw new TextbeeError(response.status, errorMessage(payload, response.status), payload);
129
+ }
130
+ return payload;
131
+ }
132
+ };
133
+ async function readBody(response) {
134
+ const text = await response.text();
135
+ if (!text) {
136
+ return void 0;
137
+ }
138
+ try {
139
+ return JSON.parse(text);
140
+ } catch {
141
+ return text;
142
+ }
143
+ }
144
+ function errorMessage(payload, status) {
145
+ if (payload && typeof payload === "object") {
146
+ const record = payload;
147
+ if (typeof record.error === "string" && record.error) {
148
+ return record.error;
149
+ }
150
+ if (typeof record.message === "string" && record.message) {
151
+ return record.message;
152
+ }
153
+ }
154
+ return `HTTP ${status}`;
155
+ }
156
+
157
+ // src/webhooks.ts
158
+ async function verifyWebhookSignature(options) {
159
+ const { payload, signature, signingSecret } = options;
160
+ if (!signature || !signingSecret) {
161
+ return false;
162
+ }
163
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
164
+ const expected = await hmacSha256Hex(signingSecret, body);
165
+ return constantTimeEqual(expected, signature.toLowerCase());
166
+ }
167
+ async function hmacSha256Hex(secret, message) {
168
+ const encoder = new TextEncoder();
169
+ const key = await crypto.subtle.importKey(
170
+ "raw",
171
+ encoder.encode(secret),
172
+ { name: "HMAC", hash: "SHA-256" },
173
+ false,
174
+ ["sign"]
175
+ );
176
+ const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
177
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
178
+ }
179
+ function constantTimeEqual(a, b) {
180
+ if (a.length !== b.length) {
181
+ return false;
182
+ }
183
+ let mismatch = 0;
184
+ for (let i = 0; i < a.length; i++) {
185
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
186
+ }
187
+ return mismatch === 0;
188
+ }
189
+ export {
190
+ Textbee,
191
+ TextbeeError,
192
+ verifyWebhookSignature
193
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@textbee/sdk",
3
+ "version": "0.0.1",
4
+ "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
5
+ "license": "MIT",
6
+ "packageManager": "pnpm@9.14.2",
7
+ "type": "module",
8
+ "main": "./dist/index.cjs",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.cts",
19
+ "default": "./dist/index.cjs"
20
+ }
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "LICENSE",
26
+ "README.md"
27
+ ],
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/textbee/textbee-js.git"
38
+ },
39
+ "homepage": "https://textbee.dev",
40
+ "bugs": {
41
+ "url": "https://github.com/textbee/textbee-js/issues"
42
+ },
43
+ "keywords": [
44
+ "sms",
45
+ "sms-gateway",
46
+ "textbee",
47
+ "android"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22",
57
+ "tsup": "^8.3.5",
58
+ "typescript": "^5.7.2",
59
+ "vitest": "^2.1.8"
60
+ }
61
+ }