/** * Typed client for the xaxumessage REST API (docs/API.md). Zero dependencies: only the global `fetch`/`FormData`/`Blob` * (Node 18+). Use it on the SERVER side only (Next.js route handlers / server actions): the API key must never reach a browser. * * const msg = new XaxuMessage({ baseUrl: process.env.MSG_URL!, apiKey: process.env.MSG_API_KEY! }); * const sent = await msg.messages.sendText(accountId, { to: { phone: '+79991234567' }, text: 'Hello', clientId: 'order-42' }); * * Copy this folder into your project (or publish it as a private package). Types are hand-written to match docs/API.md. */ export { verifyWebhookSignature, readVerifiedEvent, type XaxuEvent } from './webhook.js'; import type { XaxuEvent } from './webhook.js'; // ───────────────────────────── types ───────────────────────────── export type Channel = 'whatsapp' | 'telegram' | 'max'; export type Support = 'yes' | 'unverified' | 'no'; export type MessageStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'received'; /** Self-service numbers: what this customer may still order (`maxAccounts` 0 = disabled by the operator). */ export interface Limits { maxAccounts: number; accountsUsed: number; pendingOrders: number; remaining: number; canProvision: Record; trialDays: number; /** 'platform': the operator sets paid periods. 'tenant': you set them for your own numbers (`accounts.setBilling` / `extend`). */ billingMode: 'platform' | 'tenant'; /** how many numbers you may order per hour */ ordersPerHour: number; } /** An invoice for paid periods of one number. The number is extended when the payment provider confirms the payment. */ export interface Payment { id: string; /** the number the payment provider knows the invoice by */ invoiceNumber: number; accountId: string; status: 'pending' | 'paid'; /** integer minor units (kopecks): 299000 = 2 990.00 */ amountMinor: number; currency: string; periods: number; days: number; /** created in the provider's test mode: paid in name only, never extends a number */ isTest: boolean; email: string | null; createdAt: string; paidAt: string | null; /** the number was really extended */ credited: boolean; note: string | null; /** send the customer here (only while `status` is `pending`) */ payUrl: string | null; } /** What a number costs you: integers in minor units (kopecks) per `periodDays`. */ export interface PriceList { code: string; name: string; currency: string; periodDays: number; prices: Record; } export interface Account { id: string; channel: Channel; label: string; idInstance: number; wid: string | null; /** 'pendingPassword' (Telegram): the login was accepted, now send the cloud password (accounts.submitPassword). */ state: 'notAuthorized' | 'authorized' | 'blocked' | 'starting' | 'suspended' | 'yellowCard' | 'pendingPassword' | 'unknown'; billingStatus: 'active' | 'grace' | 'suspended'; paidUntil: string | null; ingestMode: 'webhook' | 'poll'; sendIntervalMs: number; /** Group chats and channels are handled only when true (default false). */ groupsEnabled: boolean; capabilities: Record; createdAt: string; } export interface Attachment { id: string; messageId: string | null; direction: 'in' | 'out'; kind: 'image' | 'video' | 'audio' | 'document' | 'sticker'; fileName: string | null; mimeType: string | null; sizeBytes: number | null; sha256: string | null; caption: string | null; status: 'pending' | 'stored' | 'failed' | 'expired'; error: string | null; contentUrl: string | null; } export interface Message { id: string; accountId: string; chatId: string; externalId: string | null; clientId: string | null; direction: 'in' | 'out'; origin: 'contact' | 'api' | 'phone'; kind: string; text: string | null; data: Record; quotedExternalId: string | null; /** `phone`: international digits without "+", set only on inbound messages of direct chats when the messenger shares it. */ sender: { id: string | null; name: string | null; phone: string | null }; status: MessageStatus; statusDetail: string | null; sentAt: string; editedAt: string | null; deletedAt: string | null; attachments: Attachment[]; reactions: { actor: string; emoji: string }[]; } export interface Chat { id: string; accountId: string; externalId: string; kind: 'direct' | 'group' | 'channel' | 'unknown'; title: string | null; contactName: string | null; /** Latest known phone of the contact of a direct chat (international digits without "+"); null when unknown or hidden. */ phone: string | null; avatarUrl: string | null; lastMessageAt: string | null; unreadCount: number; archived: boolean; } export interface Story { id: string; accountId: string; externalId: string | null; direction: 'in' | 'out'; kind: 'text' | 'image' | 'video' | 'audio'; text: string | null; caption: string | null; status: 'queued' | 'sent' | 'failed' | 'deleted'; postedAt: string; views: { viewer: string; status: string; viewedAt: string | null }[]; } export interface Target { /** Provider chat id: `79991234567@c.us`, `...@g.us`, numeric id for Telegram / MAX. */ chatId?: string; /** International phone number — WhatsApp only. */ phone?: string; } export interface SendOptions { /** Idempotency key: repeating the call with the same key never sends twice. Use your own message id. */ clientId?: string; /** Reply to a stored message (our id) or by provider id. */ quote?: { messageId?: string; externalId?: string }; typingTime?: number; } export interface Page { items: T[]; nextCursor: string | null; } export class XaxuError extends Error { constructor( message: string, public readonly status: number, public readonly code: string, public readonly details?: unknown, ) { super(message); this.name = 'XaxuError'; } /** Worth retrying later: server trouble, GREEN-API trouble, rate limiting. */ get retryable(): boolean { return this.status >= 500 || this.status === 429 || this.status === 0; } } export interface ClientOptions { baseUrl: string; apiKey: string; fetch?: typeof fetch; /** Per-request timeout, default 30 s (uploads: 10 min). */ timeoutMs?: number; } type Query = Record; // portable across lib.dom and @types/node typings type Body = NonNullable; type BlobParts = ConstructorParameters[0]; // ───────────────────────────── client ───────────────────────────── export class XaxuMessage { private readonly base: string; private readonly key: string; private readonly f: typeof fetch; private readonly timeoutMs: number; constructor(opts: ClientOptions) { this.base = opts.baseUrl.replace(/\/+$/, ''); this.key = opts.apiKey; this.f = opts.fetch ?? fetch; this.timeoutMs = opts.timeoutMs ?? 30_000; } /** Low-level call; every method below uses it. Throws XaxuError for HTTP >= 400 and for network failures (status 0). */ async request(method: string, path: string, o: { query?: Query; json?: unknown; body?: Body; headers?: Record; timeoutMs?: number; raw?: boolean; duplex?: boolean } = {}): Promise { const url = new URL(this.base + path); for (const [k, v] of Object.entries(o.query ?? {})) if (v !== undefined) url.searchParams.set(k, String(v)); const headers: Record = { authorization: `Bearer ${this.key}`, ...(o.headers ?? {}) }; let body: Body | undefined = o.body; if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; } let res: Response; try { res = await this.f(url, { method, headers, body, signal: AbortSignal.timeout(o.timeoutMs ?? this.timeoutMs), ...(o.duplex ? { duplex: 'half' } : {}), } as RequestInit); } catch (e) { throw new XaxuError(`Network error: ${(e as Error).message}`, 0, 'network'); } if (o.raw && res.ok) return res as T; const text = await res.text(); let data: unknown; try { data = text ? JSON.parse(text) : undefined; } catch { data = text; } if (!res.ok) { const err = (data as { error?: { code?: string; message?: string; details?: unknown } } | undefined)?.error; throw new XaxuError(err?.message ?? `HTTP ${res.status}`, res.status, err?.code ?? 'http_error', err?.details); } return data as T; } // ───────────── accounts (numbers) ───────────── readonly accounts = { list: async () => (await this.request<{ accounts: Account[] }>('GET', '/v1/accounts')).accounts, get: (id: string) => this.request('GET', `/v1/accounts/${id}`), /** * Order a new number (self-service; the operator must have raised your limit, see `webhook.me().limits`). * Pass a stable `clientId` (e.g. your organisation id): a retry after a timeout returns the same number instead of buying another. * The number starts with a trial and suspends itself unless it is paid for. Throws XaxuError `quota_exceeded` (403), `rate_limited` (429), `order_in_progress` (409). * If the operator switched you to billing mode `tenant` (you bill your own users), pass `paidUntil` (ISO date, in the future) to skip the trial; * otherwise `paidUntil` is rejected with `billing_managed_by_platform` (403). */ create: (p: { channel: Channel; label?: string; clientId?: string; paidUntil?: string }) => this.request('POST', '/v1/accounts', { json: p }), /** Set the paid-until date of one of your numbers (billing mode `tenant` only). A past date switches the number off at once. */ setBilling: (id: string, p: { paidUntil: string; graceDays?: number }) => this.request('PUT', `/v1/accounts/${id}/billing`, { json: p }), /** Prolong a number by N days from its current expiry (or from now if it ran out); reactivates it at once (billing mode `tenant` only). */ extend: (id: string, days: number) => this.request('POST', `/v1/accounts/${id}/extend`, { json: { days } }), /** Remove a number you ordered yourself; the instance is deleted at the provider too (stops its daily charge). */ remove: (id: string) => this.request<{ deleted: true; instanceDeleted: boolean }>('DELETE', `/v1/accounts/${id}`), update: (id: string, patch: { label?: string; sendIntervalMs?: number; ingestMode?: 'webhook' | 'poll'; groupsEnabled?: boolean }) => this.request('PATCH', `/v1/accounts/${id}`, { json: patch }), /** Ask the provider for the current state right now. */ refresh: (id: string) => this.request('POST', `/v1/accounts/${id}/refresh`, { json: {} }), /** `{type: 'qrCode', message: }` | `{type: 'alreadyLogged'}` — render `data:image/png;base64,${message}`. */ qr: (id: string) => this.request<{ type: string; message: string }>('GET', `/v1/accounts/${id}/qr`), /** WhatsApp: returns a code to type on the phone. Telegram / MAX: sends an SMS / in-app code. */ startPhoneAuth: (id: string, phone: string) => this.request>('POST', `/v1/accounts/${id}/authorization/phone`, { json: { phone } }), submitCode: (id: string, code: string, password?: string) => this.request>('POST', `/v1/accounts/${id}/authorization/code`, { json: { code, password } }), /** Telegram two-step verification password. */ submitPassword: (id: string, password: string) => this.request>('POST', `/v1/accounts/${id}/authorization/password`, { json: { password } }), logout: (id: string) => this.request>('POST', `/v1/accounts/${id}/logout`, { json: {} }), /** Re-import recent history after downtime. Idempotent. */ backfill: (id: string, minutes = 60) => this.request<{ fetched: number; created: number }>('POST', `/v1/accounts/${id}/backfill`, { json: { minutes } }), check: (id: string, phone: string) => this.request>('POST', `/v1/accounts/${id}/check`, { json: { phone } }), }; // ───────────── payments (customers billed by the platform) ───────────── readonly payments = { /** * Invoice for `periods` (1-12) paid periods of one of your numbers; redirect the customer to `payUrl`. After the payment the number is extended * automatically and you receive a `payment.paid` event. Not for customers in billing mode `tenant` (403 `billing_managed_by_tenant`). * Throws `payments_disabled` (422) when the platform has no payment provider configured, `no_price` (422), `rate_limited` (429). */ create: (p: { accountId: string; periods?: number; email?: string }) => this.request('POST', '/v1/payments', { json: p }), get: (id: string) => this.request('GET', `/v1/payments/${id}`), /** Newest first; pass `next` of the previous page as `before`. */ list: (q: { accountId?: string; status?: 'pending' | 'paid'; before?: number; limit?: number } = {}) => this.request<{ payments: Payment[]; next: number | null }>('GET', '/v1/payments', { query: q }), }; // ───────────── messages ───────────── private send(accountId: string, body: Record) { return this.request('POST', `/v1/accounts/${accountId}/messages`, { json: body }); } readonly messages = { sendText: (accountId: string, p: { to: Target; text: string; linkPreview?: boolean } & SendOptions) => this.send(accountId, { type: 'text', ...p }), /** Send an uploaded file (`attachmentId` from `attachments.upload`) or a public `url`. */ sendFile: (accountId: string, p: { to: Target; attachmentId?: string; url?: string; fileName?: string; caption?: string } & SendOptions) => this.send(accountId, { type: 'file', ...p }), sendLocation: (accountId: string, p: { to: Target; latitude: number; longitude: number; name?: string; address?: string } & SendOptions) => this.send(accountId, { type: 'location', ...p }), sendContact: (accountId: string, p: { to: Target; contact: { phone: string; firstName?: string; lastName?: string; company?: string } } & SendOptions) => this.send(accountId, { type: 'contact', ...p }), sendPoll: (accountId: string, p: { to: Target; question: string; options: string[]; multipleAnswers?: boolean } & SendOptions) => this.send(accountId, { type: 'poll', ...p }), forward: (accountId: string, p: { to: Target; messageIds: string[] } & SendOptions) => this.send(accountId, { type: 'forward', ...p }), get: (id: string) => this.request('GET', `/v1/messages/${id}`), /** queued → sending → sent → delivered → read, with timestamps. */ history: async (id: string) => (await this.request<{ history: { status: MessageStatus; detail: string | null; at: string }[] }>('GET', `/v1/messages/${id}/history`)).history, edit: (id: string, text: string) => this.request<{ accepted: boolean }>('PATCH', `/v1/messages/${id}`, { json: { text } }), delete: (id: string, forEveryone = true) => this.request<{ accepted: boolean }>('DELETE', `/v1/messages/${id}`, { query: { forEveryone } }), }; // ───────────── chats ───────────── readonly chats = (() => { const messages = async (id: string, q: { limit?: number; cursor?: string } = {}): Promise> => { const r = await this.request<{ messages: Message[]; nextCursor: string | null }>('GET', `/v1/chats/${id}/messages`, { query: q }); return { items: r.messages, nextCursor: r.nextCursor }; }; return { list: async (q: { accountId?: string; unread?: boolean; archived?: boolean; search?: string; limit?: number; cursor?: string } = {}): Promise> => { const r = await this.request<{ chats: Chat[]; nextCursor: string | null }>('GET', '/v1/chats', { query: q }); return { items: r.chats, nextCursor: r.nextCursor }; }, get: (id: string) => this.request('GET', `/v1/chats/${id}`), /** Newest first. */ messages, /** Resets the unread counter and sends the read receipt to the messenger. */ markRead: (id: string, upToMessageId?: string) => this.request<{ accepted: boolean }>('POST', `/v1/chats/${id}/read`, { json: upToMessageId ? { upToMessageId } : {} }), typing: (id: string, ms?: number) => this.request<{ accepted: boolean }>('POST', `/v1/chats/${id}/typing`, { json: ms ? { ms } : {} }), archive: (id: string, archived: boolean) => this.request<{ accepted: boolean }>('POST', `/v1/chats/${id}/archive`, { json: { archived } }), /** Async iterator over a whole conversation, newest first. */ iterateMessages: async function* (id: string, pageSize = 100): AsyncGenerator { let cursor: string | undefined; for (;;) { const page = await messages(id, { limit: pageSize, cursor }); yield* page.items; if (!page.nextCursor) return; cursor = page.nextCursor; } }, }; })(); // ───────────── files ───────────── readonly attachments = { /** * Upload a file you want to send. Blob / Uint8Array / ArrayBuffer go as multipart; a ReadableStream is streamed raw * (no buffering) — for big files in Node use `await fs.openAsBlob(path)`. */ upload: (data: Blob | Uint8Array | ArrayBuffer | ReadableStream, meta: { fileName: string; mimeType?: string; caption?: string }): Promise => { const opts = { timeoutMs: 10 * 60_000 }; if (data instanceof ReadableStream) { return this.request('PUT', '/v1/attachments', { ...opts, query: { fileName: meta.fileName, mimeType: meta.mimeType, caption: meta.caption }, body: data as unknown as Body, headers: { 'content-type': 'application/octet-stream' }, duplex: true, }); } const blob = data instanceof Blob ? data : new Blob(data instanceof ArrayBuffer ? [data] : [data as Uint8Array], { type: meta.mimeType }); const form = new FormData(); form.append('file', blob, meta.fileName); if (meta.caption) form.append('caption', meta.caption); return this.request('POST', '/v1/attachments', { ...opts, body: form }); }, get: (id: string) => this.request('GET', `/v1/attachments/${id}`), /** The file bytes as a streaming `Response` (pipe it to your own HTTP response; never expose the API key). */ download: (id: string, inline = false) => this.request('GET', `/v1/attachments/${id}/content`, { raw: true, query: { inline }, timeoutMs: 10 * 60_000 }), }; // ───────────── stories (WhatsApp) ───────────── readonly stories = { createText: (accountId: string, p: { text: string; backgroundColor?: string; font?: string; participants?: string[] }) => this.request('POST', `/v1/accounts/${accountId}/stories`, { json: { type: 'text', ...p } }), createMedia: (accountId: string, p: { attachmentId?: string; url?: string; fileName?: string; caption?: string; participants?: string[] }) => this.request('POST', `/v1/accounts/${accountId}/stories`, { json: { type: 'media', ...p } }), createVoice: (accountId: string, p: { attachmentId?: string; url?: string; fileName?: string; backgroundColor?: string; participants?: string[] }) => this.request('POST', `/v1/accounts/${accountId}/stories`, { json: { type: 'voice', ...p } }), list: async (accountId: string, q: { direction?: 'in' | 'out'; limit?: number } = {}) => (await this.request<{ stories: Story[] }>('GET', `/v1/accounts/${accountId}/stories`, { query: q })).stories, /** Import incoming/outgoing statuses (there is no webhook for incoming ones). */ sync: (accountId: string, minutes = 1440) => this.request<{ created: number }>('POST', `/v1/accounts/${accountId}/stories/sync`, { json: { minutes } }), refreshViews: (id: string) => this.request('POST', `/v1/stories/${id}/refresh-views`, { json: {} }), delete: (id: string) => this.request<{ accepted: boolean }>('DELETE', `/v1/stories/${id}`), }; // ───────────── events (pull) and webhook settings ───────────── readonly events = (() => { const list = (after = 0, limit = 100) => this.request<{ events: XaxuEvent[]; next: number }>('GET', '/v1/events', { query: { after, limit } }); return { list, /** * Everything after `after`, page by page. Persist the id of the last event you handled and pass it next time: * `for await (const e of msg.events.iterate(cursor)) { await handle(e); cursor = e.id; save(cursor); }` */ iterate: async function* (after = 0, pageSize = 100): AsyncGenerator { let cursor = after; for (;;) { const page = await list(cursor, pageSize); yield* page.events; if (page.events.length < pageSize) return; cursor = page.next; } }, }; })(); /** The price list assigned to you (minor units per period) and who sets the paid periods of your numbers. */ readonly pricing = () => this.request<{ billingMode: 'platform' | 'tenant'; priceList: PriceList }>('GET', '/v1/pricing'); readonly webhook = { me: () => this.request<{ id: string; name: string; webhookUrl: string | null; hasWebhookSecret: boolean; limits: Limits }>('GET', '/v1/me'), /** Returns the signing `secret` ONLY when it is generated here (store it immediately). */ set: (url: string | null, secret?: string) => this.request<{ url: string | null; secret?: string }>('PUT', '/v1/webhook', { json: { url, secret } }), }; }