/** * Frosvo REST API — a typed client in one file, no dependencies. * Works anywhere with fetch and Web Crypto: Node 18+, Deno, Bun, browsers * (but keep API keys on servers). * * import { Frosvo } from "./frosvo"; * const frosvo = new Frosvo({ apiKey: process.env.FROSVO_API_KEY!, baseUrl: "https://your-frosvo.example" }); * for await (const c of frosvo.conversations.all({ status: "open" })) console.log(c.id); * * Reference: /developers and /openapi.json on your Frosvo. */ export type ConversationStatus = "open" | "active" | "closed"; export type Channel = "web" | "telegram" | "whatsapp" | "messenger" | "instagram" | "email"; export interface Conversation { id: string; site_id: string; contact_id: string; status: ConversationStatus; channel: Channel; assignee_id: string | null; assistant_handling: boolean; spam: boolean; tags: string[]; data: Record; subject: string | null; rating: number | null; rating_comment: string | null; last_message_at: string; resolved_at: string | null; created_at: string; } export interface Message { id: string; from: "visitor" | "agent" | "bot" | "system"; sender_name: string | null; agent_id: string | null; text: string; private: boolean; files: { name: string; url: string; size: number; type: string }[]; created_at: string; } export interface Contact { id: string; site_id: string; name: string | null; email: string | null; phone: string | null; company: string | null; notes: string | null; tags: string[]; fields: Record; marketing_consent: boolean; consent_at: string | null; consent_source: string | null; country: string | null; city: string | null; first_seen_at: string; last_seen_at: string; } export interface ContactInput { name?: string; email?: string; phone?: string; company?: string; notes?: string; tags?: string[]; fields?: Record; /** Only when the person agreed; recorded as from "api". */ marketing_consent?: boolean; } export interface Article { id: string; title: string; slug: string; body: string; category: string | null; published: boolean; url: string; views: number; helpful: number; not_helpful: number; created_at: string; updated_at: string; } export type EventType = | "conversation.created" | "conversation.resolved" | "conversation.reopened" | "conversation.assigned" | "conversation.handed_over" | "message.created" | "contact.created" | "contact.updated" | "rating.created" | "webhook.test"; export interface WebhookEvent> { id: string; type: EventType; created_at: string; workspace_id: string; data: T; } export interface WebhookEndpoint { id: string; url: string; description: string | null; events: (EventType | "*")[]; enabled: boolean; disabled_reason: string | null; failure_streak: number; created_at: string; } export interface Page { data: T[]; next_cursor: string | null; } /** What the API said went wrong: code is stable (invalid_token, insufficient_scope, rate_limited…). */ export class FrosvoError extends Error { constructor( readonly status: number, readonly code: string, message: string, /** Seconds to wait, on 429 or temporary 503 failures. */ readonly retryAfter: number | null, /** Server-generated correlation ID, when returned by this API version. */ readonly requestId: string | null = null, ) { super(message); this.name = "FrosvoError"; } } export interface FrosvoOptions { apiKey: string; /** Your Frosvo's address, e.g. https://chat.example.com (without /api/v1). */ baseUrl: string; /** Wait and retry once when rate limited (default true). */ retryOnRateLimit?: boolean; fetch?: typeof fetch; } type Query = Record; export class Frosvo { private readonly base: string; private readonly key: string; private readonly retry: boolean; private readonly doFetch: typeof fetch; constructor(opts: FrosvoOptions) { if (!/^frv_/.test(opts.apiKey)) throw new Error("A Frosvo API key starts with frv_."); this.base = `${opts.baseUrl.replace(/\/+$/, "")}/api/v1`; this.key = opts.apiKey; this.retry = opts.retryOnRateLimit ?? true; this.doFetch = opts.fetch ?? fetch; } async request(method: string, path: string, body?: unknown, query?: Query, retried = false): Promise { const qs = query ? Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&") : ""; const res = await this.doFetch(`${this.base}${path}${qs ? `?${qs}` : ""}`, { method, headers: { Authorization: `Bearer ${this.key}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); const json = text ? JSON.parse(text) : {}; if (res.ok) return json as T; const retryAfter = res.headers.get("retry-after") ? Number(res.headers.get("retry-after")) : null; if (res.status === 429 && this.retry && !retried && retryAfter !== null && retryAfter <= 60) { await new Promise((r) => setTimeout(r, retryAfter * 1000)); return this.request(method, path, body, query, true); } const id = res.headers.get("x-request-id") ?? json?.error?.request_id; const requestId = typeof id === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(id) ? id : null; throw new FrosvoError(res.status, json?.error?.code ?? "http_error", json?.error?.message ?? `HTTP ${res.status}`, retryAfter, requestId); } /** Every item, page after page. */ async *paginate(path: string, query: Query = {}): AsyncGenerator { let cursor: string | null = null; do { const page: Page = await this.request>("GET", path, undefined, { ...query, limit: query.limit ?? 100, cursor }); for (const item of page.data) yield item; cursor = page.next_cursor; } while (cursor); } me() { return this.request<{ key: { id: string; name: string; prefix: string; scopes: string[]; expires_at: string | null }; workspace: { id: string; name: string; slug: string }; rate_limit: { per_minute: number; remaining: number } }>("GET", "/me"); } readonly conversations = { list: (q: { status?: ConversationStatus; site_id?: string; updated_since?: string; limit?: number; cursor?: string } = {}) => this.request>("GET", "/conversations", undefined, q), all: (q: { status?: ConversationStatus; site_id?: string; updated_since?: string } = {}) => this.paginate("/conversations", q), get: (id: string) => this.request<{ data: Conversation & { reply_version: number; contact: Contact; messages: Message[] } }>("GET", `/conversations/${encodeURIComponent(id)}`).then((r) => r.data), messages: (id: string, q: { limit?: number; cursor?: string } = {}) => this.request>("GET", `/conversations/${encodeURIComponent(id)}/messages`, undefined, q), /** Sent like a reply from the desk: translated, delivered on the conversation's channel, counted for SLAs. */ reply: (id: string, text: string, opts: { senderName?: string; expectedReplyVersion?: number } = {}) => this.request<{ data: Message }>("POST", `/conversations/${encodeURIComponent(id)}/messages`, { text, sender_name: opts.senderName, expected_reply_version: opts.expectedReplyVersion }).then((r) => r.data), note: (id: string, text: string) => this.request<{ data: Message }>("POST", `/conversations/${encodeURIComponent(id)}/messages`, { text, private: true }).then((r) => r.data), resolve: (id: string) => this.action(id, { action: "close" }), reopen: (id: string) => this.action(id, { action: "reopen" }), assign: (id: string, agentId: string | null) => this.action(id, { action: "assign", agent_id: agentId }), tag: (id: string, tag: string) => this.action(id, { action: "tag", tag }), untag: (id: string, tag: string) => this.action(id, { action: "untag", tag }), setData: (id: string, key: string, value: string) => this.action(id, { action: "data", key, value }), }; private action(id: string, body: Record) { return this.request<{ data: Conversation }>("POST", `/conversations/${encodeURIComponent(id)}/actions`, body).then((r) => r.data); } readonly contacts = { list: (q: { email?: string; q?: string; site_id?: string; tag?: string; limit?: number; cursor?: string } = {}) => this.request>("GET", "/contacts", undefined, q), all: (q: { q?: string; site_id?: string; tag?: string } = {}) => this.paginate("/contacts", q), findByEmail: async (email: string) => (await this.request>("GET", "/contacts", undefined, { email })).data[0] ?? null, get: (id: string) => this.request<{ data: Contact }>("GET", `/contacts/${encodeURIComponent(id)}`).then((r) => r.data), /** Creates the contact, or updates the one with this email on this website. */ upsert: (siteId: string, input: ContactInput) => this.request<{ data: Contact; created: boolean }>("POST", "/contacts", { site_id: siteId, ...input }), update: (id: string, input: ContactInput) => this.request<{ data: Contact }>("PATCH", `/contacts/${encodeURIComponent(id)}`, input).then((r) => r.data), }; readonly articles = { list: (q: { published?: boolean; limit?: number; cursor?: string } = {}) => this.request>("GET", "/articles", undefined, q), get: (id: string) => this.request<{ data: Article }>("GET", `/articles/${encodeURIComponent(id)}`).then((r) => r.data), create: (input: { title: string; body: string; category?: string; published?: boolean }) => this.request<{ data: Article }>("POST", "/articles", input).then((r) => r.data), update: (id: string, input: Partial<{ title: string; body: string; category: string | null; published: boolean }>) => this.request<{ data: Article }>("PATCH", `/articles/${encodeURIComponent(id)}`, input).then((r) => r.data), }; readonly reports = { summary: (q: { from?: string; to?: string; tz?: string; site_id?: string; channel?: string } = {}) => this.request<{ data: Record }>("GET", "/reports/summary", undefined, q).then((r) => r.data), }; readonly webhooks = { list: () => this.request<{ data: WebhookEndpoint[] }>("GET", "/webhooks").then((r) => r.data), /** The answer includes the signing secret — store it. */ create: (input: { url: string; events: (EventType | "*")[]; description?: string }) => this.request<{ data: WebhookEndpoint & { secret: string } }>("POST", "/webhooks", input).then((r) => r.data), update: (id: string, input: Partial<{ url: string; events: (EventType | "*")[]; description: string; enabled: boolean }>) => this.request<{ data: WebhookEndpoint }>("PATCH", `/webhooks/${encodeURIComponent(id)}`, input).then((r) => r.data), delete: (id: string) => this.request<{ ok: true }>("DELETE", `/webhooks/${encodeURIComponent(id)}`), replay: (id: string, eventId: string) => this.request<{ data: { delivery_id: string } }>("POST", `/webhooks/${encodeURIComponent(id)}/replay`, { event_id: eventId }).then((r) => r.data), }; } /** * Checks a webhook request is from Frosvo: pass the raw body (exactly as * received — not re-serialized JSON), the Frosvo-Signature header and the * endpoint's secret. Rejects signatures older than `toleranceSeconds`. */ export async function verifyWebhook(rawBody: string, signatureHeader: string, secret: string, toleranceSeconds = 300): Promise { const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.trim().split("=") as [string, string])); const t = Number(parts.t); if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const enc = new TextEncoder(); const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(`${t}.${rawBody}`))); const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join(""); if (expected.length !== parts.v1.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ parts.v1.charCodeAt(i); return diff === 0; } /** Parses a verified webhook body. */ export function parseWebhook>(rawBody: string): WebhookEvent { return JSON.parse(rawBody) as WebhookEvent; }