/** * POASMS client for Node 18+ / Next.js (uses global fetch). No dependencies. * * import { PoaSms } from "./poasms"; * const sms = new PoaSms(process.env.POASMS_API_KEY!); * const res = await sms.send("0712345678", "Habari! Code yako ni 482913", { type: "otp" }); * if (!res.ok) console.error(res.error); */ import { createHmac, timingSafeEqual } from "node:crypto"; export type MessageType = "transactional" | "promotional" | "otp"; export type Result = | { ok: true; status: number; data: T } | { ok: false; status: number; error: string; code?: string }; export interface SendOptions { from?: string; type?: MessageType; reference?: string; schedule_at?: string; app?: string; } export interface SendResponse { batch_id: string; accepted: number; rejected: { to: string; reason: string }[]; segments: number; encoding: "gsm7" | "ucs2"; price: number; currency: "TZS"; balance: number; sandbox: boolean; messages: { id: string; to: string; status: string }[]; } export class PoaSms { constructor( private apiKey: string, private baseUrl = process.env.POASMS_BASE_URL || "https://poasms.com/api/v1", private timeoutMs = 20000, ) {} send(to: string | string[], message: string, options: SendOptions = {}) { return this.request("POST", "/sms/send", { to, message, ...options }); } otp(to: string, message: string, reference?: string) { return this.send(to, message, { type: "otp", reference }); } status(id: string) { return this.request("GET", `/sms/${encodeURIComponent(id)}`); } balance() { return this.request<{ balance: number; rate_per_sms: number; sms_remaining: number | null }>("GET", "/balance"); } usage(params: { from?: string; to?: string; group_by?: string } = {}) { return this.request("GET", "/usage", params); } // --- WhatsApp ------------------------------------------------------------- whatsapp(to: string | string[], template: string, params: string[] = [], options: { header?: { type: "image" | "document" | "video"; url?: string; id?: string; filename?: string }; buttons?: string[]; reference?: string; app?: string; schedule_at?: string; language?: string; } = {}) { return this.request("POST", "/whatsapp/send", { to, template, params, ...options }); } /** Free-text reply — only within 24h of the customer's last message. */ whatsappReply(to: string, message: string, reference?: string) { return this.request("POST", "/whatsapp/send", { to, message, reference }); } whatsappTemplates() { return this.request("GET", "/whatsapp/templates"); } whatsappInbox(params: { from?: string; since?: string } = {}) { return this.request("GET", "/whatsapp/inbox", params); } static verifyWebhook(rawBody: string, signature: string | null | undefined, secret: string): boolean { if (!signature) return false; const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")); const given = Buffer.from(signature); return expected.length === given.length && timingSafeEqual(expected, given); } private async request(method: "GET" | "POST", path: string, body?: Record): Promise> { let url = this.baseUrl.replace(/\/$/, "") + path; if (method === "GET" && body) { const qs = new URLSearchParams(Object.entries(body).filter(([, v]) => v != null) as [string, string][]); if ([...qs].length) url += "?" + qs; } try { const res = await fetch(url, { method, headers: { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json", ...(method === "POST" ? { "Content-Type": "application/json" } : {}), }, body: method === "POST" ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(this.timeoutMs), }); const json: any = await res.json().catch(() => ({})); if (res.ok) return { ok: true, status: res.status, data: json.data as T }; return { ok: false, status: res.status, error: json?.error?.message ?? `HTTP ${res.status}`, code: json?.error?.code }; } catch (e) { return { ok: false, status: 0, error: `Network error: ${String(e)}`, code: "network_error" }; } } }