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 @@ -71,17 +71,33 @@ await textbee.setDefaultDevice(deviceId)

## Messages and delivery status

History is account-level: one call covers every device, and `deviceIds` narrows it.

```js
// Paginated history, filterable and searchable
const { data, meta } = await textbee.getMessages(deviceId, {
type: 'received', // filter is lowercase: 'all' | 'sent' | 'received'
// Paginated history across the whole account, filterable and searchable
const { data, meta } = await textbee.getMessages({
direction: 'received', // 'all' | 'sent' | 'received'
deviceIds: [deviceId], // omit for every device
status: 'delivered', // delivery state; direction=sent + 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
page: 1,
limit: 50,
search: 'invoice',
})

// Each message reports its direction uppercase, so compare accordingly
data.filter((m) => m.type === 'RECEIVED')
// direction on each message is lowercase and feeds straight back into filters
data.filter((m) => m.direction === 'received')

// Which recipients of a bulk send failed: filter by the batch a send returned
const { smsBatchId } = await textbee.sendSms({ recipients, message })
const failed = await textbee.getMessages({ smsBatchId, status: 'failed' })

// Drain everything matching a filter: iterateMessages follows the
// pagination cursor for you until there is nothing left
for await (const message of textbee.iterateMessages({ direction: 'received', order: 'asc' })) {
console.log(message.sender, message.message)
}

// A single message and its current status
const sms = await textbee.getSms(deviceId, smsId)
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.0.3",
"version": "0.1.0",
"description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
"license": "MIT",
"packageManager": "pnpm@9.14.2",
Expand Down
72 changes: 53 additions & 19 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { TextbeeError } from './errors'
import type {
Device,
GetMessagesOptions,
IterateMessagesOptions,
ListMessagesOptions,
Message,
MessagesPage,
MessageList,
SendSmsRequest,
SendSmsResponse,
SmsBatchResult,
Expand Down Expand Up @@ -88,25 +89,54 @@ export class Textbee {
return payload.data
}

/** Page through a device's sent and received messages. */
async getMessages(
deviceId: string,
options: GetMessagesOptions = {},
): Promise<MessagesPage> {
// This endpoint returns { data, meta } with no outer wrapper, so the whole
/**
* Page through the account's sent and received messages, filtered by
* device, batch, direction, status, text, and time range.
*/
async getMessages(options: ListMessagesOptions = {}): Promise<MessageList> {
// The endpoint returns { data, meta } with no outer wrapper, so the whole
// payload is the result.
return await this.#request<MessagesPage>(
'GET',
`/gateway/devices/${encodeURIComponent(deviceId)}/messages`,
{
query: {
type: options.type,
page: options.page,
limit: options.limit,
search: options.search,
},
return await this.#request<MessageList>('GET', '/gateway/messages', {
query: {
deviceIds: options.deviceIds?.length
? options.deviceIds.join(',')
: undefined,
smsBatchId: options.smsBatchId,
direction: options.direction,
status: options.status,
search: options.search,
from: toIsoString(options.from),
to: toIsoString(options.to),
order: options.order,
page: options.page,
limit: options.limit,
cursor: options.cursor,
},
)
})
}

/**
* 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.
*
* ```ts
* for await (const message of textbee.iterateMessages({ direction: 'received' })) {
* handle(message)
* }
* ```
*/
async *iterateMessages(
options: IterateMessagesOptions = {},
): AsyncGenerator<Message, void, undefined> {
let page = await this.getMessages(options)
yield* page.data

while (page.meta.nextCursor) {
page = await this.getMessages({ ...options, cursor: page.meta.nextCursor })
yield* page.data
}
}

/** Fetch a single message, including its delivery status. */
Expand Down Expand Up @@ -168,6 +198,10 @@ export class Textbee {
}
}

function toIsoString(value: string | Date | undefined): string | undefined {
return value instanceof Date ? value.toISOString() : value
}

async function readBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) {
Expand Down
8 changes: 5 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ export { verifyWebhookSignature } from './webhooks'
export type { VerifyWebhookSignatureOptions } from './webhooks'
export type {
Device,
GetMessagesOptions,
IterateMessagesOptions,
ListMessagesOptions,
Message,
MessagesPage,
PaginationMeta,
MessageDevice,
MessageList,
MessageListMeta,
SendSmsImmediateResponse,
SendSmsQueuedResponse,
SendSmsRequest,
Expand Down
91 changes: 78 additions & 13 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,61 @@ export interface SendSmsImmediateResponse {
*/
export type SendSmsResponse = SendSmsQueuedResponse | SendSmsImmediateResponse

export interface GetMessagesOptions {
export interface ListMessagesOptions {
/** Only messages from these devices. Omit for every device on the account. */
deviceIds?: string[]

/**
* Only messages from this batch, using the `smsBatchId` a send returns.
* Combine with `status: 'failed'` to list the recipients that failed.
*/
smsBatchId?: string

/** Filter by direction. Defaults to `all`. */
type?: 'all' | 'sent' | 'received'
direction?: 'all' | 'sent' | 'received'

/** Filter by delivery state, e.g. `failed` to list sends that failed. */
status?:
| 'pending'
| 'dispatched'
| 'sent'
| 'delivered'
| 'failed'
| 'unknown'
| 'received'

/** 1-based page number. Defaults to 1. */
/** Free text match across the message body, recipient, and sender. */
search?: string

/**
* Inclusive lower bound on when the platform stored the message. A string
* must carry an explicit timezone (`2026-08-01T00:00:00Z`); a bare date like
* `2026-08-01` is read as UTC midnight.
*/
from?: string | Date

/** Exclusive upper bound, same formats as `from`. */
to?: string | Date

/** `desc` (default) for newest first, `asc` to walk forward when polling. */
order?: 'desc' | 'asc'

/** 1-based page number. Defaults to 1. Mutually exclusive with `cursor`. */
page?: number

/** Items per page. Defaults to 50, capped at 100 by the API. */
limit?: number

/** Free text match across the message body, recipient, and sender. */
search?: string
/**
* Opaque position from a previous response's `meta.nextCursor`. When set,
* `meta` switches to cursor mode and omits the total count.
*/
cursor?: string
}

/** Everything `iterateMessages` accepts: a filter set, minus the paging knobs it drives itself. */
export type IterateMessagesOptions = Omit<ListMessagesOptions, 'page' | 'cursor'>

export interface Device {
_id: string
name?: string
Expand All @@ -97,14 +138,30 @@ export interface Device {
updatedAt?: string
}

export interface MessageDevice {
_id: string
brand?: string
model?: string
buildId?: string
enabled?: boolean
}

export interface Message {
_id: string
message: string
/**
* Direction, uppercase. Note the asymmetry with the `type` filter accepted by
* getMessages, which is lowercase.
* Direction, uppercase.
*
* @deprecated Read `direction` instead: it is lowercase and matches the
* `direction` filter, so a response value feeds straight back into a query.
*/
type: 'SENT' | 'RECEIVED'
/** Direction, lowercase. Matches the `direction` filter values. */
direction?: 'sent' | 'received'
/** Message channel. Currently always `sms`; absent means `sms`. */
channel?: string
/** The sending or receiving device, populated by the history endpoints. */
device?: MessageDevice
/** Lowercase, and absent on messages stored before status tracking. */
status?:
| 'pending'
Expand Down Expand Up @@ -150,16 +207,24 @@ export interface SmsBatch {
updatedAt?: string
}

export interface PaginationMeta {
page: number
/**
* Meta for the account-level message list. Page mode carries the counters and
* the cursor fields; cursor mode carries `limit`, `nextCursor`, and `hasMore`
* only, skipping the expensive total count.
*/
export interface MessageListMeta {
limit: number
total: number
totalPages: number
page?: number
total?: number
totalPages?: number
/** Opaque position after the last message. Null on the final page. */
nextCursor?: string | null
hasMore?: boolean
}

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

export interface SmsBatchResult {
Expand Down
Loading