Skip to content

Build on Frosvo

Read and answer conversations from your own systems, keep contacts in sync, publish help articles the assistant learns from, and hear about everything as it happens.

Quick start

Make a key in Settings → API (workspace owners, Essentials plan and up). Give it only the scopes it needs. Then:

curl https://frosvo.com/api/v1/conversations?status=open \
  -H "Authorization: Bearer frv_your_key"

Every answer is JSON. Lists come a page at a time: pass ?limit= (up to 100) and the next_cursor you got as ?cursor=; it's null on the last page.

Keys and scopes

Keys look like frv_ followed by 43 characters. Frosvo keeps only a hash: copy a key when it's made — it isn't shown again. Keys can expire, and revoking one stops it at once.

  • conversations:read — Read conversations and their messages
  • conversations:write — Reply, add notes, resolve, assign and tag conversations
  • contacts:read — Read contacts
  • contacts:write — Create and update contacts
  • knowledge:read — Read knowledge base articles
  • knowledge:write — Create and update knowledge base articles
  • reports:read — Read report numbers
  • webhooks:manage — Create, change and replay webhooks
  • audit:read — Read the workspace's audit log (for a SIEM)

Errors and limits

Errors are { "error": { "code": "…", "message": "…" } }. The code is stable; the message is for people.

Supported API responses include a server-generated X-Request-Id. Include it when reporting a failure. Unexpected failures also include error.request_id in the JSON response.

  • 401 invalid_token — no key, or revoked or expired
  • 403 insufficient_scope — the key lacks the scope named in the message; plan_required, plan_limit
  • 404 not_found — doesn't exist, or isn't in this key's workspace
  • 400 invalid_request, 409 channel_refused (for example, outside WhatsApp's 24-hour window)
  • 409 reply_collision — another public reply arrived after the version you reviewed; read the conversation again and review it before retrying
  • 429 rate_limited — each key gets a number of requests a minute (X-RateLimit-Limit, -Remaining, -Reset); wait Retry-After seconds
  • 500 server_error — unexpected failure; report the request ID and verify the outcome before retrying a write
  • 503 temporarily_unavailable — database unavailable or busy; wait Retry-After seconds and verify ambiguous write outcomes before retrying

Endpoints

All under https://frosvo.com/api/v1.

get/meThe key: its workspace, scopes and quota
get/streamLive events (Server-Sent Events): message.created, conversation.updated, typing, conversation.read, message.removed, message.deliveryconversations:read
get/conversationsList conversations, most recently active firstconversations:read

Query: status (open | active | closed), site_id, updated_since, limit, cursor

get/conversations/{id}A conversation with its contact and latest 200 messagesconversations:read
get/conversations/{id}/messagesMessages, oldest firstconversations:read

Query: limit, cursor

post/conversations/{id}/messagesReply to the visitor, or add a private team noteconversations:write
post/conversations/{id}/actionsResolve, reopen, assign, tag or mark as spamconversations:write
get/contactsList or find contactscontacts:read

Query: email, q, site_id, tag, limit, cursor

post/contactsCreate a contact, or update the one with this email on this websitecontacts:write
get/contacts/{id}A contactcontacts:read
patch/contacts/{id}Change a contactcontacts:write
get/articlesList knowledge base articlesknowledge:read

Query: published, limit, cursor

post/articlesPublish an article (the assistant learns it)knowledge:write
get/articles/{id}An articleknowledge:read
patch/articles/{id}Change an articleknowledge:write
get/reports/summaryReport numbers for a date rangereports:read

Query: from, to, tz, site_id, channel

get/auditThe audit log, newest first, a page at a timeaudit:read

Query: action, since, limit, cursor

get/webhooksList webhook endpointswebhooks:manage
post/webhooksAdd an endpoint (its signing secret is in this answer)webhooks:manage
get/webhooks/{id}An endpointwebhooks:manage
patch/webhooks/{id}Change an endpoint, or switch it on/offwebhooks:manage
delete/webhooks/{id}Delete an endpoint and its delivery historywebhooks:manage
get/webhooks/{id}/deliveriesDeliveries to an endpoint, newest firstwebhooks:manage

Query: status (pending | sending | succeeded | failed | dead), before

post/webhooks/{id}/replaySend an event to this endpoint againwebhooks:manage
get/eventsEvents sent to this workspace's webhooks, newest firstwebhooks:manage

Query: type, before

Request and response shapes for each: /openapi.json (import it into Postman, Insomnia or a code generator).

TypeScript client

One file, no dependencies, typed: /sdk/frosvo.ts. Node 18+, Deno, Bun.

import { Frosvo, FrosvoError } from "./frosvo";

const frosvo = new Frosvo({ apiKey: process.env.FROSVO_API_KEY!, baseUrl: "https://frosvo.com" });

// Every open conversation, page after page
for await (const c of frosvo.conversations.all({ status: "open" })) {
  const { messages, contact } = await frosvo.conversations.get(c.id);
  console.log(contact.email, messages.at(-1)?.text);
}

// Review the conversation, then guard the reply against another writer
const reviewed = await frosvo.conversations.get(id);
await frosvo.conversations.reply(id, "Your refund is on its way.", {
  senderName: "Billing", expectedReplyVersion: reviewed.reply_version
});
await frosvo.conversations.tag(id, "refund");
await frosvo.conversations.resolve(id);

// Keep contacts in sync (creates, or updates the same email on that website)
const { data, created } = await frosvo.contacts.upsert(siteId, { email: "pat@example.com", name: "Pat", tags: ["customer"] });

try {
  await frosvo.contacts.get("nope");
} catch (e) {
  if (e instanceof FrosvoError && e.code === "not_found") { /* … */ }
}

Chat widget JavaScript API

The snippet adds window.Frosvo to your page. Call it once the script has loaded:

<script src="https://frosvo.com/w.js" data-site="your-site-key" async></script>
<script>
  window.addEventListener("load", function () {
    Frosvo.setUser({ name: "Pat Lee", email: "pat@example.com" }); // prefill who's chatting
    Frosvo.setPage("Checkout", location.href);                       // what the team sees as their page
    document.querySelector("#help").onclick = function () { Frosvo.open(); };
  });
</script>
  • Frosvo.open(), Frosvo.close(), Frosvo.toggle() — show or hide the chat.
  • Frosvo.setUser({ name, email }) — prefill the visitor's details. Add hash (hex HMAC-SHA256 of the lowercase email with the website's secret from Settings → Websites, made on your server) to verify the identity: the team sees a verified badge and the person's history follows them across devices.
  • Frosvo.setPage(name, url) — tell the team which page they're on (single-page apps).
  • Frosvo.setConsent(true | false) — connect your cookie banner: no page-based suggestions or visit recordings until it's true.
  • data-record="off" on the snippet turns visit recordings off for that page.
  • data-locale="es" on the snippet picks the widget's language (otherwise the visitor's browser decides).

