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:
- Your backend signs a short-lived JWT for each authenticated user using your OpinioM secret key
- Your frontend passes that JWT to the OpinioM SDK, which mounts the chat widget and opens a WebSocket
- 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.
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
| Type | Use case |
|---|---|
direct | 1-on-1 messaging between two users |
group | Multi-user chat where members can send messages |
channel | Public, broadcast-style conversations |
support | Customer-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_deletedflag 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:
| Data | Where it lives | Owner |
|---|---|---|
| Users, passwords, profiles | Your database | You |
| Chat messages, threads, reactions | OpinioM database | OpinioM |
| File attachments | OpinioM CDN (S3-backed) | OpinioM |
| Presence / typing status | OpinioM Redis (ephemeral) | OpinioM |
| Message events (optional sync) | Your DB via webhook | You |
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
- A user logs into your application (using whatever auth system you already have)
- Your backend signs a JWT containing the user's identity, using your OpinioM secret key as the HMAC secret
- Your frontend passes this token to the OpinioM SDK
- The SDK sends the token to OpinioM, which verifies the signature with the same secret key
- OpinioM creates or updates an external user record (keyed on the
subclaim) and issues a short-lived internal session token - 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
| Claim | Required | Description |
|---|---|---|
sub | Yes | Your internal user ID. Used as the unique identifier inside OpinioM. |
name | No | Display name shown in the chat UI |
email | No | Email address (optional, useful for support contexts) |
avatar_url | No | Profile picture URL shown in the chat UI |
iat | Yes | Issued-at timestamp (Unix seconds) |
exp | Yes | Expiry 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");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/reactInitialization options
| Option | Type | Description |
|---|---|---|
publicKey | string | Required. Your project's public key (ok_live_...) |
token | string | Required. JWT signed by your backend |
container | string | HTMLElement | CSS selector or DOM element to mount the widget into. Defaults to floating launcher in the corner of the page. |
onReady | function | Callback fired once the widget connects and is ready |
onError | function | Callback fired on connection or auth errors |
onMessage | function | Callback 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 widgetReact 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.
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.
| Hook | Returns | Use case |
|---|---|---|
useConversations() | Conversation[] | Render a conversation list / sidebar |
useConversation(id) | Conversation | undefined | Show 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() | number | Badge on your nav or tab title |
useUnreadByConversation() | Record<string, number> | Per-conversation unread badges |
useConnectionStatus() | ClientState | Show connection banners |
useCurrentUser() | User | null | Display 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).
| Hook | Action | Returns |
|---|---|---|
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, errorNon-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
| Event | Payload |
|---|---|
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
| Event | When it fires |
|---|---|
message:new | A new message was sent to a conversation you're in |
message:updated | A message was edited |
message:deleted | A message was deleted |
reaction:added | Someone added a reaction |
reaction:removed | Someone removed a reaction |
member:joined | A user joined a conversation |
member:left | A user left a conversation |
typing:update | Typing indicator changed |
presence:changed | A user came online or went offline |
read:updated | A 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
| Event | Trigger |
|---|---|
message.created | A new message was sent |
message.updated | A message was edited |
message.deleted | A message was deleted |
reaction.added | An emoji reaction was added |
reaction.removed | A reaction was removed |
conversation.created | A new conversation was started |
conversation.archived | A conversation was archived |
member.joined | A user joined a conversation |
member.left | A user left a conversation |
user.presence.changed | A 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", 200Ruby (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
endRetry 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
200as 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
idfield to deduplicate. - Verify signatures. Always verify the
X-Signatureheader 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 webhookWidget 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
| Section | Options |
|---|---|
| Colors | Primary, message bubble background, chat panel background, and text colors. All accept 6-character hex values (e.g. #6366F1). |
| Typography | Choose 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). |
| Layout | Position the launcher in bottom-right, bottom-left, or render inline within a container element. Choose launcher size (small / medium / large). |
| Content | Header title (max 40 chars), input placeholder text (max 80 chars), and the powered-by badge. |
| Launcher icon | Upload 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>Need help? Email support@opiniom.io or check the changelog for recent updates.

