npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

kasookoo-sdk

v0.2.13

Published

Kasookoo JavaScript SDK — voice calls (WebRTC/SIP), messaging and real-time notifications

Readme

Kasookoo SDK

JavaScript/TypeScript SDK for the Kasookoo communication platform — voice calls (WebRTC / SIP), messaging and real-time notifications. Framework-agnostic core; works in any frontend framework or vanilla JS.

Table of contents

Install

npm install kasookoo-sdk

Installing also runs a postinstall step that copies the SDK's service worker into your app's public/static folder — required for push notifications, since browser push cannot mint a device token without it. It's copied as-is, already containing Kasookoo's Firebase config, and must end up served from your site root as /firebase-messaging-sw.js.

The postinstall step looks for a public/, static/ or www/ folder (or static/ specifically for SvelteKit) and copies into whichever it finds. If your project doesn't use one of those, or if your package manager has postinstall scripts disabled (e.g. --ignore-scripts, pnpm's default), run it yourself with an explicit output directory:

npx kasookoo-sdk --out path/to/public

If the file is ever missing or out of date, the SDK logs a console warning at runtime pointing back at this command.

Already running your own Firebase project in this app? See Firebase integration / advanced service worker setup before you install — copying Kasookoo's worker file over your own (or vice versa) will break one of them.

Getting started

Initialize the SDK once at the top of your app.

Tip: call init() in response to a user action (login click, an "enable calls" button) rather than on page load. init() requests microphone, location and notification permissions, and browsers treat permission prompts triggered by a user gesture much more favorably — prompts fired on page load may be auto-dismissed or silently blocked.

import { KasookooClient } from "kasookoo-sdk"

const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",     // from your Kasookoo dashboard
    subject: "[email protected]",       // the end user's EMAIL — see below
})

subject must be the end user's email address. The SDK uses it to resolve who that user is within your organization, so it can register this browser against them and route their calls and messages here. It must match an existing user's email — init() throws if it doesn't.

Correlation & tracing: The SDK automatically sends lifecycle correlation headers on API calls and optionally exports OpenTelemetry traces. See Correlation & tracing.

Quickstart: end to end

Initialize, listen for the events you care about, then place a call. This is the shortest path from an installed package to a working call — every piece here is covered in more depth further down.

import { KasookooClient } from "kasookoo-sdk"

// 1. Initialize — do this once, in response to a user gesture (e.g. a login button).
const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",   // the signed-in end user's email
})

// 2. Listen for the things that can happen to you passively.
kasookoo.on("call:incoming", (call) => {
    console.log(`${call.remote.name} is calling`)   // the SDK shows its own call window automatically
})
kasookoo.on("notification:message", (data) => {
    console.log("push payload:", data)               // see "Receiving messages" below to parse this
})
kasookoo.on("error", (err) => console.warn(err.code, err.message))

// 3. Do something — e.g. place a call.
const call = await kasookoo.initCall({
    roomName: `call_${crypto.randomUUID()}`,
    caller: { name: "Jane", email: "[email protected]", type: "agent" },
    callee: { name: "John", email: "[email protected]", type: "customer" },
})

call.on("state", (state) => console.log("call state:", state))
// The SDK's own call window (bottom-right, draggable) handles the rest —
// answer/reject, mute, hang up. Nothing else is required to get a working call.

Events

kasookoo.on("session:created", (info) => console.log(info.sessionId, info.allowedScopes))
kasookoo.on("session:refreshed", (info) => console.log("valid until", new Date(info.expiresAt)))
kasookoo.on("session:expired", ({ reason }) => { /* re-init or show a reconnect UI */ })
kasookoo.on("notification:message", (data) => { /* raw push data payload */ })
kasookoo.on("trace:request", ({ url, method, requestId, callTraceId, status, ok }) => { /* one per SDK HTTP request — see "Correlation & tracing" below */ })
kasookoo.on("error", (err) => console.warn(err.code, err.message))

on() returns an unsubscribe function — call it to stop listening:

const off = kasookoo.on("notification:message", (data) => console.log(data))

// later, e.g. when a component unmounts or you no longer care:
off()

This is the only way to remove a listener added inline (an arrow function has no reference you could otherwise pass to off()). It matters most in frameworks with a component lifecycle — call off() on unmount, or the listener keeps firing (and keeps its closure alive) for as long as the SDK instance lives, even after the UI that cared about it is gone:

useEffect(() => {
    const off = kasookoo.on("call:incoming", (call) => setIncoming(call))
    return off   // React calls this automatically when the component unmounts
}, [kasookoo])

Calling the same off more than once is harmless — the second call is a no-op. You can also remove a listener with kasookoo.off(event, handler) directly, but that requires holding a reference to the original function, which the returned off() avoids needing.

Capabilities

Your publishable key is provisioned with a set of capabilities, returned as allowed_scopes when the session is created. Each feature is gated by its capability — if the session wasn't granted a feature's scope, calling that feature's method throws error:

// session without the "cdr" capability:
await kasookoo.downloadCdr()
// → KasookooError { code: "Error",
//   message: "'property downloadCdr' does not exist on type KasookooClient " }

| Capability | Gated methods | | --- | --- | | in_app_calling | initCall (and incoming calls / call:incoming) | | sip_calling | initSipCall | | in_app_messaging | sendMessage, sendLocation, sendWhatsAppMessage, getConversations, getMessages, getUnreadCount, markRead, deleteConversation, deleteMessage | | cdr | getCdr, downloadCdr | | user | createUser, updateUser, deleteUser, getUser, getUsers | | associated_number | createAssociatedNumber, updateAssociatedNumber, deleteAssociatedNumber, getAssociatedNumbers |

session, getScopes, on/off, and close are always available. authentication_calling:* scopes are not web-SDK features and are ignored.

Calls

Four things to know, covered one at a time below: placing an in-app call, placing a call to a phone number, how the call window works and what it hands you, and how to replace it with your own.

Placing an in-app call

initCall() places a call between two Kasookoo users. It resolves once the call is set up — connected to the room, microphone published — and returns a Call handle you use to control it. (Everything you can do with that handle, and how the window around it behaves, is covered in The call window below.)

const call = await kasookoo.initCall({
    roomName: "call_room_123",   // must be unique per call
    caller: { name: "John Caller", email: "[email protected]", phoneNumber: "+15550001111", type: "agent" },
    callee: { name: "Jane Callee", email: "[email protected]", phoneNumber: "+15550002222", type: "customer" },
    deviceType: "web",          // optional — defaults to a web client
    isCallRecording: false,     // optional — defaults to true; see below
})