The ticket form is one more line: <script src="https://frosvo.com/f.js" data-site="your-site-key"></script> where the form should appear.

Real-time (RTM)

Hear about every message, status change, typing indicator and read receipt the moment it happens — no public endpoint needed. Two ways in, both with a key that has conversations:read:

WebSocket

const ws = new WebSocket("wss://frosvo.com/v1/rtm");
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", token: "frv_your_key" }));
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "event" && msg.event === "message.created") {
    // Answer over the same socket — any REST call works
    ws.send(JSON.stringify({ type: "request", id: "1", method: "POST",
      path: `conversations/${msg.data.conversation_id}/messages`, body: { text: "On it!" } }));
  }
  if (msg.type === "response") console.log(msg.id, msg.status, msg.body);
};
  • Authenticate with the first message (within 10 seconds) or with ?token= in the address. A wrong key gets an error and close code 4001.
  • You receive {"type":"ready"}, then {"type":"event","event":"…","id":"…","data":{…}}.
  • {"type":"request","id","method","path","body"} runs any endpoint above (path relative to /api/v1) and answers {"type":"response","id","status","body"}.
  • {"type":"ping"} → {"type":"pong"}. The server pings idle sockets every 30 seconds.

Server-Sent Events

curl -N https://frosvo.com/api/v1/stream -H "Authorization: Bearer frv_your_key"

event: ready
data: {"event":"ready","workspace_id":"…"}

id: 4812
event: message.created
data: {"event":"message.created","conversation_id":"…","message":{…}}

Every event has an id. Reconnect with Last-Event-ID and you get what you missed (the WebSocket gateway does this for you). Events: message.created, message.removed, message.delivery, conversation.updated, conversation.read, typing. Revoking the key ends open streams within 25 seconds.

Webhooks

