Integration Documentation

Everything you need to embed OpinioM chat into your application — from signing user identity tokens in your backend to mounting the widget in your frontend and receiving webhook events.

Before you start: You should have already signed up, completed the onboarding wizard, and saved your project's public_key and secret_key. The public key is safe to expose in your frontend. The secret key must stay on your backend — never commit it to source control or ship it in client-side code.

Quick Start

This guide walks you through the full integration end-to-end. By the end, your users will have a working chat widget on every authenticated page of your app.

The integration has three pieces:

  1. Your backend signs a short-lived JWT for each authenticated user using your OpinioM secret key
  2. Your frontend passes that JWT to the OpinioM SDK, which mounts the chat widget and opens a WebSocket
  3. Optional: Your backend receives webhook events from OpinioM when chat activity happens, so you can sync data into your own systems

Step 1 — Sign a user JWT in your backend

Whenever a user logs into your application, generate a JWT containing their identity using HS256 with your OpinioM secret key as the HMAC secret.

// Node.js — using 'jose'
import * as jose from "jose";

const secret = new TextEncoder().encode(process.env.PROJECT_M_SECRET_KEY);

export async function signChatToken(user) {
  return await new jose.SignJWT({
    name: user.fullName,
    email: user.email,
    avatar_url: user.avatarUrl,
  })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(String(user.id))    // your internal user ID
    .setIssuedAt()
    .setExpirationTime("2h")
    .sign(secret);
}

Expose this token via an authenticated endpoint your frontend can call (for example,GET /api/chat-token).

Step 2 — Mount the widget in your frontend

Choose your framework:

Vanilla JavaScript:

<!-- Add the SDK script before the closing </body> tag -->
<script src="https://cdn.opiniom.io/sdk/v1.js"></script>

<script>
  // Fetch the JWT from your backend, then init
  fetch("/api/chat-token")
    .then((r) => r.json())
    .then((data) => {
      OpinioM.init({
        publicKey: "ok_live_your_public_key",
        token: data.token,
      });
    });
</script>

React:

import { useEffect, useState } from "react";
import { OpinioMChat, ChatWidget } from "@opiniom/react";

export function App() {
  const [token, setToken] = useState(null);

  useEffect(() => {
    fetch("/api/chat-token", { credentials: "include" })
      .then((r) => r.json())
      .then((data) => setToken(data.token));
  }, []);

  if (!token) return null;

  return (
    <OpinioMChat publicKey="ok_live_your_public_key" token={token}>
      <ChatWidget />
    </OpinioMChat>
  );
}

Step 3 — (Optional) Set up webhooks

If you want chat events delivered to your backend (e.g. to log messages into your own database or send notifications), create a webhook in your project dashboard pointing to an HTTPS endpoint on your server. See the Webhooks section below.

That's it. Your users will see a chat launcher button on every page where the SDK is mounted. Clicking it opens the widget, and they can start chatting immediately.

Core Concepts

Understanding these concepts will help you model your chat features correctly.

External user

Your application's users become "external users" inside OpinioM when you sign them in via the identity bridge. The mapping is keyed on the sub claim in your JWT — typically your internal user ID. OpinioM stores only the mapped ID, display name, email, and avatar URL. Passwords and other sensitive credentials never leave your system.

Conversation types

TypeUse case
direct1-on-1 messaging between two users
groupMulti-user chat where members can send messages
channelPublic, broadcast-style conversations
supportCustomer-to-agent help desk conversations

Message lifecycle

  • Messages are delivered to all members of the conversation in real-time over WebSocket
  • Senders can edit their own messages — edits are broadcast and the message is flagged as edited
  • Senders can delete their own messages — deletions are soft (the row is retained, content cleared, and an is_deleted flag set)
  • Messages can have emoji reactions and read receipts
  • File attachments are uploaded separately and referenced by URL

Data ownership

This is one of the most important things to understand about OpinioM. We're explicit about what data we store versus what stays in your system:

DataWhere it livesOwner
Users, passwords, profilesYour databaseYou
Chat messages, threads, reactionsOpinioM databaseOpinioM
File attachmentsOpinioM CDN (S3-backed)OpinioM
Presence / typing statusOpinioM Redis (ephemeral)OpinioM
Message events (optional sync)Your DB via webhookYou

Identity Bridge

The identity bridge is how your existing user authentication system connects to OpinioM without us ever touching your user database.

How it works

  1. A user logs into your application (using whatever auth system you already have)
  2. Your backend signs a JWT containing the user's identity, using your OpinioM secret key as the HMAC secret
  3. Your frontend passes this token to the OpinioM SDK
  4. The SDK sends the token to OpinioM, which verifies the signature with the same secret key
  5. OpinioM creates or updates an external user record (keyed on the sub claim) and issues a short-lived internal session token
  6. The SDK uses this session token to open a WebSocket connection

Because the JWT is signed with a secret that only you and OpinioM know, no one else can impersonate your users. And because OpinioM only stores the data you put in the JWT claims, your users' passwords and personal information stay in your system.

JWT claims

ClaimRequiredDescription
subYesYour internal user ID. Used as the unique identifier inside OpinioM.
nameNoDisplay name shown in the chat UI
emailNoEmail address (optional, useful for support contexts)
avatar_urlNoProfile picture URL shown in the chat UI
iatYesIssued-at timestamp (Unix seconds)
expYesExpiry timestamp. Recommended: 2 hours. Maximum: 24 hours.

Signing the JWT — code samples

Node.js (jose):

import * as jose from "jose";

const secret = new TextEncoder().encode(process.env.PROJECT_M_SECRET_KEY);

const token = await new jose.SignJWT({
  name: "Alice Johnson",
  email: "alice@yourapp.com",
  avatar_url: "https://yourapp.com/avatars/alice.png",
})
  .setProtectedHeader({ alg: "HS256" })
  .setSubject("user_42")
  .setIssuedAt()
  .setExpirationTime("2h")
  .sign(secret);

Node.js (jsonwebtoken):

import jwt from "jsonwebtoken";

const token = jwt.sign(
  {
    sub: "user_42",
    name: "Alice Johnson",
    email: "alice@yourapp.com",
    avatar_url: "https://yourapp.com/avatars/alice.png",
  },
  process.env.PROJECT_M_SECRET_KEY,
  { algorithm: "HS256", expiresIn: "2h" }
);

Python (PyJWT):

import jwt
import time
import os

token = jwt.encode(
    {
        "sub": "user_42",
        "name": "Alice Johnson",
        "email": "alice@yourapp.com",
        "avatar_url": "https://yourapp.com/avatars/alice.png",
        "iat": int(time.time()),
        "exp": int(time.time()) + 7200,
    },
    os.environ["PROJECT_M_SECRET_KEY"],
    algorithm="HS256",
)

Ruby (jwt gem):

require "jwt"

payload = {
  sub: "user_42",
  name: "Alice Johnson",
  email: "alice@yourapp.com",
  avatar_url: "https://yourapp.com/avatars/alice.png",
  iat: Time.now.to_i,
  exp: Time.now.to_i + 7200
}

token = JWT.encode(payload, ENV["PROJECT_M_SECRET_KEY"], "HS256")

Go (golang-jwt):

import (
    "os"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "sub":        "user_42",
    "name":       "Alice Johnson",
    "email":      "alice@yourapp.com",
    "avatar_url": "https://yourapp.com/avatars/alice.png",
    "iat":        time.Now().Unix(),
    "exp":        time.Now().Add(2 * time.Hour).Unix(),
})

signed, err := token.SignedString([]byte(os.Getenv("PROJECT_M_SECRET_KEY")))

PHP (firebase/php-jwt):

<?php
use Firebase\JWT\JWT;

$payload = [
    "sub"        => "user_42",
    "name"       => "Alice Johnson",
    "email"      => "alice@yourapp.com",
    "avatar_url" => "https://yourapp.com/avatars/alice.png",
    "iat"        => time(),
    "exp"        => time() + 7200,
];