| Parameter | Details | | --- | --- | | roomName | Identifier for this call. Must be unique — reusing one while an earlier call on it is still live will collide. | | caller | The side placing the call — your end user. { name, email, phoneNumber?, type }. phoneNumber is optional — omit it if this participant has none on file. | | callee | The side being called. Same shape as caller. | | caller.type / callee.type | Free-form (e.g. "agent", "customer") — not restricted to a fixed set. Also recorded as each side's role on the resulting CDR entry. | | deviceType | Optional. How this device is described on the call. Defaults to a web client — you will not normally need to set this. | | isCallRecording | Optional, defaults to true — every call is recorded automatically unless you opt out. Set false only when you deliberately don't want a recording; the call still connects and works exactly the same either way, it just won't have a recording_download_url on its CDR entry, and there is no recording to download afterward. |

Only one call can be active at a time — initCall() throws call_in_progress while another call, of either kind, is already live.

Requires the in_app_calling capability.

Placing a call to a phone number (SIP)

initSipCall() places a call to an external phone number instead of another Kasookoo user.

Unlike initCall(), the SDK does not create the call on its own — it needs a call intent first, minted by your backend. The SDK never talks to your call-intent endpoint or holds your organization's secret key; it only dials with the short-lived intentId/clientSecret pair your frontend hands it.

On your backend, create that intent with kasookoo-server-sdk (Node.js — holds your org's secret key, never exposed to a browser):

// your backend
import { Kasookoo } from "kasookoo-server-sdk"
const kasookoo = new Kasookoo({ secretKey: process.env.KASOOKOO_SECRET_KEY! })

app.post("/my-api/call-intent", async (req, res) => {
    const intent = await kasookoo.callIntents.create({
        subject: "Website visitor",
        phoneNumber: req.body.phoneNumber,
    })
    res.json(intent)   // { intentId, clientSecret, ... }
})

Then, on the frontend, fetch that intent right before dialing:

// 1. Your frontend asks your backend for a call intent
const { intentId, clientSecret } = await fetch("/my-api/call-intent", {
    method: "POST",
    body: JSON.stringify({ phoneNumber: "+447783021617" }),
}).then((r) => r.json())

// 2. Dial with the intent
const call = await kasookoo.initSipCall({
    phoneNumber: "+447783021617",   // E.164 — same number the intent was created for
    intentId,
    clientSecret,
    participantName: "John Doe",    // optional — how your user appears on the call
})

| Parameter | Details | | --- | --- | | phoneNumber | Destination number, in E.164 format. | | intentId | The call intent id from your backend's call-intent endpoint. | | clientSecret | The client secret returned alongside intentId. Authorizes this dial — treat it as a short-lived credential. | | participantName | Optional. How your user appears on the call. Falls back to a generated name if omitted. |

Don't want to build this yourself? kasookoo-click-to-call-widget wraps this entire flow — dial pad UI, intent fetch, and initSipCall() — behind a <kasookoo-dialer> tag; you only point it at your call-intent route.

Only one call can be active at a time — initSipCall() throws call_in_progress if another call, of either kind, is already live.

Requires the sip_calling capability.

How placing a SIP call differs from placing an in-app call:

| | initCall() — in-app | initSipCall() — phone | | --- |------------------------------------------------------------------------| --- | | What you provide | caller and callee, full participant details | phoneNumber plus an intentId/clientSecret from your backend | | Recording | opt-out via isCallRecording (on by default) | not configurable here | | Direction | can be incoming or outgoing — see Receiving a call | always outgoing — a phone number can never call in | | Capability required | in_app_calling | sip_calling |

Both return the same kind of Call handle and are controlled identically from there on — that's next.

The call window

Every call — however it started — is represented by one Call object and shown in one call window for its entire lifetime, from ringing (if it rings) through to hang-up. By default the SDK renders this window itself: bottom-right, draggable, stacking automatically if more than one call is live, showing accept/reject while ringing, a cancel button while connecting, and mute + end + a running timer once connected. See Building a custom call window to replace it, or Headless below to disable it entirely.

Receiving a call

initCall() and initSipCall() both start calls you place. A call also arrives when someone calls your user — the SDK listens for that automatically and raises the window itself:

kasookoo.on("call:incoming", (call) => {
    console.log(`${call.remote.name} is calling`, call.roomName)
})

kasookoo.incomingCalls   // all calls currently ringing, oldest first

Rules the SDK enforces for a ringing call:

  • It stays on screen until manually accepted or rejected, the page reloads, or the ring timeout passes (see below) — whichever comes first.
  • Answering one call does not dismiss others that are also ringing — each keeps its own clock.
  • Accepting while already in a call shows a confirmation modal ("Disconnect & answer"). On confirm, the current call is disconnected, then the new one connects — the same window just transitions in place. On cancel, nothing changes.
  • Accepting needs no API round-trip — the push payload already carries this device's room credentials.
  • Duplicate pushes for a room already tracked are ignored.

Only an in-app call can arrive this way. A phone number can't call in — initSipCall() is always outgoing, so a SIP Call never enters "incoming" and call:incoming never fires for one.

Ring timeout

An unanswered call doesn't ring forever — both sides give up on their own after KasookooConfig.callRingTimeoutMs, which defaults to 60000 (60 seconds):

const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",
    callRingTimeoutMs: 30_000,   // or `false` to ring forever
})
  • The receiver auto-declines — identical to clicking "Reject".
  • The caller auto-cancels — identical to clicking "Cancel" while still connecting.
  • Pass false to disable it entirely for that client — calls then ring until manually accepted, declined, or cancelled.

This must be set to the same value on every client. The timeout runs independently inside each browser's own SDK instance — it is not something the backend coordinates between the two sides of a call. If a caller's client is configured with a different value than the receiver's (or one has it disabled and the other doesn't), the two sides will disagree about when the call has gone unanswered — e.g. the caller giving up at 30s while the receiver's window keeps ringing for the full 60s. Configure it once, and set it identically everywhere init() is called across your app.

The Call handle

One object for the whole lifetime of a call. Returned by initCall() / initSipCall(), emitted by call:incoming, passed to a custom window, and available via kasookoo.activeCall / kasookoo.incomingCalls.

