Integration Scenarios
Common patterns for integrating external applications with an active Virbe kiosk or web widget using the API.
Building an Operator Control Panel
The example used throughout is an operator control panel – an external app that finds the active conversation on a kiosk or web widget and lets a human send messages or take over in real time.
Architecture Overview
The typical integration flow looks like this:
- A kiosk or web widget starts a conversation via its connected profile.
- Your external application polls the API by
profileIdto discover the active conversation. - Once found, your application sends messages or actions to that conversation using the Messages API.
All integration calls are server-side only. Use the x-virbe-api-key header for authentication – see Authentication.
Step 1 – Know Your Profile ID
Each kiosk or web widget is connected through a profile. You can find your profile ID in the dashboard under Profiles → General – it appears in the browser URL:
https://hub.virbe.app/dashboard/profiles/PROFILE_ID/edit/generalYou will use this profile ID to filter conversations and identify sessions from a specific touchpoint.
Step 2 – Discover the Active Conversation
Poll the List Conversations endpoint with your profileId to find the currently active session in a single API call:
curl -X GET \
"https://my-assistant.virbe.app/api/v1/conversations-history?profileId=PROFILE_ID&sortBy=CreatedAtDesc&pageSizeAndNumber[size]=1&pageSizeAndNumber[number]=1" \
-H "x-virbe-api-key: YOUR_API_KEY"The first conversation in the response is the most recent session for that profile. Check its state – if it is still active (not closed), that is your target. Note the id from the response.
For a kiosk with a single concurrent user, the most recent conversation for that profile is almost always the active session.
Step 3 – Send Messages to the Active Conversation
Once you have the conversation id, use the Create Message endpoint to inject text or trigger named actions:
Sending a text message
curl -X POST \
"https://my-assistant.virbe.app/api/v1/conversations/CONVERSATION_ID/messages/add" \
-H "x-virbe-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"senderId": "operator-panel",
"action": {
"text": { "text": "The operator says hello!" }
}
}'Triggering a named action
curl -X POST \
"https://my-assistant.virbe.app/api/v1/conversations/CONVERSATION_ID/messages/add" \
-H "x-virbe-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"senderId": "operator-panel",
"action": {
"customAction": { "name": "welcome" }
}
}'Step 4 – Enable Human Handover
When an operator takes over a conversation, you should mark it as a human handover. This signals to the system that a human is now handling the conversation, which can affect conversation flow behavior and analytics.
PATCH /api/v1/conversations/:id/human-handover
curl -X PATCH \
"https://my-assistant.virbe.app/api/v1/conversations/CONVERSATION_ID/human-handover" \
-H "x-virbe-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true
}'Set "enabled": false to return the conversation back to the virtual being.
This endpoint requires an API key with the ConversationActions scope.
Polling for Active Sessions
Since the API is REST-based, your application should poll at a regular interval to detect new conversations. A simple polling loop works well:
- Every few seconds, call
GET /api/v1/conversations-history?profileId=PROFILE_ID&sortBy=CreatedAtDesc&pageSizeAndNumber[size]=1&pageSizeAndNumber[number]=1. - Compare the returned conversation
idto the one you already have. - If it changed, update your local state – a new session has started.
Do not poll more frequently than once every 2–3 seconds to avoid rate limiting.
Example: React Operator Panel
Below is a minimal React component that polls for the active conversation and lets an operator send text messages and named actions:
import { useState, useEffect, useCallback } from "react";
const API_BASE = "https://my-assistant.virbe.app/api/v1";
const API_KEY = "YOUR_API_KEY"; // Keep this server-side in production!
const PROFILE_ID = "YOUR_PROFILE_ID";
export function OperatorPanel() {
const [conversationId, setConversationId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [status, setStatus] = useState("Polling...");
// Poll for the active conversation by profileId
useEffect(() => {
let active = true;
async function poll() {
try {
const res = await fetch(
`${API_BASE}/conversations-history?profileId=${PROFILE_ID}&sortBy=CreatedAtDesc&pageSizeAndNumber[size]=1&pageSizeAndNumber[number]=1`,
{ headers: { "x-virbe-api-key": API_KEY } }
);
const data = await res.json();
const latest = data?.results?.[0];
if (active && latest?.id) {
setConversationId(latest.id);
setStatus("Connected ✅");
} else {
if (active) setStatus("No active conversation");
}
} catch (err) {
if (active) setStatus("Error polling");
}
}
poll();
const interval = setInterval(poll, 3000);
return () => {
active = false;
clearInterval(interval);
};
}, []);
// Send a text message
const sendText = useCallback(
async (text: string) => {
if (!conversationId) return;
await fetch(`${API_BASE}/conversations/${conversationId}/messages/add`, {
method: "POST",
headers: {
"x-virbe-api-key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
senderId: "operator-panel",
action: { text: { text } },
}),
});
},
[conversationId]
);
// Send a named action
const sendAction = useCallback(
async (name: string) => {
if (!conversationId) return;
await fetch(`${API_BASE}/conversations/${conversationId}/messages/add`, {
method: "POST",
headers: {
"x-virbe-api-key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
senderId: "operator-panel",
action: { customAction: { name } },
}),
});
},
[conversationId]
);
return (
<div style={{ fontFamily: "sans-serif", maxWidth: 480, margin: "0 auto", padding: 24 }}>
<h2>Operator Panel</h2>
<p>
Status: <strong>{status}</strong>
</p>
{conversationId && (
<p style={{ fontSize: 12, color: "#666" }}>
Conversation: <code>{conversationId}</code>
</p>
)}
<h4>Quick Actions</h4>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
{["welcome", "info", "help", "goodbye"].map((action) => (
<button key={action} onClick={() => sendAction(action)}>
{action}
</button>
))}
</div>
<h4>Send Text</h4>
<form
onSubmit={(e) => {
e.preventDefault();
sendText(message);
setMessage("");
}}
>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message..."
style={{ width: "100%", padding: 8, marginBottom: 8 }}
/>
<button type="submit" disabled={!conversationId}>
Send
</button>
</form>
</div>
);
}Example: Plain HTML Operator Panel
A standalone HTML file that works without any build tools:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Virbe Operator Panel</title>
<style>
body { font-family: sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; }
h4 { margin-bottom: 8px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.actions button { padding: 8px 16px; cursor: pointer; }
input[type="text"] { width: 100%; padding: 8px; box-sizing: border-box; margin-bottom: 8px; }
.status { margin-bottom: 16px; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
</style>
</head>
<body>
<h2>Virbe Operator Panel</h2>
<div class="status">Status: <strong id="status">Polling...</strong></div>
<div id="conv-info" style="display:none; font-size:12px; color:#666; margin-bottom:16px;">
Conversation: <code id="conv-id"></code>
</div>
<h4>Quick Actions</h4>
<div class="actions">
<button onclick="sendAction('welcome')">welcome</button>
<button onclick="sendAction('info')">info</button>
<button onclick="sendAction('help')">help</button>
<button onclick="sendAction('goodbye')">goodbye</button>
</div>
<h4>Send Text</h4>
<form onsubmit="handleSubmit(event)">
<input type="text" id="msg-input" placeholder="Type a message..." />
<button type="submit" id="send-btn" disabled>Send</button>
</form>
<script>
const API_BASE = "https://my-assistant.virbe.app/api/v1";
const API_KEY = "YOUR_API_KEY"; // Keep server-side in production!
const PROFILE_ID = "YOUR_PROFILE_ID";
let conversationId = null;
async function poll() {
try {
const res = await fetch(
`${API_BASE}/conversations-history?profileId=${PROFILE_ID}&sortBy=CreatedAtDesc&pageSizeAndNumber[size]=1&pageSizeAndNumber[number]=1`,
{ headers: { "x-virbe-api-key": API_KEY } }
);
const data = await res.json();
const latest = data?.results?.[0];
if (latest?.id) {
conversationId = latest.id;
setStatus("Connected ✅");
document.getElementById("conv-info").style.display = "block";
document.getElementById("conv-id").textContent = conversationId;
document.getElementById("send-btn").disabled = false;
} else {
setStatus("No active conversation");
}
} catch (e) {
setStatus("Error polling");
}
}
async function sendText(text) {
if (!conversationId) return;
await fetch(`${API_BASE}/conversations/${conversationId}/messages/add`, {
method: "POST",
headers: { "x-virbe-api-key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ senderId: "operator-panel", action: { text: { text } } }),
});
}
async function sendAction(name) {
if (!conversationId) return;
await fetch(`${API_BASE}/conversations/${conversationId}/messages/add`, {
method: "POST",
headers: { "x-virbe-api-key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ senderId: "operator-panel", action: { customAction: { name } } }),
});
}
function handleSubmit(e) {
e.preventDefault();
const input = document.getElementById("msg-input");
if (input.value.trim()) {
sendText(input.value.trim());
input.value = "";
}
}
function setStatus(msg) {
document.getElementById("status").textContent = msg;
}
poll();
setInterval(poll, 3000);
</script>
</body>
</html>These examples call the API directly from the browser for simplicity. In production, proxy all API calls through your own backend (e.g. a Netlify Function or Node.js server) to keep your API key secret.