Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ await textbee.sendSms({
| --- | --- | --- |
| `deviceId` | `string` | Which phone sends the message. Omit it and textbee uses your default device, or the enabled device with the most recent heartbeat. |
| `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. |
| `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future, up to 72 hours ahead. |
| `scheduledAt` | `string \| Date` | Send later instead of now. ISO 8601 or a `Date`, must be in the future. |

```js
await textbee.sendSms({
Expand Down Expand Up @@ -76,9 +76,9 @@ History is account-level: one call covers every device, and `deviceIds` narrows
```js
// Paginated history across the whole account, filterable and searchable
const { data, meta } = await textbee.getMessages({
direction: 'received', // 'all' | 'sent' | 'received'
direction: 'sent', // 'all' | 'sent' | 'received'
deviceIds: [deviceId], // omit for every device
status: 'delivered', // delivery state; direction=sent + status=failed lists failed sends
status: 'delivered', // delivery state; status=failed lists failed sends
search: 'invoice',
from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone
to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count
Expand All @@ -87,7 +87,7 @@ const { data, meta } = await textbee.getMessages({
})

// direction on each message is lowercase and feeds straight back into filters
data.filter((m) => m.direction === 'received')
data.filter((m) => m.direction === 'sent')

// Which recipients of a bulk send failed: filter by the batch a send returned
const { smsBatchId } = await textbee.sendSms({ recipients, message })
Expand All @@ -102,10 +102,14 @@ for await (const message of textbee.iterateMessages({ direction: 'received', ord
// A single message and its current status
const sms = await textbee.getSms(deviceId, smsId)

// A whole batch, using the smsBatchId returned by sendSms
// A whole batch, using the smsBatchId returned by sendSms. This needs the id
// of the device that sent it; when textbee picked the device, use
// getMessages({ smsBatchId }) instead.
const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)
```

`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`).

`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.

## Verifying webhooks
Expand Down Expand Up @@ -211,10 +215,22 @@ try {
```js
new Textbee({
apiKey: 'your-api-key',
baseUrl: 'https://api.textbee.dev/api/v1', // override for self-hosted instances
baseUrl: 'https://api.textbee.dev/api/v1', // the default
})
```

### Self-hosted textbee

Point `baseUrl` at your own instance. The `/api/v1` suffix is optional, so all of these reach the same API:

```js
new Textbee({ apiKey, baseUrl: 'https://sms.example.com' })
new Textbee({ apiKey, baseUrl: 'https://sms.example.com/api/v1' })
new Textbee({ apiKey, baseUrl: 'http://localhost:3001' })
```

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.

## What is not covered yet

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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@textbee/sdk",
"version": "0.2.0",
"version": "0.3.0",
"description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
"license": "MIT",
"packageManager": "pnpm@9.14.2",
Expand Down
100 changes: 88 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
MessageList,
SendSmsRequest,
SendSmsResponse,
Sms,
SmsBatchResult,
TextbeeOptions,
} from './types'
Expand All @@ -31,7 +32,7 @@ export class Textbee {
}

this.#apiKey = options.apiKey
this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
this.#baseUrl = normalizeBaseUrl(options.baseUrl)
}

/** Send an SMS to one or more recipients. */
Expand Down Expand Up @@ -117,9 +118,10 @@ export class Textbee {

/**
* Iterate every message matching the filters, following the pagination
* cursor until the end. Use `order: 'asc'` with a `from` bound to drain
* forward when polling; keep the last message's `createdAt` (or track
* `meta.nextCursor` via `getMessages`) to resume the next poll.
* cursor until the end. `limit` is the page size, not a cap on the total.
* Use `order: 'asc'` with a `from` bound to drain forward when polling.
* `from` is inclusive, so a poll that resumes from the last `createdAt` sees
* that message again; skip ids you have already handled.
*
* ```ts
* for await (const message of textbee.iterateMessages({ direction: 'received' })) {
Expand All @@ -140,15 +142,19 @@ export class Textbee {
}

/** Fetch a single message, including its delivery status. */
async getSms(deviceId: string, smsId: string): Promise<Message> {
const payload = await this.#request<{ data: Message }>(
async getSms(deviceId: string, smsId: string): Promise<Sms> {
const payload = await this.#request<{ data: Sms }>(
'GET',
`/gateway/devices/${encodeURIComponent(deviceId)}/sms/${encodeURIComponent(smsId)}`,
)
return payload.data
}

/** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
/**
* Fetch a batch and every message in it, by the `smsBatchId` a send returns.
* Needs the id of the device that sent the batch. When textbee picked the
* device, use `getMessages({ smsBatchId })` instead.
*/
async getSmsBatch(
deviceId: string,
smsBatchId: string,
Expand Down Expand Up @@ -198,6 +204,66 @@ export class Textbee {
}
}

/**
* Resolve the API root from a base URL. A bare origin gets /api/v1, a path
* containing /api/v1 is cut there, and any other path is kept as given for
* proxies that map a prefix onto the API. Bad values throw rather than falling
* back, so a typo never sends a self-hoster's key to the public API.
*/
function normalizeBaseUrl(raw: string | undefined): string {
const value = raw?.trim()
if (!value) {
return DEFAULT_BASE_URL
}

const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
? value
: `https://${value}`

let url: URL
try {
url = new URL(withScheme)
} catch {
throw new TypeError(
'baseUrl is not a valid URL. Use your textbee api, ' +
'for example https://sms.example.com. The /api/v1 suffix is added for you.',
)
}

if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new TypeError(`baseUrl must be http or https, got "${url.protocol}"`)
}
// textbee never uses basic auth, and https://api.textbee.dev@evil.com reads
// like textbee while sending the key to evil.com.
if (url.username || url.password) {
throw new TypeError('baseUrl must not contain a username or password')
}

const segments = url.pathname.split('/').filter(Boolean)
let cut = -1
for (let i = segments.length - 2; i >= 0; i--) {
if (
segments[i]!.toLowerCase() === 'api' &&
segments[i + 1]!.toLowerCase() === 'v1'
) {
cut = i + 2
break
}
}

let path: string[]
if (segments.length === 0) {
path = ['api', 'v1']
} else if (cut === -1) {
path = segments
} else {
// Anything after /api/v1 is a route, not part of the root, so it is dropped.
path = [...segments.slice(0, cut - 2), 'api', 'v1']
}

return [url.origin, ...path].join('/')
}

function toIsoString(value: string | Date | undefined): string | undefined {
return value instanceof Date ? value.toISOString() : value
}
Expand All @@ -218,11 +284,21 @@ async function readBody(response: Response): Promise<unknown> {
function errorMessage(payload: unknown, status: number): string {
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>
if (typeof record.error === 'string' && record.error) {
return record.error
}
if (typeof record.message === 'string' && record.message) {
return record.message
// Framework errors ({ statusCode, message, error }) carry the detail in
// message, and error is only the reason phrase such as "Not Found".
const keys = 'statusCode' in record ? ['message', 'error'] : ['error', 'message']
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value) {
return value
}
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item) => typeof item === 'string')
) {
return value.join('; ')
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type {
SendSmsQueuedResponse,
SendSmsRequest,
SendSmsResponse,
SendSmsWarning,
Sms,
SmsBatch,
SmsBatchResult,
TextbeeOptions,
Expand Down
39 changes: 35 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ export interface TextbeeOptions {
/** API key from the textbee dashboard. Sent as the `x-api-key` header. */
apiKey: string

/** Override the API base URL. Defaults to `https://api.textbee.dev/api/v1`. */
/**
* Your self-hosted textbee API. Defaults to `https://api.textbee.dev/api/v1`.
*
* A bare origin such as `https://sms.example.com` gets `/api/v1` added. A URL
* that contains `/api/v1` is cut there, so a pasted endpoint URL still works.
* Any other path is used as given, for proxies that map a prefix onto the API.
* A value with no scheme gets `https://`. An invalid URL throws a `TypeError`
* rather than falling back, so a typo never sends your key to the public API.
*/
baseUrl?: string
}

Expand Down Expand Up @@ -39,22 +47,35 @@ export interface SendSmsRequest {
/**
* Send later instead of now, as an ISO 8601 string or a `Date`.
*
* Must be in the future, and up to 72 hours ahead. Omit for an immediate send.
* Must be in the future. Omit for an immediate send.
*/
scheduledAt?: string | Date
}

/** A reason the send will probably fail, such as `SMS_PERMISSION_MISSING`. */
export interface SendSmsWarning {
code: string
message: string
}

/** Returned when the message was accepted for delivery and queued. */
export interface SendSmsQueuedResponse {
success: true
message: string
smsBatchId: string
recipientCount: number
/** When a large send, spread over several waves, should finish. */
estimatedCompletionAt?: string
/** Present when the phone last reported a problem, such as the SMS permission off. */
warning?: SendSmsWarning
}

/** Returned when the message was dispatched to the device immediately. */
export interface SendSmsImmediateResponse {
successCount: number
failureCount?: number
/** Present when the phone last reported a problem, such as the SMS permission off. */
warning?: SendSmsWarning
[key: string]: unknown
}

Expand Down Expand Up @@ -140,6 +161,7 @@ export interface Device {

export interface MessageDevice {
_id: string
name?: string
brand?: string
model?: string
buildId?: string
Expand All @@ -160,7 +182,7 @@ export interface Message {
direction?: 'sent' | 'received'
/** Message channel. Currently always `sms`; absent means `sms`. */
channel?: string
/** The sending or receiving device, populated by the history endpoints. */
/** The sending or receiving device. */
device?: MessageDevice
/** Lowercase, and absent on messages stored before status tracking. */
status?:
Expand Down Expand Up @@ -241,14 +263,23 @@ export interface MessageListMeta {
hasMore?: boolean
}

/**
* A message as `getSms` and `getSmsBatch` return it: `device` is the id
* string, and `direction` is absent (read `type`).
*/
export interface Sms extends Omit<Message, 'device' | 'direction'> {
/** The id of the sending or receiving device. */
device?: string
}

export interface MessageList {
data: Message[]
meta: MessageListMeta
}

export interface SmsBatchResult {
batch: SmsBatch
messages: Message[]
messages: Sms[]
}

/** Event names textbee sends in a webhook payload. */
Expand Down
Loading
Loading