| Member | Description | | --- | --- | | state | "incoming" (ringing) → "connecting" → "connected" → "ended". "connecting" means the same thing for every call, regardless of kind: placed, waiting for the other party to connect. | | direction | "outgoing" or "incoming". Always "outgoing" for a SIP call — see Placing a call to a phone number. | | kind | "webrtc" (in-app, via initCall) or "sip" (a phone number, via initSipCall). Informational only — every control below behaves the same regardless of which it is. | | remote | { name, type?, id? } — the OTHER side. For an in-app call: the callee's details when outgoing, the caller's when incoming. For a SIP call: name is the phone number, type is "sip", and id is absent. Use this for display. | | roomName | The call's room identifier. | | caller / callee | Full participant details — in-app outgoing calls only. undefined for incoming calls and for every SIP call, since a phone call has no caller/callee objects to begin with. | | muted | Current mic state. | | accept() | Answers a ringing call. No-op unless state === "incoming" — which means it is always a no-op for a SIP call, since those never ring in. Safe to call more than once. | | reject() | Declines a ringing call and closes its window; the caller stops ringing. No-op unless state === "incoming". | | setMuted(bool) | Mute/unmute the microphone (async). Identical for both kinds. | | end() | Hangs up from any state: declines if still ringing, otherwise leaves the call and ends it for the other side too. Identical for both kinds — call it the same way regardless of kind, and regardless of whether the far end ever answered. | | on("state", cb) | Every state transition; "ended" always fires exactly once, whoever hung up. | | on("error", cb) | Connection failures (call_connect_failed) — the call then ends itself. |

Headless (no window at all)

Pass callWindow: false to init() to disable all SDK UI, including the confirmation modal. Drive everything yourself from call:incoming and call.on("state", ...).

In headless mode, call.accept() does not ask for confirmation — it immediately disconnects any active call and joins the new one. Show your own confirm dialog first if you want one.

Building a custom call window

Pass a single renderer function instead of callWindow: false or leaving it unset, to keep the SDK's mounting/lifecycle behavior but draw your own UI.

type CallWindowRenderer = (container: HTMLElement, call: Call) => (() => void) | void

What the SDK guarantees to your renderer

  • Mounting and positioning are handled for you. container is already in the DOM, inside the fixed bottom-right calls stack — you never create or position it. You own everything inside it and nothing outside it.
  • Unmounting is handled for you. The container is removed when the call reaches "ended". Your returned cleanup function runs first — use it to clear timers and listeners.
  • The renderer runs ONCE per call, at creation — it is not re-invoked on state changes. Subscribe to call.on("state", ...) yourself to update your DOM as the same call moves from ringing to connected to ended.
  • Audio is never your problem. The SDK publishes the microphone and plays the remote audio. A renderer that draws nothing at all still has a fully working call.
  • A throwing renderer never kills the call: the SDK logs the error and falls back to its built-in window.
  • The switch-call confirmation modal remains SDK-provided even with a custom renderer — call.accept() triggers it automatically when another call is active.
  • Renderer config is validated at init(): anything other than a function or false throws invalid_config.

What differs between a phone call and an in-app call, for your renderer

One renderer serves both. A SIP call is an ordinary Call — same object, same states, same controls — so most of your window can ignore call.kind entirely. These are the only differences that affect what you draw:

| | in-app (kind: "webrtc") | phone (kind: "sip") | | --- | --- | --- | | direction | "incoming" or "outgoing" | always "outgoing" | | starts at | "incoming" when received, otherwise "connecting" | always "connecting" | | accept() / reject() | used when it rings in | never — a phone call cannot ring in | | remote.name | the other user's display name | the number being dialled | | remote.type | your participant type, e.g. "customer" | "sip" | | caller / callee | set on calls you place | always undefined | | end() | hangs up, answered or not | identical | | setMuted() | same | same |

"connecting" means the same thing for both — placed, waiting for the other party to connect — so render it identically for both kinds.

Two practical consequences:

  1. Show answer/decline only while state === "incoming". SIP calls never enter that state, so the same condition that drives your in-app UI hides those buttons automatically — no kind check needed.
  2. Start your call timer on "connected", not at mount. Otherwise a phone call appears to be counting call time while it is still ringing.

A renderer that handles both