$token = JWT::encode($payload, $_ENV["PROJECT_M_SECRET_KEY"], "HS256");
Security: Never sign JWTs in your frontend. Anyone with your secret key can impersonate any of your users. Always sign on the server-side and ship the resulting token to the client.

SDK Reference

The SDK is a small JavaScript library that handles the identity bridge handshake, opens the WebSocket connection, mounts the chat UI, and exposes a programmatic API for advanced use cases.

Installation

Via CDN (vanilla JS):

<script src="https://cdn.opiniom.io/sdk/v1.js"></script>

Via npm (React / TypeScript):

npm install @projectm/sdk @opiniom/react

Initialization options

OptionTypeDescription
publicKeystringRequired. Your project's public key (ok_live_...)
tokenstringRequired. JWT signed by your backend
containerstring | HTMLElementCSS selector or DOM element to mount the widget into. Defaults to floating launcher in the corner of the page.
onReadyfunctionCallback fired once the widget connects and is ready
onErrorfunctionCallback fired on connection or auth errors
onMessagefunctionCallback fired when a new message is received

Vanilla JavaScript example

OpinioM.init({
  publicKey: "ok_live_your_public_key",
  token: jwtFromYourBackend,
  container: "#chat-container",
  onReady: () => console.log("Chat ready"),
  onError: (err) => console.error("Chat error:", err),
  onMessage: (msg) => console.log("New message:", msg),
});

// Programmatic methods
OpinioM.open();              // Open the chat panel
OpinioM.close();             // Close the chat panel
OpinioM.toggle();            // Toggle open/close
OpinioM.openConversation("conv_abc123");  // Jump to a specific conversation
OpinioM.sendMessage("conv_abc123", "Hello!");
OpinioM.destroy();           // Tear down the widget

React example

import { useEffect, useState } from "react";
import { OpinioMChat, ChatWidget, useChatClient } from "@opiniom/react";

function App() {
  const [token, setToken] = useState(null);

  useEffect(() => {
    fetch("/api/chat-token", { credentials: "include" })
      .then((r) => r.json())
      .then((data) => setToken(data.token));
  }, []);

  if (!token) return <div>Loading...</div>;

  return (
    <OpinioMChat publicKey="ok_live_your_public_key" token={token}>
      <YourApp />
      <ChatWidget />
    </OpinioMChat>
  );
}

// Use the chat client from any child component
function ChatButton() {
  const chat = useChatClient();
  return <button onClick={() => chat.open()}>Open chat</button>;
}

Token refresh

Session tokens issued by OpinioM expire after 2 hours. The SDK automatically requests a new token when needed by calling your token-fetch function. If you're using the React SDK, pass a getToken function instead of a static token to enable automatic refresh:

<OpinioMChat
  publicKey="ok_live_your_public_key"
  getToken={async () => {
    const res = await fetch("/api/chat-token", { credentials: "include" });
    const data = await res.json();
    return data.token;
  }}
>
  <ChatWidget />
</OpinioMChat>

Headless Mode — Build Your Own Chat UI

The pre-built widget is great for getting started quickly, but many apps need a custom chat experience that matches their own design system. Headless mode gives you React hooks for every piece of chat state and every user action — you build the UI, we handle the real-time infrastructure.

Widget vs Headless: Use the widget when you want chat working in minutes. Use headless mode when your design team has a custom layout, or chat is deeply integrated into your UX (support inbox, marketplace messaging, collaborative workspace). Both use the same provider — you can even mix them.

Setup

Same provider as the widget — just skip <ChatWidget /> and use hooks instead:

import { OpinioMProvider } from "@opiniom/react";

function App() {
  return (
    <OpinioMProvider
      projectKey="ok_live_YOUR_KEY"
      getToken={async () => {
        const res = await fetch("/api/chat-token", { credentials: "include" });
        return (await res.json()).jwt;
      }}
    >
      <YourCustomChatUI />
    </OpinioMProvider>
  );
}

