From afe1e85c37aef092849c930cd6fb0625a9cdc780 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 9 Aug 2026 13:06:01 +0300 Subject: [PATCH 1/3] feat: account-level getMessages, filters, and a cursor-draining iterator getMessages(options) now calls GET /gateway/messages with deviceIds, direction, status, search, from/to, order, and cursor support; the device-scoped getMessages(deviceId, options) form keeps working but is marked deprecated. iterateMessages() follows the pagination cursor to drain every match, which is the loop pollers would otherwise hand-roll. Message gains direction, channel, and device fields. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 +++++++++--- package.json | 2 +- src/client.ts | 94 +++++++++++++++++++++++++++++++++++++------- src/index.ts | 5 +++ src/types.ts | 92 ++++++++++++++++++++++++++++++++++++++++++- test/client.test.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 922727a..c467b28 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,29 @@ 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') + +// 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) @@ -90,6 +102,8 @@ const sms = await textbee.getSms(deviceId, smsId) const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId) ``` +The older `getMessages(deviceId, { type })` form still works but is deprecated; new code should pass an options object. + ## Verifying webhooks 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. diff --git a/package.json b/package.json index cfe8fc0..b3b163b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client.ts b/src/client.ts index 96f34cd..76ddae7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,7 +2,10 @@ import { TextbeeError } from './errors' import type { Device, GetMessagesOptions, + IterateMessagesOptions, + ListMessagesOptions, Message, + MessageList, MessagesPage, SendSmsRequest, SendSmsResponse, @@ -88,25 +91,84 @@ export class Textbee { return payload.data } - /** Page through a device's sent and received messages. */ + /** + * Page through the account's sent and received messages, filtered by + * device, direction, status, text, and time range. + */ + async getMessages(options?: ListMessagesOptions): Promise + /** + * Page through a device's sent and received messages. + * + * @deprecated Use `getMessages(options)` with `deviceIds: [deviceId]`. This + * form calls the deprecated device-scoped endpoint, which stays supported + * but no longer appears in the API reference. + */ async getMessages( deviceId: string, - options: GetMessagesOptions = {}, - ): Promise { - // This endpoint returns { data, meta } with no outer wrapper, so the whole + options?: GetMessagesOptions, + ): Promise + async getMessages( + deviceIdOrOptions?: string | ListMessagesOptions, + deviceOptions: GetMessagesOptions = {}, + ): Promise { + // Both endpoints return { data, meta } with no outer wrapper, so the whole // payload is the result. - return await this.#request( - 'GET', - `/gateway/devices/${encodeURIComponent(deviceId)}/messages`, - { - query: { - type: options.type, - page: options.page, - limit: options.limit, - search: options.search, + if (typeof deviceIdOrOptions === 'string') { + return await this.#request( + 'GET', + `/gateway/devices/${encodeURIComponent(deviceIdOrOptions)}/messages`, + { + query: { + type: deviceOptions.type, + page: deviceOptions.page, + limit: deviceOptions.limit, + search: deviceOptions.search, + }, }, + ) + } + + const options = deviceIdOrOptions ?? {} + return await this.#request('GET', '/gateway/messages', { + query: { + deviceIds: options.deviceIds?.length + ? options.deviceIds.join(',') + : undefined, + 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 { + 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. */ @@ -168,6 +230,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 { const text = await response.text() if (!text) { diff --git a/src/index.ts b/src/index.ts index 0b7e3b4..36fb7d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,12 @@ export type { VerifyWebhookSignatureOptions } from './webhooks' export type { Device, GetMessagesOptions, + IterateMessagesOptions, + ListMessagesOptions, Message, + MessageDevice, + MessageList, + MessageListMeta, MessagesPage, PaginationMeta, SendSmsImmediateResponse, diff --git a/src/types.ts b/src/types.ts index bd13799..b043e37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,55 @@ export interface GetMessagesOptions { search?: string } +export interface ListMessagesOptions { + /** Only messages from these devices. Omit for every device on the account. */ + deviceIds?: string[] + + /** Filter by direction. Defaults to `all`. */ + direction?: 'all' | 'sent' | 'received' + + /** Filter by delivery state, e.g. `failed` to list sends that failed. */ + status?: + | 'pending' + | 'dispatched' + | 'sent' + | 'delivered' + | 'failed' + | 'unknown' + | 'received' + + /** 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 + + /** + * 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 + export interface Device { _id: string name?: string @@ -97,14 +146,33 @@ 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. Present on messages from the account-level + * endpoint; absent from the deprecated device-scoped one. + */ + 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' @@ -162,6 +230,26 @@ export interface MessagesPage { meta: PaginationMeta } +/** + * 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 + page?: number + total?: number + totalPages?: number + /** Opaque position after the last message. Null on the final page. */ + nextCursor?: string | null + hasMore?: boolean +} + +export interface MessageList { + data: Message[] + meta: MessageListMeta +} + export interface SmsBatchResult { batch: SmsBatch messages: Message[] diff --git a/test/client.test.ts b/test/client.test.ts index ca68b71..3d21a10 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -220,6 +220,102 @@ describe('response envelopes', () => { expect(new URL(lastCall().url).search).toBe('') }) + it('routes the account-level getMessages overload to /gateway/messages', async () => { + const page = { + data: [ + { + _id: 'sms-1', + message: 'hi', + type: 'RECEIVED', + direction: 'received', + status: 'received', + }, + ], + meta: { page: 1, limit: 50, total: 1, totalPages: 1, nextCursor: null, hasMore: false }, + } + fetchMock.mockImplementation(async () => respond(200, page)) + + const result = await client().getMessages({ + deviceIds: ['dev-a', 'dev-b'], + direction: 'received', + status: 'received', + from: new Date('2026-08-01T00:00:00.000Z'), + to: '2026-09-01T00:00:00Z', + order: 'asc', + limit: 50, + }) + + expect(result).toEqual(page) + + const url = new URL(lastCall().url) + expect(url.pathname).toBe('/api/v1/gateway/messages') + expect(url.searchParams.get('deviceIds')).toBe('dev-a,dev-b') + expect(url.searchParams.get('direction')).toBe('received') + expect(url.searchParams.get('status')).toBe('received') + // Dates serialize to the explicit-timezone form the API requires + expect(url.searchParams.get('from')).toBe('2026-08-01T00:00:00.000Z') + expect(url.searchParams.get('to')).toBe('2026-09-01T00:00:00Z') + expect(url.searchParams.get('order')).toBe('asc') + expect(url.searchParams.get('type')).toBeNull() + }) + + it('sends no query at all for a bare account-level getMessages()', async () => { + fetchMock.mockImplementation(async () => respond(200, { data: [], meta: {} })) + + await client().getMessages() + + const url = new URL(lastCall().url) + expect(url.pathname).toBe('/api/v1/gateway/messages') + expect(url.search).toBe('') + }) + + it('iterateMessages follows nextCursor to the end and then stops', async () => { + const pages = [ + { + data: [{ _id: 'a' }, { _id: 'b' }], + meta: { page: 1, limit: 2, total: 5, totalPages: 3, nextCursor: 'cur-1', hasMore: true }, + }, + { + data: [{ _id: 'c' }, { _id: 'd' }], + meta: { limit: 2, nextCursor: 'cur-2', hasMore: true }, + }, + { + data: [{ _id: 'e' }], + meta: { limit: 2, nextCursor: null, hasMore: false }, + }, + ] + fetchMock.mockImplementation(async () => respond(200, pages[fetchMock.mock.calls.length - 1])) + + const seen: string[] = [] + for await (const message of client().iterateMessages({ order: 'asc' })) { + seen.push(message._id) + } + + expect(seen).toEqual(['a', 'b', 'c', 'd', 'e']) + expect(fetchMock).toHaveBeenCalledTimes(3) + // First call opens without a cursor; later calls carry the returned one + const cursors = fetchMock.mock.calls.map( + (call) => new URL(String(call?.[0])).searchParams.get('cursor'), + ) + expect(cursors).toEqual([null, 'cur-1', 'cur-2']) + }) + + it('iterateMessages stops early when the consumer breaks out', async () => { + fetchMock.mockImplementation(async () => + respond(200, { + data: [{ _id: 'a' }, { _id: 'b' }], + meta: { limit: 2, nextCursor: 'cur-1', hasMore: true }, + }), + ) + + for await (const message of client().iterateMessages()) { + void message + break + } + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + it('unwraps the nested getSmsBatch envelope', async () => { fetchMock.mockResolvedValue( respond(200, { From bcfadc92efa3f52330135fee894c443e1a5b9bba Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 9 Aug 2026 13:16:29 +0300 Subject: [PATCH 2/3] feat: smsBatchId filter on account-level getMessages Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++++ src/client.ts | 1 + src/types.ts | 6 ++++++ test/client.test.ts | 2 ++ 4 files changed, 13 insertions(+) diff --git a/README.md b/README.md index c467b28..c5971fe 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ const { data, meta } = await textbee.getMessages({ // 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' })) { diff --git a/src/client.ts b/src/client.ts index 76ddae7..464a3be 100644 --- a/src/client.ts +++ b/src/client.ts @@ -134,6 +134,7 @@ export class Textbee { deviceIds: options.deviceIds?.length ? options.deviceIds.join(',') : undefined, + smsBatchId: options.smsBatchId, direction: options.direction, status: options.status, search: options.search, diff --git a/src/types.ts b/src/types.ts index b043e37..7869969 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,12 @@ 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`. */ direction?: 'all' | 'sent' | 'received' diff --git a/test/client.test.ts b/test/client.test.ts index 3d21a10..cfafb55 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -237,6 +237,7 @@ describe('response envelopes', () => { const result = await client().getMessages({ deviceIds: ['dev-a', 'dev-b'], + smsBatchId: 'batch-1', direction: 'received', status: 'received', from: new Date('2026-08-01T00:00:00.000Z'), @@ -250,6 +251,7 @@ describe('response envelopes', () => { const url = new URL(lastCall().url) expect(url.pathname).toBe('/api/v1/gateway/messages') expect(url.searchParams.get('deviceIds')).toBe('dev-a,dev-b') + expect(url.searchParams.get('smsBatchId')).toBe('batch-1') expect(url.searchParams.get('direction')).toBe('received') expect(url.searchParams.get('status')).toBe('received') // Dates serialize to the explicit-timezone form the API requires From a1403e85cfd12047cf6cd7713f646cd9cb47db5e Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 9 Aug 2026 13:19:13 +0300 Subject: [PATCH 3/3] refactor: drop the device-scoped getMessages form The SDK is days old with no consumers, so getMessages takes a single options object and always calls the account-level endpoint. Anyone on 0.0.x keeps working against the still-served device-scoped route. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 -- src/client.ts | 39 +++------------------------------------ src/index.ts | 3 --- src/types.ts | 31 +------------------------------ test/client.test.ts | 34 ---------------------------------- 5 files changed, 4 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index c5971fe..f363f14 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,6 @@ const sms = await textbee.getSms(deviceId, smsId) const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId) ``` -The older `getMessages(deviceId, { type })` form still works but is deprecated; new code should pass an options object. - ## Verifying webhooks 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. diff --git a/src/client.ts b/src/client.ts index 464a3be..df4d107 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,12 +1,10 @@ import { TextbeeError } from './errors' import type { Device, - GetMessagesOptions, IterateMessagesOptions, ListMessagesOptions, Message, MessageList, - MessagesPage, SendSmsRequest, SendSmsResponse, SmsBatchResult, @@ -93,42 +91,11 @@ export class Textbee { /** * Page through the account's sent and received messages, filtered by - * device, direction, status, text, and time range. + * device, batch, direction, status, text, and time range. */ - async getMessages(options?: ListMessagesOptions): Promise - /** - * Page through a device's sent and received messages. - * - * @deprecated Use `getMessages(options)` with `deviceIds: [deviceId]`. This - * form calls the deprecated device-scoped endpoint, which stays supported - * but no longer appears in the API reference. - */ - async getMessages( - deviceId: string, - options?: GetMessagesOptions, - ): Promise - async getMessages( - deviceIdOrOptions?: string | ListMessagesOptions, - deviceOptions: GetMessagesOptions = {}, - ): Promise { - // Both endpoints return { data, meta } with no outer wrapper, so the whole + async getMessages(options: ListMessagesOptions = {}): Promise { + // The endpoint returns { data, meta } with no outer wrapper, so the whole // payload is the result. - if (typeof deviceIdOrOptions === 'string') { - return await this.#request( - 'GET', - `/gateway/devices/${encodeURIComponent(deviceIdOrOptions)}/messages`, - { - query: { - type: deviceOptions.type, - page: deviceOptions.page, - limit: deviceOptions.limit, - search: deviceOptions.search, - }, - }, - ) - } - - const options = deviceIdOrOptions ?? {} return await this.#request('GET', '/gateway/messages', { query: { deviceIds: options.deviceIds?.length diff --git a/src/index.ts b/src/index.ts index 36fb7d6..bab1eb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,15 +5,12 @@ export { verifyWebhookSignature } from './webhooks' export type { VerifyWebhookSignatureOptions } from './webhooks' export type { Device, - GetMessagesOptions, IterateMessagesOptions, ListMessagesOptions, Message, MessageDevice, MessageList, MessageListMeta, - MessagesPage, - PaginationMeta, SendSmsImmediateResponse, SendSmsQueuedResponse, SendSmsRequest, diff --git a/src/types.ts b/src/types.ts index 7869969..5ab2c5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,20 +64,6 @@ export interface SendSmsImmediateResponse { */ export type SendSmsResponse = SendSmsQueuedResponse | SendSmsImmediateResponse -export interface GetMessagesOptions { - /** Filter by direction. Defaults to `all`. */ - type?: 'all' | 'sent' | 'received' - - /** 1-based page number. Defaults to 1. */ - 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 -} - export interface ListMessagesOptions { /** Only messages from these devices. Omit for every device on the account. */ deviceIds?: string[] @@ -170,10 +156,7 @@ export interface Message { * `direction` filter, so a response value feeds straight back into a query. */ type: 'SENT' | 'RECEIVED' - /** - * Direction, lowercase. Present on messages from the account-level - * endpoint; absent from the deprecated device-scoped one. - */ + /** Direction, lowercase. Matches the `direction` filter values. */ direction?: 'sent' | 'received' /** Message channel. Currently always `sms`; absent means `sms`. */ channel?: string @@ -224,18 +207,6 @@ export interface SmsBatch { updatedAt?: string } -export interface PaginationMeta { - page: number - limit: number - total: number - totalPages: number -} - -export interface MessagesPage { - data: Message[] - meta: PaginationMeta -} - /** * Meta for the account-level message list. Page mode carries the counters and * the cursor fields; cursor mode carries `limit`, `nextCursor`, and `hasMore` diff --git a/test/client.test.ts b/test/client.test.ts index cfafb55..2711951 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -187,40 +187,6 @@ describe('telemetry headers', () => { describe('response envelopes', () => { it('returns getMessages as { data, meta } because it has no outer wrapper', async () => { - // The response carries the direction uppercase while the query filter - // below stays lowercase. That asymmetry is the API's, not a typo here. - const page = { - data: [{ _id: 'sms-1', message: 'hi', type: 'RECEIVED', status: 'received' }], - meta: { page: 2, limit: 10, total: 11, totalPages: 2 }, - } - fetchMock.mockImplementation(async () => respond(200, page)) - - const result = await client().getMessages(DEVICE_ID, { - type: 'received', - page: 2, - limit: 10, - search: 'hi there', - }) - - expect(result).toEqual(page) - - const url = new URL(lastCall().url) - expect(url.pathname).toBe(`/api/v1/gateway/devices/${DEVICE_ID}/messages`) - expect(url.searchParams.get('type')).toBe('received') - expect(url.searchParams.get('page')).toBe('2') - expect(url.searchParams.get('limit')).toBe('10') - expect(url.searchParams.get('search')).toBe('hi there') - }) - - it('omits unset getMessages query params', async () => { - fetchMock.mockImplementation(async () => respond(200, { data: [], meta: {} })) - - await client().getMessages(DEVICE_ID) - - expect(new URL(lastCall().url).search).toBe('') - }) - - it('routes the account-level getMessages overload to /gateway/messages', async () => { const page = { data: [ {