Embeddable chat your backend already trusts.
Real-time messaging, presence, typing, and channels in one self-contained service. Your backend signs the tokens, your services own the events — emb.chat just moves the messages.
One stack. No external services.
Chat is four products pretending to be one.
Messaging, presence, push, and analytics usually arrive as four integrations with four failure modes. emb.chat collapses them into one service that hands your systems everything they need to do the rest.
The usual sprawl
- — A chat vendor that owns your users
- — A separate push pipeline
- — Presence bolted on after the fact
- — Analytics scraping it all back
With emb.chat
- + You keep identity & sign tokens
- + Webhooks feed your push service
- + Presence & typing are built in
- + Every event lands on your stack
Everything real-time chat needs — and nothing it shouldn't own.
Real-time messaging
Typed message events over WebSocket — new, edited, deleted — with cursor-paginated history and soft deletes. Send and await an ack, every time.
Presence & typing
Live online/offline presence on a 15s heartbeat, last-seen tracking, and 3-second auto-stop typing indicators — broadcast over the socket and to your webhooks.
Role-based channels
Direct, group, and admin-created channels with owner / admin / member roles, a last-owner guard, and idempotent direct channels between any two users.
Three group modes
Pick external (host-owned), internal (client-driven), or hybrid (locked host rooms + ad-hoc client rooms) per app. Lock individual channels when you need to.
Auth you own
Your backend signs chat tokens with RS256 or EdDSA. emb.chat verifies against a registered public key — it can verify, but it can never mint. Rotate keys with one call.
Signed webhooks
HMAC-SHA256 signed deliveries with a 7-attempt retry ladder, per-event delivery logs, and offline-member enrichment so your push service knows exactly who to notify.
emb.chat can verify identities. It can never mint them.
Register a public key once. Your backend signs chat tokens locally with the matching private key — RS256 or EdDSA — and emb.chat verifies them with zero round-trips. Identity ownership stays exactly where it belongs.
-
Asymmetric by design
emb.chat only ever holds your public key. It verifies signatures; it cannot forge them.
-
Zero-latency sessions
No token-validation callback to emb.chat on connect — your signature is the proof.
-
Rotate with a kid
Multiple active keys per app. Roll a new key, sign with its kid, retire the old one with one call.
-
Claims mirror your user
name, avatar, and meta from the token sync into the chat profile on connect — host stays the source of truth.
import { ChatAuth } from '@emb-chat/server-sdk';
// Your backend signs locally — zero round-trips to emb.chat.
const token = await ChatAuth.createToken(privateKey, {
sub: user.id, // your user id
aud: 'app_demo', // your app id
kid: 'key_01', // selects the registered public key
name: user.displayName,
meta: { role: user.role },
expiresIn: '1h',
}); One config flag reshapes who controls your channels.
Set it per app, lock individual channels when you need to. The same server runs host-owned, client-driven, or a deliberate mix.
external Host-owned Your API key manages every channel. Token clients read and post, but never create or administer. The original, locked-down stance.
internal Client-driven Token clients create, rename, and delete their own channels and manage membership through role gates. Perfect for mobile-style social apps.
hybrid Best of both Pre-create authoritative locked rooms with your API key, and let clients spin up their own ad-hoc unlocked channels alongside them.
Delivery envelope
HMAC-SHA256Retry ladder
7 attempts, 5s timeout each. After 50 consecutive failures the endpoint auto-disables — every delivery stays in the audit log.
Every event, signed and delivered to your services.
message, member, presence, channel, and webhook events stream to your endpoints — HMAC-signed, retried on an exponential ladder, and enriched with the offline members who need a push.
message.createdmessage.editedmessage.deletedmember.addedmember.removedmember.rolepresence.onlinepresence.offlinechannel.updated message.* Four services. One compose file. No external dependencies.
Server, webhook worker, PostgreSQL, and Redis come up together. Scale the worker independently so a slow endpoint never stalls a message. An operator dashboard ships in the box.
services:
server: # Fastify REST + Socket.IO gateway
webhook-worker:# signed delivery, retries, auto-disable
postgres: # messages, channels, members, keys
redis: # presence, typing, pub/sub, job queue Three SDKs, one mental model.
Sign on the server, connect on the client, verify on your services. Here's the whole loop.
import { readFileSync } from 'node:fs';
import { ChatAuth } from '@emb-chat/server-sdk';
const privateKey = await ChatAuth.loadPrivateKey(
readFileSync('./emb-private.pem', 'utf8'),
);
const token = await ChatAuth.createToken(privateKey, {
sub: user.id,
aud: 'app_demo',
kid: 'key_01',
name: user.displayName,
expiresIn: '1h',
}); import { ChatClient } from '@emb-chat/react-native-sdk';
const chat = new ChatClient({ url: 'https://chat.acme.com', token });
await chat.connect();
const [first] = await chat.channels.list();
const channel = chat.channel(first.id);
channel.on('message:new', (msg) => render(msg));
channel.on('typing:update', (t) => showTyping(t));
await channel.sendMessage({ body: 'Hello, world', type: 'text' }); import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyEmbWebhook(req, secret) {
const ts = Number(req.headers['x-emb-timestamp']);
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5-min window
const expected =
'sha256=' +
createHmac('sha256', secret).update(`${ts}.${req.rawBody}`).digest('hex');
const got = String(req.headers['x-emb-signature'] ?? '');
return expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got));
} Ship real-time chat this week.
Clone it, run one compose command, sign your first token. The MVP is fully shipped.