Read hooks — subscribe to live state

These hooks return reactive data. Your component re-renders only when the specific value it reads changes — no full-tree re-render.

HookReturnsUse case
useConversations()Conversation[]Render a conversation list / sidebar
useConversation(id)Conversation | undefinedShow details for the active conversation
useMessages(conversationId){ messages, loadMore, hasMore, isLoading }Render messages with built-in pagination
useTyping(conversationId)string[] (user IDs)Show "someone is typing..." indicator
usePresence(userId){ status, last_seen_at }Online / away / offline dots
useReactions(messageId){ "👍": ["user_1", ...] }Render emoji reaction chips
useUnreadCount()numberBadge on your nav or tab title
useUnreadByConversation()Record<string, number>Per-conversation unread badges
useConnectionStatus()ClientStateShow connection banners
useCurrentUser()User | nullDisplay current user profile

Action hooks — trigger operations with loading/error

Action hooks wrap client methods so you don't manage async state yourself. Each returns the action function plus isLoading, error, and reset(). All are safe to call before the client is ready (no-op returning undefined).

HookActionReturns
useSendMessage(convId)Send a text message (optimistic){ send, isLoading, error }
useEditMessage(convId)Edit a message's content{ edit, isLoading, error }
useDeleteMessage(convId)Delete a message{ remove, isLoading, error }
useCreateConversation()Create a conversation (developer use){ create, isLoading, error }
useJoinConversation()Join an existing conversation{ join, isLoading, error }
useLeaveConversation()Leave a conversation{ leave, isLoading, error }
useUploadFile()Upload a file (direct to storage){ upload, isLoading, error }
useAddReaction()Add an emoji reaction{ addReaction, isLoading, error }
useRemoveReaction()Remove an emoji reaction{ removeReaction, isLoading, error }
useSetTyping(convId)Auto-debounced typing indicator{ onKeystroke, stopTyping }
useMarkRead(convId)Mark a message as read{ markRead }
useSendMessageWithAttachments(convId)Upload files + send in one call{ send, isUploading, isSending, error }

Example: custom two-pane chat

This example builds a sidebar + thread layout using only headless hooks. No pre-built widget, fully custom UI.

import { useState, useEffect, useRef } from "react";
import {
  OpinioMProvider,
  useConnectionStatus,
  useConversations,
  useMessages,
  useTyping,
  useUnreadByConversation,
  useSendMessage,
  useSetTyping,
  useMarkRead,
  useCurrentUser,
} from "@opiniom/react";

// ─── App shell ─────────────────────────────────────────────

export default function App() {
  return (
    <OpinioMProvider
      projectKey="ok_live_YOUR_KEY"
      getToken={async () => {
        const res = await fetch("/api/chat-token", { credentials: "include" });
        return (await res.json()).jwt;
      }}
    >
      <ChatApp />
    </OpinioMProvider>
  );
}

function ChatApp() {
  const status = useConnectionStatus();
  const [activeId, setActiveId] = useState(null);

  if (status === "connecting" || status === "authenticating") {
    return <div>Connecting to chat...</div>;
  }

  return (
    <div style={{ display: "flex", height: "100vh" }}>
      <Sidebar activeId={activeId} onSelect={setActiveId} />
      {activeId ? (
        <Thread conversationId={activeId} />
      ) : (
        <div style={{ flex: 1, display: "grid", placeItems: "center" }}>
          Select a conversation
        </div>
      )}
    </div>
  );
}

// ─── Sidebar ───────────────────────────────────────────────

// Conversations are created by your app (backend or your own UI logic),
// not by end-users in the chat interface. The sidebar just renders
// whatever conversations already exist for the current user.

function Sidebar({ activeId, onSelect }) {
  const conversations = useConversations();
  const unread = useUnreadByConversation();

  return (
    <aside style={{ width: 280, borderRight: "1px solid #e5e5e5", overflow: "auto" }}>
      {conversations.map((conv) => (
        <div key={conv.id} onClick={() => onSelect(conv.id)}>
          <span>{conv.title || "Untitled"}</span>
          {(unread[conv.id] ?? 0) > 0 && <span className="badge">{unread[conv.id]}</span>}
        </div>
      ))}
    </aside>
  );
}

