Skip to content
Clonvo

API reference

REST endpoints and a drop-in browser widget. Two key types — backend (server) and browser (widget) — both versioned under /api/v1.

Get an API keyQuickstart

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 runsBackend, scripts, Zapier, n8nBrowser, mobile WebView
Identifies usersPhone / email / external IDAnonymous; identity opt-in
Origin lockNoneCORS-locked to allowedOrigins
Rate limit60 / min / key30 / min / (key, visitor)
EndpointPOST /api/v1/messagesPOST /api/v1/widget/messages

Get an API key

  1. Sign in to https://app.clonvo.chat and pick a business.
  2. Open API & Webhooks in the sidebar.
  3. Click Create API key. Choose Server for backend use, or Widget for browser embeds (you'll enter one or more allowed origins).
  4. Copy the plaintext value immediately — only the hash is stored, so it's shown once.
Treat keys like passwords. Lost keys can't be recovered — only revoked + replaced.

Make your first call

The 30-second smoke test (server key required).

bash
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.

html
<!-- 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):

tsx
// 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

ts
// 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
<!-- 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 HTML block at the bottom.
  • Classic theme: Appearance → Theme File Editor → footer.php → paste just before </body>.
html
<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.

html
<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>
Identity is trusted blindly — the host site has already authenticated the user. Don't expose 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.

AttributeDefaultNotes
data-key(required)Your cwk_live_* widget key.
data-basescript originDefaults to https://app.clonvo.chat.
data-titleChat with usHeader copy in the panel.
data-color#1B3A6BBrand accent (hex). Used for button + bubbles.
data-positionbottom-rightbottom-right or bottom-left.

Programmatic control after the script loads:

js
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).

json
{
  "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

bash
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

http
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.

FieldSourceNotes
visitorIdwidget / your codeStable per-browser, sticky in localStorage.
ipAddressX-Forwarded-For at the ALBStored verbatim. PII-redaction toggle coming.
userAgentUA headerTruncated to 500 chars.
referer / landingPathdocument.referrer + callerUseful for funnel analytics.
countryCode / cityGeo-IP enricher (worker)ISO-3166-1 alpha-2; populated async.
externalUserId / Email / Namecaller (trusted)Only when host site authenticated the visitor.
messageCountserverBumped on every successful message.

Attaching identity

Three ways to tell us who the visitor really is:

  1. Server side: include visitor.externalUserId / Email / Name in the POST /messages body.
  2. Widget (in-app): set the window.ClonvoIdentify hook before the widget script loads — see In-app users.
  3. 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 (except localhost) 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_ vs ck_live_) bind to different routes, and the auth layer rejects mismatches.

Rate limits

EndpointBucketDefaultOverride
POST /api/v1/messagesper server key60 / minINGEST_RATE_PER_MIN
POST /api/v1/widget/messagesper (widget key, visitor)30 / minWIDGET_RATE_PER_MIN
POST /api/v1/ingest (legacy)per organization60 / minINGEST_RATE_PER_MIN

Endpoints return X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. 429 responses include a Retry-After seconds-int.

Errors

StatusMeaningRecovery
400Body validation failedRead details.fieldErrors and resend.
401Missing / invalid / revoked keyIssue a new key in the dashboard.
403Wrong key kind, or origin not allowedUse the right key type, or add the origin to the widget key.
404Endpoint typoEndpoints are versioned — always /api/v1/*.
429Rate limitedHonor Retry-After. Don't tight-loop.
500Our bugEmail 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.