const kasookoo = await KasookooClient.init({
    publishableKey: "pk_...",
    subject: "user_123",

    callWindow: (container, call) => {
        const who = document.createElement("div")
        const status = document.createElement("div")
        const answer = document.createElement("button")
        const decline = document.createElement("button")
        const mute = document.createElement("button")
        const hangup = document.createElement("button")

        // Works for both kinds: a name for in-app, the number for SIP.
        who.textContent = call.remote.name

        answer.textContent = "Answer"
        decline.textContent = "Decline"
        mute.textContent = "Mute"
        hangup.textContent = "End"

        answer.onclick = () => void call.accept()
        decline.onclick = () => call.reject()
        mute.onclick = () => {
            void call.setMuted(!call.muted)
            mute.textContent = call.muted ? "Mute" : "Unmute"
        }
        hangup.onclick = () => void call.end()   // correct in every state, both kinds

        container.append(who, status, answer, decline, mute, hangup)

        let timer: ReturnType<typeof setInterval> | undefined
        const render = (state: CallState) => {
            // Only in-app calls ring in, so this hides itself for SIP.
            const ringing = state === "incoming"
            answer.hidden = decline.hidden = !ringing
            mute.hidden = state !== "connected"

            status.textContent =
                ringing ? "Incoming call"
                : state === "connecting" ? "Connecting…"
                : state === "connected" ? "00:00"
                : "Ended"

            // Count from pickup, never from mount.
            if (state === "connected" && !timer) {
                const start = Date.now()
                timer = setInterval(() => {
                    const s = Math.floor((Date.now() - start) / 1000)
                    status.textContent = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`
                }, 250)
            }
        }

        render(call.state)
        const off = call.on("state", render)

        // Cleanup — runs immediately before the container is removed.
        return () => {
            off()
            clearInterval(timer)
        }
    },
})

Messaging

Two channels are supported: normal in-app messages and WhatsApp. In both cases the first message sent to a room also establishes the conversation — there is no separate setup call. Reuse the same roomName to keep messages in the same conversation thread.

Normal messages

await kasookoo.sendMessage({
    senderUserId: "user_123",
    receiverUserId: "user_456",
    roomName: "chat_user123_user456",
    message: "Hello!",
    contentType: "text",              // optional — defaults to "text"
    metadata: { orderId: "ord_42" },  // optional — arbitrary extra data attached to the message
})

Sharing location

sendLocation shares the user's current position as a normal in-app message — same channel and same in_app_messaging capability as sendMessage, there is no separate location capability. You don't provide coordinates yourself; the SDK reads them from the browser at send time:

await kasookoo.sendLocation({
    senderUserId: "user_123",
    receiverUserId: "user_456",
    roomName: "chat_user123_user456",
    metadata: { note: "meet here" },  // optional
})

Permission. init() requests location permission once, best-effort — a denial there never fails init(), it just means this method will throw when you actually call it:

try {
    await kasookoo.sendLocation({ senderUserId: "user_123", receiverUserId: "user_456", roomName: "chat_user123_user456" })
} catch (err) {
    if (err instanceof KasookooError && err.code === "location_permission_denied") {
        // show your own "enable location" prompt
    }
}

Other codes: location_unavailable (position could not be determined) and location_unsupported (geolocation isn't available in this environment).

WhatsApp messages

WhatsApp sends additionally need the associatedNumberId — the organization number the message goes out from:

await kasookoo.sendWhatsAppMessage({
    senderUserId: "user_123",
    receiverUserId: "user_456",
    roomName: "whatsapp_chat_user123",
    message: "Hello via WhatsApp!",
    associatedNumberId: "num_abc123",
    contentType: "text",              // optional — defaults to "text"
})

A "WHATSAPP" associated number is a prerequisite for this. associatedNumberId is the id of one, and there's no default — create it first with createAssociatedNumber(). See Associated numbers.

Receiving messages

Incoming messages arrive as push payloads on the notification:message event (see Events) — dedicated typed message events are on the roadmap. The payload is Record<string, string> — every value is a string, including anything numeric — and carries the same underlying fields as a ChatMessage from getMessages(), so the two can usually share one mapping function:

function toChatMessage(data: Record<string, string>) {
    // Call-signaling pushes land on the same event — skip anything that
    // isn't a chat message before treating it as one.
    if (!data.room_name || !data.message) return null

    return {
        id: data.id,
        conversationId: data.conversation_id,
        senderUserId: data.sender_user_id,
        receiverUserId: data.receiver_user_id,
        roomName: data.room_name,
        message: data.message,
        channel: data.channel,           // "normal" | "whatsapp"
        messageType: data.message_type,  // e.g. "text"
        createdAt: data.created_at,
    }
}

kasookoo.on("notification:message", (data) => {
    const message = toChatMessage(data)
    if (!message) return   // a call-signaling push, not a chat message
    appendToThread(message)
})

Push payloads are server-controlled and untyped by design (Record<string, string>) — confirm the exact field set with a console.log(data) in your own environment before relying on it, since it can gain fields over time.

Conversations and message history

Read-only — the SDK does not cache or persist results; call these again whenever you need fresh data.

const { items: conversations, pagination } = await kasookoo.getConversations({ skip: 0, limit: 50 })
// all fields optional — `userId` defaults to the session's subject (your end user); override only to fetch someone else's

const { items: messages } = await kasookoo.getMessages(conversations[0].conversation_id, { skip: 0, limit: 50 })
// same defaulting as getConversations — `userId` defaults to the session's subject; override only to fetch someone else's

const { unread_count } = await kasookoo.getUnreadCount()
const { unread_count: forOneThread } = await kasookoo.getUnreadCount({ conversationId: conversations[0].conversation_id })

Both getConversations() and getMessages() return { items, pagination: { total, skip, limit } }. A Conversation's channel is "normal" or "whatsapp"; a ChatMessage's channel is the same, and its message_type describes the content (e.g. "text").

Marking a conversation read

await kasookoo.markRead({ conversationId: "conv_...", messageIds: ["id1"] })  // marks all the messages whose messageId is in  messageIds array

Deleting a conversation

Deletes a conversation and its messages — there is no option to keep the messages while removing just the conversation:

const { conversation_id, delete_messages } = await kasookoo.deleteConversation("conv_...")
// `userId` defaults to the session's subject; pass a second argument to override
await kasookoo.deleteConversation("conv_...", "user_456")

Deleting a message

const { message_id } = await kasookoo.deleteMessage("msg_...")
// `userId` defaults to the session's subject; pass a second argument to override
await kasookoo.deleteMessage("msg_...", "user_456")

Firebase integration / advanced service worker setup

The SDK ships its own Firebase project (see Install) — signaling pushes (incoming calls, messages) come from it, and it's embedded so you don't configure anything to receive them. The single-file setup covered in Install is all you need unless your app already runs its own Firebase project with its own service worker.

Why a rename alone doesn't work

Two service worker files can't both live at /firebase-messaging-sw.js — copying Kasookoo's file over your own (or vice versa) silently overwrites one of them and breaks its push notifications.

Renaming the file isn't enough by itself, either: a service worker's default registration scope is the directory it's served from. A renamed copy still sitting at your site root (e.g. /kasookoo-messaging-sw.js) still defaults to scope / — the same scope your own worker already occupies. Only one script can control a given (origin, scope) pair; registering a second one at the same scope replaces the first rather than letting the two run side by side.

The fix: a different filename and a different scope

npx kasookoo-sdk --out path/to/public --filename kasookoo-messaging-sw.js
const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",
    serviceWorkerPath: "/kasookoo-messaging-sw.js",
    serviceWorkerScope: "/kasookoo-push/",
})

| Option | Default | Purpose | | --- | --- | --- | | serviceWorkerPath | /firebase-messaging-sw.js | Where the Kasookoo worker file is served from. Change this to whatever filename you copied it to. | | serviceWorkerScope | directory of serviceWorkerPath (browser default) | Registration scope. Change this to a path not already claimed by another worker — it doesn't need to correspond to a real folder of assets. |

The SDK registers this worker itself (rather than relying on Firebase's implicit default lookup) and hands that exact registration to Firebase when minting the device token — so it never touches, and is never replaced by, your own app's firebase-messaging-sw.js at the default scope. Your app's own Firebase setup needs no changes.

Both options default to the plain single-file setup if you don't have a Firebase project of your own to worry about — nothing here is required reading otherwise.

Users

User CRUD. Every method here resolves with (or lists) the same UserRecord shape — see The UserRecord shape below.

Creating a user

Only email, firstName, lastName and password are mandatory. Everything else is optional:

const user = await kasookoo.createUser({
    email: "[email protected]",
    firstName: "New",
    lastName: "User",
    password: "Password123!",
})
// user →
{
    id: "6a689e321b2f56106f6c5f8f",
    email: "[email protected]",
    phone_number: null,       // omitted above, so null
    first_name: "New",
    last_name: "User",
    role: "customer",         // defaulted — omitted above
    caller_id: null,          // omitted above, so null
    organization_id: "699305e39108f01fe9a037d6",
}

The optional fields, if you want them:

const user = await kasookoo.createUser({
    email: "[email protected]",
    firstName: "New",
    lastName: "Agent",
    password: "Password123!",
    phoneNumber: "+15550123456",   // optional
    role: "agent",                 // optional — free-form, any string; defaults to "customer" if omitted
    callerId: "+15550123456",      // optional — caller ID for outbound calls placed as this user
})

Updating a user

updateUser expects the full set of fields, not a partial update:

await kasookoo.updateUser(user.id, {
    email: "[email protected]",
    phoneNumber: "+15550123456",
    firstName: "Updated",
    lastName: "User",
    role: "driver",
    callerId: "+15550123456",   // optional
})

Deleting a user

await kasookoo.deleteUser(user.id)   // resolves with nothing if success else throws an error

Fetching a single user

const user = await kasookoo.getUser(user.id)  

Listing users

const { items, pagination } = await kasookoo.getUsers({ role: "customer", search: "waseem", skip: 0, limit: 100 })

role, search, skip and limit are all optional — omit everything to list all users. Read-only, nothing is cached. items is an array of UserRecord.

The UserRecord shape

Returned by createUser, updateUser, getUser, and as each item of getUsers' items array — identical shape everywhere:

interface UserRecord {
    id: string
    email: string
    phone_number: string | null   // null if never set
    first_name: string
    last_name: string
    role: string                  // free-form — not restricted to a fixed set
    caller_id: string | null      // null if never set
    organization_id: string
}

Associated numbers

CRUD for the PSTN and WhatsApp numbers a user can send from.

This is a prerequisite for WhatsApp messaging. sendWhatsAppMessage() needs an associatedNumberId, and the only way to get one is to create a "WHATSAPP" associated number here first and use its id. There is no default — you cannot send a WhatsApp message until at least one exists.

Requires the associated_number capability.

Creating a number

Both kinds share the same fields; only the ones below differ by type.

// PSTN
const pstn = await kasookoo.createAssociatedNumber({
    associatedNumber: "+15551234567",   // E.164 — mandatory
    userId: "user_123",                 // mandatory
    numberType: "PSTN",                 // mandatory
    label: "Support line",              // mandatory
    country: "pakistan",                // optional
    isEnable: true,                     // optional
    isPrimary: true,                    // optional
})

// WhatsApp
const whatsapp = await kasookoo.createAssociatedNumber({
    associatedNumber: "+447700900123",  // mandatory
    userId: "user_123",                 // mandatory
    numberType: "WHATSAPP",             // mandatory
    label: "WhatsApp support",          // mandatory
    whatsappNumberId: "whatsapp_phone_number_id",   // mandatory for WHATSAPP — from your WhatsApp Business setup
})

| Field | Required? | Notes | | --- | --- | --- | | associatedNumber | Always | The phone number, E.164 format. | | userId | Always | Who this number belongs to. | | numberType | Always | "PSTN" or "WHATSAPP" — nothing else. Can be changed later via update — e.g. converting PSTN to WhatsApp. | | label | Always | Display label, e.g. "Support line". | | whatsappNumberId | Only if numberType is "WHATSAPP" | Your WhatsApp Business phone number id. Must be omitted entirely when numberType is "PSTN" — providing it throws, same as omitting it for WhatsApp. | | country, city, prefix | Optional | Descriptive metadata. Same fields work for either type. | | isTollFree, isEnable, isPrimary | Optional | Same fields work for either type. |

The SDK checks the numberType / whatsappNumberId pairing itself and throws invalid_params before any request goes out — a bad numberType or a mismatched whatsappNumberId never reaches the backend.

Updating a number

Every field is optional on update — only what you pass is changed, everything else on the record stays as it was:

await kasookoo.updateAssociatedNumber(pstn.id, {
    label: "Updated label",
    isPrimary: false,
})

numberType can be changed — a PSTN number can be converted into a WhatsApp one, or back:

// Convert a PSTN number into a WhatsApp one
await kasookoo.updateAssociatedNumber(pstn.id, {
    numberType: "WHATSAPP",
    whatsappNumberId: "new_whatsapp_id",   // mandatory together with numberType: "WHATSAPP"
    label: "Now used for WhatsApp",
})

// Convert back to PSTN — whatsappNumberId must be omitted, not set to something falsy
await kasookoo.updateAssociatedNumber(whatsapp.id, {
    numberType: "PSTN",
})

Whenever numberType is set to "WHATSAPP" — in createAssociatedNumber() or here — whatsappNumberId is mandatory in that same call. Setting numberType to "PSTN" forbids it, for the same reason. The SDK checks this itself and throws invalid_params before any request goes out.

If you omit numberType entirely (the common case — changing some other field on an existing number), whatsappNumberId is sent exactly as given, with no consistency check, since the SDK doesn't know the record's current type without being told.

Deleting and listing

await kasookoo.deleteAssociatedNumber(pstn.id)

const { items, total } = await kasookoo.getAssociatedNumbers({ numberType: "WHATSAPP", skip: 0, limit: 100 })

getAssociatedNumbers() is read-only — nothing is cached, call it again for fresh data. numberType, skip and limit are all optional; omit numberType to get both kinds back together.

Error handling

All failures surface as a KasookooError:

| Property | Meaning | | ------------- | ------------------------------------------------------------------------ | | err.message | Human-readable description. For validation failures the offending fields are appended, e.g. Request validation failed (last_name: Field required) | | err.code | Machine-readable. Either the SDK's own — network_error, invalid_config, invalid_params, not_initialized, push_unsupported, device_registration_failed, ... — or, when the API reports one, its code passed straight through (not_found, validation_error, ...). Falls back to api_error. | | err.status | HTTP status code (when the error came from an API response) | | err.details | Full raw response body, for debugging |

CDR (call detail records)

Fetch past calls — read-only. The SDK does not cache or persist results itself; call it again whenever you need fresh data.

const cdr = await kasookoo.getCdr({
    search: "Jane",           // optional — free-text search
    callerId: "user_123",     // optional — restrict to calls placed BY this user
    calleeId: "user_456",     // optional — restrict to calls placed TO this user
    skip: 0,                  // optional — pagination offset, defaults to 0
    limit: 20,                // optional — page size, a default applies if omitted; capped at 100 (larger values are clamped)
    dateField: "created_at",  // optional — which timestamp field results are filtered/sorted by
})

cdr.pagination.total   // total matching records (across all pages)
cdr.items               // this page's entries — see CdrEntry below

All filters are optional — call getCdr() with no arguments for the first page of everything.

CdrEntry

| Field | Description | | --- | --- | | id / call_id / room_name | Identifiers for the call | | direction | e.g. "outbound" / "inbound" | | status | e.g. "ended", "waiting" | | kind | Call type, e.g. "pstn". A separate value space from Call.kind ("webrtc" | "sip") below — this is the backend's own CDR vocabulary, not the client-side Call object's. | | caller | { id, identity, name, email, phone_number, role, kind } — the side that placed the call. email, phone_number, role and kind may be null. | | callee | Same shape as caller, or null while the call has no answering side yet (e.g. still "waiting"). | | participants | Identities of everyone who joined the call | | created_at | ISO 8601 timestamp | | started_at | ISO 8601 timestamp, or null if the call hasn't started | | ended_at | ISO 8601 timestamp, or null if the call hasn't ended | | duration_seconds | Talk time, or null if the call hasn't ended | | end_reason | Present when the call ended abnormally (e.g. an orphaned session auto-cleaned up) | | recording_download_url | Present only when the call was recorded |

Downloading the full export

downloadCdr() fetches the export file (authenticated) and triggers a normal browser file save — nothing is returned to your code:

await kasookoo.downloadCdr({ maxRows: 1000 })   // maxRows is optional

The filename is taken from the server's response when it provides one, otherwise cdr-export.csv is used. Call it directly from a click handler (browsers are more permissive about file saves triggered by a user gesture).

Correlation & tracing

The SDK automatically attaches lifecycle correlation headers to every API request it makes, and exports OpenTelemetry traces automatically too, if the optional peer packages are installed. You don't need to do anything for this to work — it's on by default, with a real opt-out. This section covers what's sent, what gets logged to your browser console, and how to use or configure it.

What gets sent automatically

| Header | Purpose | When it's set | | --- | --- | --- | | X-Request-ID | Unique per HTTP request | Every request | | X-Call-Trace-ID | Stable id for one whole user action (a call, a message send) — reused across every request that action makes | At call start / message send | | X-User-ID | The resolved Kasookoo user id | After init() resolves the user | | X-Feature-Type | Which feature category the request belongs to — in_app_call, sip_call, in_app_messaging, whatsapp_messaging, etc. | On feature-related requests |

initCall() / initSipCall() generate one callTraceId for the whole call lifecycle (token mint, reject, hang-up all share it) and expose it as call.callTraceId. Each sendMessage() / sendLocation() / sendWhatsAppMessage() gets its own short-lived one. Read-only calls (getConversations, getMessages, etc.) don't send a callTraceId.

Console logging

The SDK logs one compact structured JSON line per request to the browser console — useful for your own debugging, or if you forward console output to a log pipeline:

{"level":"info","ts":1785407238.633,"logger":"kasookoo-sdk.http","msg":"POST /api/v1/webrtc/call/tokens 200","event":"http_access","method":"POST","status_code":200,"ok":true,"request_id":"7ccb6f86...","call_trace_id":"a1b2c3...","service":"kasookoo-web-sdk"}

On by default. Configure or disable it via init():

const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",
    logging: {
        enabled: true,                       // set false to silence it entirely
        level: ["info", "warn", "error"],     // which level(s) to show — default. See below.
        service: "my-web-app",
        env: "production",
        redactConsolePaths: true,             // default — masks request paths/URLs before they hit the console
    },
})

level picks exactly which levels to show — not a minimum. Pass one level (level: "debug") or a list (level: ["debug", "error"]) — only what you name is emitted, everything else is skipped. There's no "and everything above it" behavior: asking for "warn" alone shows only warn, not error too — list every level you want. Defaults to ["info", "warn", "error"], i.e. everything except the noisier debug level.

Sensitive fields (token, password, authorization, etc.) are redacted automatically before anything is logged. Separately, redactConsolePaths (on by default) masks the request path in what's printed to the console — e.g. GET [redacted] 200 instead of GET /api/v1/users/filter?limit=50 200 — since devtools is visible to anyone using the browser.

logging and telemetry are two fully independent switches, and neither redacts what the other sends. logging.enabled/logging.level control the browser console only; redactConsolePaths masks only the console line. Neither affects OpenTelemetry export: whenever telemetry is active, the OTel collector always receives the full, unredacted entry at every level, even while logging.enabled: false keeps the console silent — a deliberate, supported combination for a quiet production console that still reports everything to your collector.

Debugging in your app

Listen for trace:request to get the same ids in your own code — fires once per SDK HTTP request, after the response is received:

kasookoo.on("trace:request", ({ url, method, requestId, callTraceId, status, ok }) => {
    console.debug("[kasookoo]", { url, requestId, callTraceId, status })
})

Using the SDK's logger in your own code

getLogger() is exported directly, so your own app can log in the exact same structured JSON format shown above (level, ts, logger, msg, service, env, host, version, ...) — useful if you want your app's logs to interleave cleanly with the SDK's, in the console or in a forwarded log pipeline.

There are two ways to use it, depending on whether you want your logger tied to the SDK's settings or fully separate.

Option A — share the SDK's settings

Call getLogger(name) with just a name. This logger uses the same enabled/level/redactConsolePaths config as KasookooConfig.logging — if you change the SDK's logging config, this logger's behavior changes too.

import { getLogger } from "kasookoo-sdk"

const logger = getLogger("my-app.checkout")

logger.info({ msg: "order_placed", order_id: "ord_42" })
logger.warn("retrying payment capture")
logger.error({ msg: "payment_failed", order_id: "ord_42", reason: "card_declined" })

Option B — an independent logger with its own settings

Pass a second argument to give this logger its own config — its own enabled, level, service, etc. — completely separate from the SDK's. Neither one affects the other afterward, in either direction:

import { getLogger } from "kasookoo-sdk"

// This logger always shows debug + error, regardless of what the SDK's own
// logging.level is set to — and won't change if that's reconfigured later.
const checkoutLogger = getLogger("my-app.checkout", {
    level: ["debug", "error"],
    service: "my-web-app",
})

checkoutLogger.debug("cart_loaded")
checkoutLogger.error({ msg: "payment_failed", order_id: "ord_42" })

Use Option A when you just want your logs to follow whatever the SDK is already configured to show. Use Option B when you want a log level (or service name, or on/off switch) for your own module that's independent of the SDK's — e.g. debugging your own checkout flow at debug level while keeping the SDK's own internal logs at their normal info level.

Either way, each method accepts a plain string or an object (an object's msg/message/event field becomes the line's msg, everything else is attached as extra fields). Sensitive-looking keys (token, password, authorization, api_key, ...) are redacted automatically, same as SDK-internal logs.

Changing the SDK's own settings after init()

configureLogger() is what init() calls internally to apply KasookooConfig.logging. It's exported so you can call it again yourself later, e.g. to change the level at runtime — this only affects loggers made the Option A way (including the SDK's own internal loggers); independent (Option B) loggers are untouched:

import { configureLogger } from "kasookoo-sdk"

configureLogger({ level: "debug" })

Reference

| Function | Signature | Purpose | | --- | --- | --- | | getLogger(name, options?) | (name: string, options?: LoggerConfig) => StructuredLogger | Without options: shares the SDK's settings (Option A). With options: an independent logger with its own settings (Option B). | | configureLogger(options?) | (options?: LoggerConfig) => void | Changes the SDK's shared settings — same options as KasookooConfig.logging, callable directly. Never affects an independent (Option B) logger. |

interface StructuredLogger {
    debug(message: string | Record<string, unknown>, caller?: string): void
    info(message: string | Record<string, unknown>, caller?: string): void
    warn(message: string | Record<string, unknown>, caller?: string): void
    error(message: string | Record<string, unknown>, caller?: string): void
}

type LogLevel = "debug" | "info" | "warn" | "error"

interface LoggerConfig {
    enabled?: boolean                    // default true
    level?: LogLevel | LogLevel[]        // exact set to show — default ["info", "warn", "error"]
    service?: string                     // default "kasookoo-web-sdk"
    env?: string
    version?: string                     // default: the SDK's own package version
    host?: string                        // default window.location.hostname
    redactConsolePaths?: boolean         // default true — see "Console path redaction" above
}

LoggerConfig is the same shape as KasookooLoggingConfig used in KasookooConfig.logging — two names for the same fields, kept separate because one is reached through init() and the other is called directly.

Optional OpenTelemetry export

Distributed traces and logs (not just console logs) export automatically once the optional peer packages below are installed — no enabled: true needed:

npm install @opentelemetry/sdk-trace-web @opentelemetry/sdk-trace-base \
  @opentelemetry/exporter-trace-otlp-http @opentelemetry/sdk-logs \
  @opentelemetry/exporter-logs-otlp-http @opentelemetry/instrumentation \
  @opentelemetry/instrumentation-fetch @opentelemetry/resources \
  @opentelemetry/semantic-conventions @opentelemetry/api

Once installed, traces and logs export to Kasookoo's own monitoring collector automatically — in addition to any collector of your own — this is how we monitor SDK health and catch integration issues across every app using it, so that export can't be disabled or overridden. Set telemetry.enabled: false to turn off all export entirely, even with the packages installed (e.g. if they're there for an unrelated reason).

// No telemetry config needed at all — traces/logs export automatically
// now that the packages above are installed:
const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",
})

// Add your own collector(s) alongside Kasookoo's — still automatic, this
// just adds where else it goes:
const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",
    telemetry: {
        // Accepts a single URL or an array for more than one.
        endpoint: ["https://your-collector.example.com/v1/traces"],
        logsEndpoint: ["https://your-collector.example.com/v1/logs"],   // optional — defaults to each endpoint with /v1/traces swapped for /v1/logs, matched up by position
        serviceName: "my-web-app",   // optional
        environment: "production",    // optional
    },
})

// Opt out entirely — no export at all, even with the packages installed:
const kasookoo = await KasookooClient.init({
    publishableKey: "pk_live_...",
    subject: "[email protected]",
    telemetry: { enabled: false },
})

These packages are optional peer dependencies — if the trace ones aren't installed, telemetry just doesn't activate and the SDK continues working normally without trace export; the same applies independently to the log packages, so you can end up with traces but not logs, or vice versa, depending on which are present. Spans and log entries are tagged with the same request_id / call_trace_id / user_id / feature_type as the console logs above, so a trace and a log line for the same request can be cross-referenced. Everything shipped to the OpenTelemetry backend is full and unredacted, regardless of the console's redactConsolePaths setting.

A whole call is one trace, not one trace per request. initCall()/initSipCall() open a single span covering the call's entire lifecycle — every request tied to that call (starting it, and later ending it, whenever that happens) becomes a child span in the same trace instead of its own independent one. The span closes when the Call reaches "ended", however that happens (hangup, rejection, timeout, or a connection failure) — so a trace for a 10-minute call stays open for those 10 minutes. The same value is also attached as call_trace_id on every span and log line for that call, so you can filter by it directly without opening the full trace.

Advanced: manual correlation

For integrations outside the built-in call/messaging flows, the header-building helpers are exported directly:

import { FEATURE, buildCorrelationHeaders, newCallTraceId } from "kasookoo-sdk"

const headers = buildCorrelationHeaders({
    callTraceId: newCallTraceId(),
    userId: "usr_abc",
    featureType: FEATURE.IN_APP_MESSAGING,
})

await fetch("https://sdk.kasookoo.ai/api/v1/...", {
    headers: { ...headers, Authorization: "Bearer ..." },
})

Full type signatures for everything above are in Correlation & telemetry under Type definitions.

Other APIs

kasookoo.session               // { sessionId, subject, organizationId, expiresAt, allowedScopes } | null
kasookoo.getScopes()           // string[] — capabilities granted to this session (see "Capabilities")
kasookoo.initSipCall()         // Promise<Call> — call a phone number, see "Calls" above
kasookoo.activeCall            // Call | null — connecting/connected
kasookoo.incomingCalls          // Call[] — ringing
kasookoo.getCdr()               // Promise<CdrResponse> — see "CDR" above
kasookoo.downloadCdr()          // Promise<void> — triggers a browser file download
kasookoo.sendMessage()          // Promise<SendMessageResponse> — see "Messaging" above
kasookoo.sendLocation()         // Promise<SendMessageResponse> — see "Sharing location" above
kasookoo.sendWhatsAppMessage()  // Promise<SendMessageResponse> — see "Messaging" above
kasookoo.getConversations()     // Promise<ConversationsResponse> — see "Messaging" above
kasookoo.getMessages()          // Promise<MessagesResponse> — see "Messaging" above
kasookoo.getUnreadCount()       // Promise<UnreadCountResponse> — see "Messaging" above
kasookoo.markRead()             // Promise<void> — see "Messaging" above
kasookoo.deleteConversation()   // Promise<DeleteConversationResponse> — see "Messaging" above
kasookoo.deleteMessage()        // Promise<DeleteMessageResponse> — see "Messaging" above
kasookoo.createUser()           // Promise<UserRecord> — see "Users" above
kasookoo.updateUser()           // Promise<UserRecord> — see "Users" above
kasookoo.deleteUser()           // Promise<void> — see "Users" above
kasookoo.getUser()              // Promise<UserRecord> — see "Fetching a single user" above
kasookoo.getUsers()             // Promise<UserListResponse> — see "Listing users" above
kasookoo.createAssociatedNumber()   // Promise<AssociatedNumber> — see "Associated numbers" above
kasookoo.updateAssociatedNumber()   // Promise<AssociatedNumber> — see "Associated numbers" above
kasookoo.deleteAssociatedNumber()   // Promise<void> — see "Associated numbers" above
kasookoo.getAssociatedNumbers()     // Promise<AssociatedNumbersResponse> — see "Associated numbers" above
kasookoo.close()                // end active call, unregister the device, revoke the session with the backend, stop scheduler + listener, clear stored session

Type definitions

Every type below is exported from kasookoo-sdk directly (import type { ... } from "kasookoo-sdk"). This is a quick-reference index — each one is documented in context in the sections above; only the shapes are repeated here.

Setup

interface KasookooConfig {
    publishableKey: string
    subject: string                            // end user's email — see "Getting started"
    baseUrl?: string                            // default "https://sdk-test.kasookoo.ai"
    callWindow?: CallWindowRenderer | false     // see "Building a custom call window"
    callRingTimeoutMs?: number | false          // default 60000 — see "Ring timeout"
    serviceWorkerPath?: string                  // default "/firebase-messaging-sw.js" — see "Firebase integration"
    serviceWorkerScope?: string                 // see "Firebase integration"
    logging?: KasookooLoggingConfig             // see "Correlation & tracing"
    telemetry?: KasookooTelemetryConfig          // see "Correlation & tracing"
}

interface KasookooLoggingConfig {
    enabled?: boolean                                 // default true
    level?: LogLevel | LogLevel[]                      // default ["info", "warn", "error"] — exact set, not a minimum
    service?: string                                  // default "kasookoo-web-sdk"
    env?: string
    version?: string
    host?: string                                     // default window.location.hostname
    redactConsolePaths?: boolean                       // default true — masks request paths in console output only
}

interface KasookooTelemetryConfig {
    enabled?: boolean               // default: automatic — on once the optional @opentelemetry/* peer deps are installed; set false to opt out
    // Your own OTLP/HTTP traces endpoint(s) — sent in addition to Kasookoo's
    // own monitoring collector, which is always exported to whenever
    // telemetry is active and can't be disabled or replaced. Pass an array
    // for more than one.
    endpoint?: string | string[]
    // OTLP/HTTP logs endpoint(s) for the collector(s) above — defaults to each
    // traces endpoint with /v1/traces swapped for /v1/logs. Array matched up
    // by position when `endpoint` is an array.
    logsEndpoint?: string | string[]
    serviceName?: string             // default "kasookoo-web-sdk" — locked to this on Kasookoo's own collector regardless
    environment?: string             // default "production" — locked to "production" on Kasookoo's own collector regardless
}

interface SessionInfo {
    sessionId: string
    subject: string
    organizationId: string
    expiresAt: number
    allowedScopes: string[]
}

type KasookooScope =
    | "in_app_calling" | "in_app_messaging" | "sip_calling"
    | "whatsapp" | "authentication_calling" | "recording"   // reserved — no web-SDK feature yet
    | "user" | "organization" | "cdr" | "associated_number"

Calls

type CallState = "incoming" | "connecting" | "connected" | "ended"
type CallKind = "webrtc" | "sip"

interface RemoteParty {
    name: string
    type?: string
    id?: string
}

interface CallParticipant {
    name: string
    email: string
    phoneNumber?: string   // optional — the backend does not require it
    type: string   // free-form, e.g. "customer" | "agent"
}

interface InitCallParams {
    roomName: string
    caller: CallParticipant
    callee: CallParticipant
    deviceType?: string
    isCallRecording?: boolean   // default true
}

interface SipCallParams {
    phoneNumber: string    // E.164
    intentId: string       // from your backend's call-intent endpoint
    clientSecret: string   // from your backend's call-intent endpoint
    participantName?: string
}

type CallWindowRenderer = (container: HTMLElement, call: Call) => (() => void) | void

The Call handle itself (state, direction, kind, remote, accept(), reject(), setMuted(), end(), on(), ...) is documented in full in The Call handle — it's a class, not a plain data interface, so it isn't repeated here.

Messaging

interface SendMessageParams {
    senderUserId: string
    receiverUserId: string
    roomName: string
    message: string
    contentType?: string             // default "text"
    metadata?: Record<string, unknown>
}

interface SendLocationParams {
    senderUserId: string
    receiverUserId: string
    roomName: string
    metadata?: Record<string, unknown>
}

interface SendWhatsAppMessageParams {
    senderUserId: string
    receiverUserId: string
    roomName: string
    message: string
    associatedNumberId: string
    contentType?: string
}

interface ConversationsQuery {
    userId?: string
    skip?: number
    limit?: number
}

interface Conversation {
    conversation_id: string
    room_name: string
    participant_user_id: string
    participant_name: string | null
    participant_email: string | null
    channel: string              // "normal" | "whatsapp"
    associated_number_id: string | null
    last_message: string
    last_message_at: string
    unread_count: number
    created_at: string
    updated_at: string
}

interface ChatMessage {
    id: string
    conversation_id: string
    sender_user_id: string
    receiver_user_id: string
    room_name: string
    message: string
    channel: string              // "normal" | "whatsapp"
    message_type: string         // e.g. "text"
    metadata: Record<string, unknown>
    created_at: string
    read_at: string | null
}

interface UnreadCountQuery {
    userId?: string
    conversationId?: string
}

interface MarkReadParams {
    conversationId: string
    messageIds: string[]
    userId?: string
}

interface DeleteConversationResponse {
    success: boolean
    conversation_id: string
    delete_messages: boolean
}

interface DeleteMessageResponse {
    success: boolean
    message_id: string
}

interface PageInfo {
    total: number
    skip: number
    limit: number
}

Users

interface CreateUserParams {
    email: string
    firstName: string
    lastName: string
    password: string
    role?: string              // free-form, defaults to "customer"
    phoneNumber?: string
    callerId?: string
}

interface UpdateUserParams {
    email: string
    phoneNumber: string
    firstName: string
    lastName: string
    role: string
    callerId?: string
}

interface UserRecord {
    id: string
    email: string
    phone_number: string | null
    first_name: string
    last_name: string
    role: string
    caller_id: string | null
    organization_id: string
}

interface UserQuery {
    role?: string
    search?: string
    skip?: number
    limit?: number
}

Associated numbers

type AssociatedNumberType = "PSTN" | "W