// ─── Thread ────────────────────────────────────────────────

function Thread({ conversationId }) {
  const { messages, loadMore, hasMore, isLoading } = useMessages(conversationId);
  const typingUsers = useTyping(conversationId);
  const { markRead } = useMarkRead(conversationId);
  const user = useCurrentUser();

  // Mark newest message as read
  useEffect(() => {
    const last = messages[messages.length - 1];
    if (last) markRead(last.id);
  }, [messages, markRead]);

  return (
    <main style={{ flex: 1, display: "flex", flexDirection: "column" }}>
      <div style={{ flex: 1, overflow: "auto", padding: 16 }}>
        {hasMore && <button onClick={loadMore}>{isLoading ? "Loading..." : "Load more"}</button>}
        {messages.map((msg) => (
          <div
            key={msg.id}
            style={{
              textAlign: msg.sender_id === user?.id ? "right" : "left",
              opacity: msg.status === "pending" ? 0.6 : 1,
              marginBottom: 8,
            }}
          >
            <strong>{msg.sender?.name}: </strong>
            {msg.is_deleted ? <em>deleted</em> : msg.content}
            {msg.status === "failed" && <span style={{ color: "red" }}> (failed)</span>}
          </div>
        ))}
        {typingUsers.length > 0 && <div><em>Someone is typing...</em></div>}
      </div>
      <ComposeBar conversationId={conversationId} />
    </main>
  );
}

// ─── Compose bar ───────────────────────────────────────────

function ComposeBar({ conversationId }) {
  const [text, setText] = useState("");
  const { send, isLoading, error } = useSendMessage(conversationId);
  const { onKeystroke, stopTyping } = useSetTyping(conversationId);

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!text.trim() || isLoading) return;
    await send({ content: text });
    setText("");
    stopTyping();
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: "flex", gap: 8, padding: 16 }}>
      <input
        value={text}
        onChange={(e) => { setText(e.target.value); onKeystroke(); }}
        onBlur={stopTyping}
        placeholder="Type a message..."
        style={{ flex: 1 }}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); }
        }}
      />
      <button type="submit" disabled={isLoading || !text.trim()}>
        {isLoading ? "..." : "Send"}
      </button>
      {error && <span style={{ color: "red" }}>{error.message}</span>}
    </form>
  );
}

Sending messages with file attachments

Use useSendMessageWithAttachments to upload files and send a message in one call, or use useUploadFile + useSendMessage for more control:

import { useSendMessageWithAttachments } from "@opiniom/react";

function ComposerWithFiles({ conversationId }) {
  const [text, setText] = useState("");
  const [files, setFiles] = useState([]);
  const { send, isUploading, isSending, isLoading, error } =
    useSendMessageWithAttachments(conversationId);

  const handleSubmit = async (e) => {
    e.preventDefault();
    await send(text, files.length > 0 ? files : undefined);
    setText("");
    setFiles([]);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files))} />
      {files.length > 0 && (
        <div>{files.map((f, i) => <span key={i}>{f.name} </span>)}</div>
      )}
      <button disabled={isLoading}>
        {isUploading ? "Uploading..." : isSending ? "Sending..." : "Send"}
      </button>
      {error && <p style={{ color: "red" }}>{error.message}</p>}
    </form>
  );
}

Emoji reactions

import { useReactions, useAddReaction, useRemoveReaction, useCurrentUser } from "@opiniom/react";