Add an endpoint in Settings → API → Webhooks (or POST /webhooks). Frosvo POSTs JSON to it when something happens:

  • conversation.created — A new conversation starts (its first message)
  • conversation.resolved — A conversation is resolved
  • conversation.reopened — A resolved conversation is reopened
  • conversation.assigned — A conversation is assigned, reassigned or released
  • conversation.handed_over — The assistant hands a conversation to the team
  • message.created — A message from the visitor, the assistant or the team (not private notes)
  • contact.created — A contact is created
  • contact.updated — A contact is changed
  • rating.created — A visitor rates a conversation
  • sla.breached — A first-response or resolution deadline is missed
POST /your-endpoint
Frosvo-Event-Id: evt_7c1f…          ← dedupe on this
Frosvo-Event-Type: message.created
Frosvo-Signature: t=1760000000,v1=5f2b…

{ "id": "evt_7c1f…", "type": "message.created", "created_at": "…",
  "workspace_id": "…", "data": { "conversation_id": "…", "message": { … } } }

Answer with any 2xx within 10 seconds. Anything else is retried after 1, 5, 30, 120, 360, 720 and 1440 minutes, then given up; 50 failures in a row switch the endpoint off. A delivery can arrive more than once — use the event id. Every delivery, with your endpoint's answer, is in the desk, where any can be sent again.

Check the signature on the raw body before trusting it: HMAC-SHA256 of <t>.<body> with the endpoint's secret, compared in constant time, and reject timestamps older than five minutes.

// Node (Express) — with the client: verifyWebhook(rawBody, header, secret)
import crypto from "node:crypto";
app.post("/frosvo", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Frosvo-Signature") ?? "";
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", process.env.FROSVO_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`).digest("hex");
  const ok = v1 && expected.length === v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) &&
    Math.abs(Date.now() / 1000 - Number(t)) < 300;
  if (!ok) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  res.sendStatus(200); // answer first, then work
});
# Python (Flask)
import hmac, hashlib, time, os
from flask import request

@app.post("/frosvo")
def frosvo():
    parts = dict(p.split("=", 1) for p in request.headers.get("Frosvo-Signature", "").split(","))
    body = request.get_data(as_text=True)
    expected = hmac.new(os.environ["FROSVO_WEBHOOK_SECRET"].encode(), f"{parts.get('t')}.{body}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(400)
    event = request.get_json()
    return "", 200
<?php // PHP
$body = file_get_contents("php://input");
parse_str(str_replace(",", "&", $_SERVER["HTTP_FROSVO_SIGNATURE"] ?? ""), $sig);
$expected = hash_hmac("sha256", ($sig["t"] ?? "") . "." . $body, getenv("FROSVO_WEBHOOK_SECRET"));
if (!hash_equals($expected, $sig["v1"] ?? "") || abs(time() - (int)($sig["t"] ?? 0)) > 300) {
    http_response_code(400); exit;
}
$event = json_decode($body, true);
http_response_code(200);

Account connector

Put your own system — a hosting panel, a shop, a CRM — beside every conversation. In Settings → Integrations, give Frosvo your connector's address. Frosvo POSTs JSON to it, signed exactly like webhooks (check Frosvo-Signature with the connector's secret), with Frosvo-Request-Type naming the request. Answer within 10 seconds.

manifest — what your system lets the team do. Frosvo asks when the connector is saved and when the owner asks again. Only these actions can ever be run; the owner can switch any off; roles says who may run it (owners only if you leave it out).

→ { "type": "manifest" }
← { "actions": [
    { "key": "resend_invoice", "label": "Resend invoice", "roles": ["OWNER", "AGENT"],
      "params": [{ "key": "invoice", "label": "Invoice number", "type": "text", "required": true }] },
    { "key": "reboot", "label": "Reboot server", "danger": true, "roles": ["OWNER"],
      "confirm": "The server restarts and goes offline for about a minute.",
      "params": [{ "key": "server", "label": "Server", "type": "select", "options": ["web-01", "web-02"], "required": true }] }
  ] }

lookup — who the customer in a conversation is, in your system. Frosvo sends what it knows; external_id is your own user id when they chatted signed in.

→ { "type": "lookup", "conversation_id": "…",
    "contact": { "id": "…", "email": "pat@example.com", "name": "Pat", "phone": null, "external_id": "4711", "site": { "id": "…", "name": "KryoHost" } } }
