API reference
REST endpoints and a drop-in browser widget. Two key types — backend (server) and browser (widget) — both versioned under /api/v1.
Overview
Two surfaces, one bot. Server keys live on a backend you control — Node, Python, a CRM, Zapier, n8n. They identify customers by phone, email, or any external user ID. Widget keys are browser-safe — CORS-locked to your domains, anonymous by default, dropped in via a single <script> tag.
| Server keyck_live_… | Widget keycwk_live_… | |
|---|---|---|
| Where it runs | Backend, scripts, Zapier, n8n | Browser, mobile WebView |
| Identifies users | Phone / email / external ID | Anonymous; identity opt-in |
| Origin lock | None | CORS-locked to allowedOrigins |
| Rate limit | 60 / min / key | 30 / min / (key, visitor) |
| Endpoint | POST /api/v1/messages | POST /api/v1/widget/messages |
Get an API key
- Sign in to https://app.clonvo.chat and pick a business.
- Open
API & Webhooksin the sidebar. - Click
Create API key. ChooseServerfor backend use, orWidgetfor browser embeds (you'll enter one or more allowed origins). - Copy the plaintext value immediately — only the hash is stored, so it's shown once.
Make your first call
The 30-second smoke test (server key required).
curl -X POST "https://app.clonvo.chat/api/v1/messages" \
-H "Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"body": "hi from curl",
"direction": "inbound",
"customer": { "phoneE164": "+15551234567", "displayName": "Curl Test" }
}'Refresh the dashboard inbox — the message appears. Open the per-key analytics page to see your IP, the user-agent, and a fresh visitor session.
HTML / vanilla JS
The widget is a single ~7 KB file. Paste this once into your page and you're live. Works on plain HTML, Astro, Hugo, Eleventy — anywhere a <script> tag works.
<!-- Place just before </body> -->
<script
src="https://app.clonvo.chat/widget.js"
data-key="cwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
data-title="Talk to support"
data-color="#1B3A6B"
data-position="bottom-right"
defer
></script>A floating button appears in the corner and opens a chat panel on click. The widget runs in a Shadow DOM so your site's CSS can't bleed in (and vice versa).
React / Next.js
Use the same widget.js via Next's <Script> component (renders once, deduped across pages):
// app/layout.tsx (Next.js App Router)
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Script
src="https://app.clonvo.chat/widget.js"
data-key="cwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
data-title="Chat with support"
strategy="lazyOnload"
/>
</body>
</html>
);
}Pages Router: drop the same <Script> into pages/_app.tsx.
Vue / Nuxt
// nuxt.config.ts (Nuxt 3)
export default defineNuxtConfig({
app: {
head: {
script: [
{
src: "https://app.clonvo.chat/widget.js",
"data-key": "cwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"data-title": "Chat with support",
defer: true,
},
],
},
},
});<!-- Vue 3 SPA: in App.vue or any layout -->
<script setup lang="ts">
import { onMounted } from "vue";
onMounted(() => {
const s = document.createElement("script");
s.src = "https://app.clonvo.chat/widget.js";
s.dataset.key = "cwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
s.defer = true;
document.body.appendChild(s);
});
</script>WordPress
No plugin needed. Two paths:
- Block theme (Site Editor): Appearance → Editor → Patterns → Footer → add a
Custom HTMLblock at the bottom. - Classic theme: Appearance → Theme File Editor →
footer.php→ paste just before</body>.
<script
src="https://app.clonvo.chat/widget.js"
data-key="cwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
data-title="Chat with us"
defer
></script>Prefer a plugin? WPCode or Insert Headers and Footers both let you paste this without touching theme files.
In-app (logged-in users)
When the widget runs inside your own product (a SaaS dashboard, a mobile WebView), you already know who the user is. Pass their identity to the widget so the inbox shows their name + email instead of an anonymous visitor ID.
<script>
// Set BEFORE the widget script loads.
window.ClonvoIdentify = function () {
return {
externalUserId: "u_42", // your internal user PK
externalEmail: "jane@acme.com", // optional but helps
externalName: "Jane Doe", // optional
};
};
</script>
<script
src="https://app.clonvo.chat/widget.js"
data-key="cwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
defer
></script>ClonvoIdentify on a public marketing page. Anyone could spoof another user. Set it only on routes behind your own auth.Customization
All options are data-* attributes on the script tag.
| Attribute | Default | Notes |
|---|---|---|
data-key | (required) | Your cwk_live_* widget key. |
data-base | script origin | Defaults to https://app.clonvo.chat. |
data-title | Chat with us | Header copy in the panel. |
data-color | #1B3A6B | Brand accent (hex). Used for button + bubbles. |
data-position | bottom-right | bottom-right or bottom-left. |
Programmatic control after the script loads:
window.Clonvo.open(); // expand the panel
window.Clonvo.close(); // collapse
window.Clonvo.sendProgrammatic("Hi!"); // send a message as the visitor
window.Clonvo.visitorId(); // current visitor ID (string)POST /api/v1/messages
Server-side ingest. Authenticates with a ck_live_* key via Authorization: Bearer. The same endpoint accepts phone-keyed customers (WhatsApp-style) and visitor-keyed ones (web / in-app embeds).
{
"direction": "inbound",
"body": "Hi, do you ship to Canada?",
"providerMessageId": "site-msg-abc123",
"customer": {
"phoneE164": "+15551234567",
"displayName": "Jane Doe"
},
"visitor": {
"visitorId": "v_browser_or_session_id",
"externalUserId": "u_42",
"externalEmail": "jane@example.com",
"externalName": "Jane Doe",
"path": "/pricing",
"referer": "https://google.com/"
}
}Send at least one of: customer.phoneE164, visitor.externalUserId, visitor.externalEmail, visitor.visitorId.
Code samples
curl -X POST "https://app.clonvo.chat/api/v1/messages" \
-H "Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"body": "Hi, do you ship to Canada?",
"direction": "inbound",
"customer": { "phoneE164": "+15551234567" }
}'Response shapes
200 OK
{ "ok": true, "id": "msg_…", "customerId": "cus_…", "sessionId": "vs_…", "visitorId": "…" }
200 OK (idempotent retry)
{ "ok": true, "duplicate": true, "id": "msg_…" }
400 Bad Request
{ "error": "Validation failed", "details": { … } }
401 Unauthorized
{ "error": "Invalid or revoked API key" }
403 Forbidden
{ "error": "This endpoint requires a server key" }
429 Too Many Requests
Retry-After: 17
{ "error": "Rate limit exceeded", "limit": 60, "windowSeconds": 60 }What we capture per visitor
Every call writes to two tables: visitor_session (one row per logical browser, 30-day rolling window) and widget_event (append-only event log). Everything surfaces on the per-key analytics page in the dashboard.
| Field | Source | Notes |
|---|---|---|
visitorId | widget / your code | Stable per-browser, sticky in localStorage. |
ipAddress | X-Forwarded-For at the ALB | Stored verbatim. PII-redaction toggle coming. |
userAgent | UA header | Truncated to 500 chars. |
referer / landingPath | document.referrer + caller | Useful for funnel analytics. |
countryCode / city | Geo-IP enricher (worker) | ISO-3166-1 alpha-2; populated async. |
externalUserId / Email / Name | caller (trusted) | Only when host site authenticated the visitor. |
messageCount | server | Bumped on every successful message. |
Attaching identity
Three ways to tell us who the visitor really is:
- Server side: include
visitor.externalUserId / Email / Namein thePOST /messagesbody. - Widget (in-app): set the
window.ClonvoIdentifyhook before the widget script loads — see In-app users. - Phone-keyed: if the customer provided a phone, pass
customer.phoneE164. The same record is shared with any future WhatsApp conversation from the same number.
Security
- Keys are stored as
sha256(plaintext). The plaintext never touches the database after creation. - Lookup uses a unique index on the hash + a constant-time equality check, so the auth path is timing-attack-resistant.
- Widget keys reject every CORS request that doesn't match an entry in
allowedOrigins. Wildcards and HTTP origins (exceptlocalhost) are rejected at creation. - Revoking a key is immediate — every subsequent request returns 401, including in-flight retries.
- Browser callers can't escalate a widget key into a server key — the prefixes (
cwk_live_vsck_live_) bind to different routes, and the auth layer rejects mismatches.
Rate limits
| Endpoint | Bucket | Default | Override |
|---|---|---|---|
POST /api/v1/messages | per server key | 60 / min | INGEST_RATE_PER_MIN |
POST /api/v1/widget/messages | per (widget key, visitor) | 30 / min | WIDGET_RATE_PER_MIN |
POST /api/v1/ingest (legacy) | per organization | 60 / min | INGEST_RATE_PER_MIN |
Endpoints return X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. 429 responses include a Retry-After seconds-int.
Errors
| Status | Meaning | Recovery |
|---|---|---|
400 | Body validation failed | Read details.fieldErrors and resend. |
401 | Missing / invalid / revoked key | Issue a new key in the dashboard. |
403 | Wrong key kind, or origin not allowed | Use the right key type, or add the origin to the widget key. |
404 | Endpoint typo | Endpoints are versioned — always /api/v1/*. |
429 | Rate limited | Honor Retry-After. Don't tight-loop. |
500 | Our bug | Email developer@clonvo.chat with the org slug + UTC timestamp; logs are kept 14 days. |
Legacy migration
The old POST /api/v1/ingest with X-ChatHub-Org + X-ChatHub-Secret still works. The same route also accepts an Authorization: Bearer ck_live_* key — both auth modes can coexist while you migrate. Once every caller is on the new key, rotate the legacy ingest secret from the dashboard to fully retire it.