function ReactionBar({ messageId }) {
  const reactions = useReactions(messageId);
  const user = useCurrentUser();
  const { addReaction } = useAddReaction();
  const { removeReaction } = useRemoveReaction();

  const toggle = (emoji) => {
    const users = reactions[emoji] ?? [];
    if (user && users.includes(user.id)) {
      removeReaction(messageId, emoji);
    } else {
      addReaction(messageId, emoji);
    }
  };

  return (
    <div style={{ display: "flex", gap: 4 }}>
      {Object.entries(reactions).map(([emoji, userIds]) => (
        <button
          key={emoji}
          onClick={() => toggle(emoji)}
          style={{ fontWeight: userIds.includes(user?.id) ? "bold" : "normal" }}
        >
          {emoji} {userIds.length}
        </button>
      ))}
      <button onClick={() => addReaction(messageId, "👍")}>+</button>
    </div>
  );
}

Listening to real-time events

The hooks handle most cases, but you can subscribe to raw SDK events for custom logic like desktop notifications or analytics:

import { useChatClient } from "@opiniom/react";
import { useEffect } from "react";

function NotificationListener() {
  const client = useChatClient();

  useEffect(() => {
    if (!client) return;
    const unsub = client.on("message:new", (message) => {
      if (message.sender_id !== client.user?.id) {
        new Notification(message.sender?.name + ": " + message.content);
      }
    });
    return unsub;
  }, [client]);

  return null;
}

// Available events:
// ready, message:new, message:updated, message:deleted,
// reaction:added, reaction:removed, typing:update,
// presence:changed, unread:changed, connection:changed,
// auth:required, error

Non-React frameworks

For Vue, Svelte, or vanilla JS, use @opiniom/core directly:

import { createClient } from "@opiniom/core";

const client = await createClient({
  projectKey: "ok_live_YOUR_KEY",
  jwt: yourJwt,
});

// Subscribe to events
client.on("message:new", (msg) => { /* update your UI */ });
client.on("typing:update", (data) => { /* show indicator */ });
client.on("presence:changed", (state) => { /* update status */ });

// Perform actions
await client.sendMessage(conversationId, { content: "Hello!" });
await client.createConversation({ type: "direct", member_ids: ["user_123"] });
client.setTyping(conversationId, true);
client.markRead(conversationId, messageId);

// Get current state
const conversations = client.listConversations();
const messages = client.getMessages(conversationId);

What the SDK handles for you

Even in headless mode, you get everything the backend provides — no extra setup:

  • WebSocket connection with automatic reconnection and exponential backoff
  • Optimistic updates — sent messages appear instantly, merge on server confirmation
  • Typing indicators — debounced, cleaned up on unmount
  • Presence tracking — online / away / offline
  • Read receipts — unread counts update in real-time
  • File uploads — direct-to-storage via presigned URLs
  • Message deduplication — handles retries and race conditions
  • Token refresh — automatic when using getToken
  • IndexedDB caching — optional, for instant load on revisit
  • Internationalization — 5 built-in locales (en, fr, de, ja, es)

WebSocket Protocol

If you're building a custom client (mobile app, embedded device, or just bypassing the SDK), here's how to talk to OpinioM directly over Socket.io. Most integrations don't need this — the SDK handles everything.

Step 1 — Get a session token

First, exchange your customer-signed JWT for an internal session token by calling the SDK init endpoint:

curl -X POST https://api.opiniom.io/api/v1/sdk/init \
  -H "X-Public-Key: ok_live_your_public_key" \
  -H "Authorization: Bearer <jwt-signed-by-your-backend>"

# Response:
# {
#   "success": true,
#   "data": {
#     "session_token": "eyJ...",
#     "user_id": "ext_user_42",
#     "widget_config": { ... },
#     "project_name": "My Chat App"
#   }
# }

Step 2 — Open the WebSocket

import { io } from "socket.io-client";

const socket = io("https://api.opiniom.io", {
  path: "/ws",
  auth: { token: sessionToken },  // From step 1
});

socket.on("connect", () => {
  console.log("Connected:", socket.id);
});

socket.on("connect_error", (err) => {
  console.error("Auth failed:", err.message);
});

Events you can emit