← { "account": { "id": "4711", "name": "Pat Doe", "email": "pat@example.com", "status": "Active", "url": "https://panel.example.com/clients/4711",
    "summary": [{ "label": "Plan", "value": "Business hosting" }, { "label": "Balance", "value": "$0.00" }],
    "items": [{ "title": "web-01", "subtitle": "VPS · Frankfurt", "status": "Running", "url": "https://panel.example.com/services/91" }] } }
← { "account": null }   // not one of yours

action — someone on the team ran one. Parameters are checked against your manifest first; the person's role is checked against it too. Use idempotency_key so a retried request doesn't act twice.

→ { "type": "action", "action": "resend_invoice", "params": { "invoice": "INV-1042" },
    "contact": { … }, "conversation_id": "…", "idempotency_key": "…",
    "agent": { "id": "…", "name": "Amy", "email": "amy@example.com", "role": "AGENT" } }
← { "ok": true, "message": "Invoice INV-1042 sent to pat@example.com." }
← { "ok": false, "error": "That invoice is already paid." }

Every attempt — done, failed, or refused by Frosvo — is recorded with who, when, the parameters and your answer, and a private note is added to the conversation.

Plugins

A plugin is your HTTPS service that any Frosvo workspace can install from Plugins in the desk. Build one in Plugins → Build your own: it stays private to your workspace (install it there to try it) until you submit it and Frosvo approves it for every customer. You choose what it may reach — API scopes and events — and the installing owner sees and accepts exactly that.

Signing. Every request Frosvo sends your plugin carries Frosvo-Timestamp and Frosvo-Signature: t=…,v1=…, an HMAC-SHA256 with your plugin's signing secret over t + "." + body — the same check as webhooks above. Refuse anything that doesn't match or is older than five minutes.

Lifecycle events (to your events URL). On install you get the workspace's settings and an API key limited to your scopes — keep it per install_id.

→ { "type": "plugin.installed", "workspace_id": "…",
    "data": { "install_id": "…", "plugin": "acme-crm", "api_key": "frv_…", "scopes": ["contacts:read"],
              "settings": { "account": "ACME-1" } } }
→ { "type": "plugin.settings_updated", "data": { "install_id": "…", "settings": { … } } }
→ { "type": "plugin.uninstalled", "data": { "install_id": "…" } }   // the key stops working
→ { "type": "conversation.resolved", … }   // and the events you asked for, as webhooks

Panel (your panel URL): shown next to a conversation. Frosvo sends the conversation, contact and recent messages; you answer with sections. Items: text, badge (tone good/warn/bad/neutral), link (https only), button (optionally with form fields), divider. Anything else is dropped. Answer within 6 seconds.

→ { "type": "widget", "install_id": "…", "workspace_id": "…", "settings": { … },
    "conversation": { "id": "…", "status": "OPEN", "channel": "WEB", "subject": "…", "tags": [], "url": "…" },
    "contact": { "id": "…", "name": "Pat", "email": "pat@example.com", "phone": null, "fields": {} },
    "messages": [{ "from": "visitor", "text": "…", "at": "…" }],
    "agent": { "id": "…", "name": "Amy", "email": "amy@example.com" } }
← { "sections": [{ "title": "Acme account", "items": [
      { "type": "text", "label": "Plan", "value": "Gold" },
      { "type": "badge", "label": "Invoices", "value": "Overdue", "tone": "bad" },
      { "type": "link", "label": "Open in Acme", "url": "https://acme.example/c/4711" },
      { "type": "button", "label": "Give credit", "action": "credit", "confirm": "Give credit?",
        "fields": [{ "key": "amount", "label": "Amount", "type": "number", "required": true }] } ] }] }

Actions (your actions URL): a button was pressed. You get the same context plus action and input; answer within 15 seconds.

→ { "type": "action", "action": "credit", "input": { "amount": "10" }, "conversation": { … }, "contact": { … }, "agent": { … } }
← { "message": "Credited $10." }      or      { "error": "That account is closed." }

Frame (optional): instead of sections, a page of yours shown in a frame. Its URL gets ?frosvo=<payload>.<mac> — base64url JSON (install_id, conversation_id, contact, agent, exp) and its HMAC-SHA256 (base64url) with your secret. Check both and the expiry.

Settings you declare (text, secret, url, email, number, textarea, toggle) are what the installing owner fills in; secrets are stored encrypted and only ever sent to you.