EventPayload
conversation:create{ type, title?, member_ids? }
conversation:join{ conversation_id }
conversation:leave{ conversation_id }
message:send{ conversation_id, content }
message:edit{ message_id, content }
message:delete{ message_id }
reaction:add{ message_id, emoji }
reaction:remove{ message_id, emoji }
typing:start{ conversation_id }
typing:stop{ conversation_id }
read:mark{ conversation_id, message_id }

Events you can listen to

EventWhen it fires
message:newA new message was sent to a conversation you're in
message:updatedA message was edited
message:deletedA message was deleted
reaction:addedSomeone added a reaction
reaction:removedSomeone removed a reaction
member:joinedA user joined a conversation
member:leftA user left a conversation
typing:updateTyping indicator changed
presence:changedA user came online or went offline
read:updatedA read receipt was updated

Example: send a message

// Send a message
socket.emit("message:send", {
  conversation_id: "conv_abc123",
  content: "Hello, world!",
}, (response) => {
  if (response.error) {
    console.error("Send failed:", response.error);
  } else {
    console.log("Message sent:", response.data);
  }
});

// Listen for incoming messages
socket.on("message:new", (msg) => {
  console.log(`${msg.sender.name}: ${msg.content}`);
});

Presence and typing

Send a heartbeat event every 30 seconds to keep your presence "online". The server marks you offline after 60 seconds without a heartbeat. Typing indicators expire automatically after 5 seconds — emit typing:stop when the user stops typing or send a message.

Webhooks

Webhooks let your backend receive real-time notifications when chat events happen. Use them to sync chat data into your own database, trigger notifications, or integrate with other systems like CRMs and analytics tools.

Setting up a webhook

In your project dashboard, go to Webhooks and click Create webhook. Provide an HTTPS endpoint URL on your server and select which events to subscribe to. OpinioM will return a signing secret shown only once — store it in your environment variables alongside your other secrets.

Available events

EventTrigger
message.createdA new message was sent
message.updatedA message was edited
message.deletedA message was deleted
reaction.addedAn emoji reaction was added
reaction.removedA reaction was removed
conversation.createdA new conversation was started
conversation.archivedA conversation was archived
member.joinedA user joined a conversation
member.leftA user left a conversation
user.presence.changedA user came online or went offline

Payload format

Every webhook delivery sends a POST request with this envelope:

{
  "id": "evt_01HNZF7K2P3B4Q5R6S7T8U9V",
  "type": "message.created",
  "created_at": "2026-04-06T12:00:00Z",
  "project_id": "proj_abc123",
  "data": {
    "message_id": "msg_xyz789",
    "conversation_id": "conv_abc123",
    "sender_id": "ext_user_42",
    "content": "Hello!",
    "created_at": "2026-04-06T12:00:00Z"
  }
}

The data object varies by event type. Other examples:

// reaction.added
{
  "data": {
    "message_id": "msg_xyz789",
    "user_id": "ext_user_42",
    "emoji": "👍",
    "reacted_at": "2026-04-06T12:00:05Z"
  }
}

// member.joined
{
  "data": {
    "conversation_id": "conv_abc123",
    "user_id": "ext_user_42",
    "joined_at": "2026-04-06T11:59:00Z"
  }
}

// user.presence.changed
{
  "data": {
    "user_id": "ext_user_42",
    "status": "online",
    "last_seen_at": "2026-04-06T12:00:00Z"
  }
}

Verifying the signature

Every webhook request includes an X-Signature header containing an HMAC-SHA256 signature of the raw request body, computed using your webhook's signing secret. Always verify this signature before processing the event — it's the only way to be sure the request actually came from OpinioM.

Node.js (Express):

import express from "express";
import crypto from "node:crypto";

const app = express();

// IMPORTANT: capture the raw body, not the parsed JSON
app.use("/webhooks/projectm", express.raw({ type: "application/json" }));

app.post("/webhooks/projectm", (req, res) => {
  const signature = req.headers["x-signature"];
  const body = req.body;  // Raw Buffer

  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.PROJECT_M_WEBHOOK_SECRET)
    .update(body)
    .digest("hex");

  // Use timing-safe comparison to prevent timing attacks
  const valid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );

  if (!valid) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(body.toString());
  console.log("Received:", event.type, event.id);

  // Acknowledge immediately, process async
  res.status(200).send("OK");

  // Process the event in the background
  handleEvent(event).catch(console.error);
});

Python (Flask):

import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PROJECT_M_WEBHOOK_SECRET"].encode()

@app.route("/webhooks/projectm", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Signature", "")
    body = request.get_data()  # raw bytes

    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = request.get_json()
    print(f"Received: {event['type']} {event['id']}")

    # Process async / enqueue a job, then return 200 fast
    return "OK", 200

Ruby (Rails):

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def projectm
    signature = request.headers["X-Signature"]
    body = request.raw_post

    expected = "sha256=" + OpenSSL::HMAC.hexdigest(
      "SHA256",
      ENV["PROJECT_M_WEBHOOK_SECRET"],
      body
    )

    unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
      head :unauthorized and return
    end

    event = JSON.parse(body)
    Rails.logger.info("Received: #{event['type']} #{event['id']}")

    # Enqueue async processing
    head :ok
  end
end

Retry behavior

  • Each delivery has a 10-second timeout
  • Failed deliveries (timeout or non-2xx response) are retried 3 times with exponential backoff (30s, 120s, 480s)
  • All delivery attempts are logged and visible in your dashboard for 30 days
  • You can manually retry individual failed deliveries from the dashboard

Best practices

  • Respond fast. Return 200 as quickly as possible. If you need to do heavy processing, enqueue a job and process asynchronously.
  • Be idempotent. The same event may be delivered multiple times due to retries or network issues. Use the id field to deduplicate.
  • Verify signatures. Always verify the X-Signature header to prevent spoofed requests.
  • Use timing-safe comparison. Don't compare signatures with === — use a constant-time comparison function to prevent timing attacks.
  • Keep secrets in env vars. Never commit your signing secret to source control.
  • Use HTTPS. OpinioM only allows HTTPS endpoint URLs.

Testing webhooks

Use the Send test event button in the dashboard to fire a synthetic payload at your endpoint. This helps you validate signature verification, response format, and connectivity before relying on real chat events. Test deliveries are not recorded in the delivery log.

Local development

To test webhooks against a local development server, use a tunneling tool like ngrok to expose localhost:3000 over HTTPS, then point your webhook to the ngrok URL.

# Start your local server
npm run dev

# In another terminal, expose it via ngrok
ngrok http 3000

# Use the https://...ngrok-free.app URL when creating the webhook

Widget Customization

The chat widget is fully themeable to match your brand. Configure it from the Customize tab in your project dashboard — changes apply instantly to every user the next time they load the widget.

What you can customize

SectionOptions
ColorsPrimary, message bubble background, chat panel background, and text colors. All accept 6-character hex values (e.g. #6366F1).
TypographyChoose from 12 Google Fonts (Inter, Roboto, Open Sans, Lato, Montserrat, Poppins, Nunito, Raleway, Source Sans Pro, PT Sans, Merriweather, Playfair Display) and a font size scale (small / medium / large).
LayoutPosition the launcher in bottom-right, bottom-left, or render inline within a container element. Choose launcher size (small / medium / large).
ContentHeader title (max 40 chars), input placeholder text (max 80 chars), and the powered-by badge.
Launcher iconUpload a custom PNG (auto-resized to fit 64×64) or SVG (max 100KB).

Inline embedding

By default, the widget appears as a floating launcher in the corner of the page. To embed it inline within your existing page layout, set the Layout → Position to inline and pass a container selector to the SDK:

<div id="my-chat-container" style="width: 100%; height: 600px;"></div>

<script>
  OpinioM.init({
    publicKey: "ok_live_your_public_key",
    token: jwtFromYourBackend,
    container: "#my-chat-container",
  });
</script>
Note: The "Powered by OpinioM" badge is required on the Starter plan. Upgrade to Pro to remove it.

Need help? Email support@opiniom.io or check the changelog for recent updates.