Real-time chat in your product, in an afternoon. چتِ بیدرنگ در محصولت، توی یک بعدازظهر.
A multi-tenant chat backend with a TypeScript SDK that handles the hard parts — reconnect, message ordering, de-duplication, and backfilling anything missed while a socket was down. You mint a token and render messages; the SDK does the rest. یک بکاند چتِ چندمستأجری همراه با یک SDK تایپاسکریپت که بخشهای سختش را خودش حل میکند: اتصال مجدد، ترتیب پیامها، حذف پیامهای تکراری و جبران پیامهایی که موقع قطعشدن کانکشن جا ماندهاند. شما فقط یک توکن میسازید و پیامها را نمایش میدهید؛ بقیهاش با SDK است.
The mental model, once: You own your users — we never see their passwords. Your backend proves “this is user X” by signing a short-lived JWT with your signing secret. Two credentials do everything: an API key (backend, server-to-server) and a per-user JWT (frontend). The API key and signing secret never touch the browser.
یک بار این مدل ذهنی را بخوان: کاربران مالِ خودت هستند — ما هیچوقت رمز عبورشان را نمیبینیم. بکاندِ تو با امضای یک JWT کوتاهعمر توسط «کلید امضا»یِ اختصاصیات، اثبات میکند که «این کاربرِ X است». همهچیز با دو اعتبارنامه انجام میشود: یک کلید API (سمت بکاند، ارتباط سرور به سرور) و یک JWT جداگانه برای هر کاربر (سمت فرانتاند). کلید API و کلید امضا هیچوقت نباید به مرورگر برسند.
Get your account provisionedحساب خود را فعال کنید
The platform operator creates your tenant once and hands you four things. Store the API key and signing secret in your backend secret store — treat them like database passwords. اپراتور پلتفرم، یک بار برای همیشه «مستأجر» (tenant) شما را میسازد و چهار چیز به شما تحویل میدهد. کلید API و کلید امضا را در secret store بکاندتان نگه دارید و دقیقاً مثل رمز دیتابیس با آنها رفتار کنید.
| You receiveچه میگیرید | Used byمصرفکننده | Exampleنمونه |
|---|---|---|
| Tenant slug your JWT issمقدار iss در توکن | frontend & backendفرانت و بکاند | acme |
| API key | your backendبکاند شما | ck_ab12….<secret> |
| JWT signing secretکلید امضای JWT | your backendبکاند شما | s5lm3cq7… |
| Base URLs | your frontendفرانتاند شما | https://chat.titanapp.dev |
Never ship the API key or the signing secret to the browser. The browser only ever sees a short-lived per-user JWT that your backend minted.
کلید API و کلید امضا را هیچوقت به مرورگر نفرستید. مرورگر فقط باید یک JWT کوتاهعمرِ مخصوص همان کاربر را ببیند که بکاند شما آن را ساخته است.
Backend: mint a user token on loginبکاند: هنگام ورود، یک توکن کاربر بساز
When a user logs into your app, sign a short-lived JWT and return it to your frontend. Required claims: iss (your slug), sub (your own user id — any string), and exp. Optional name / avatar refresh the user's profile on every token. The chat user is created automatically on first use.
وقتی کاربری وارد اپلیکیشنِ خودتان میشود، یک JWT کوتاهعمر امضا کنید و به فرانتاند برگردانید. کلیمهای الزامی: iss (همان slug شما)، sub (شناسهٔ کاربر در سیستم خودتان — هر رشتهای)، و exp. مقادیر اختیاری name و avatar در هر توکن، پروفایل کاربر را بهروز میکنند. کاربر در چت، بار اول بهصورت خودکار ساخته میشود.
import jwt from "jsonwebtoken";
// your existing login / session endpoint
function chatToken(user) {
return jwt.sign(
{ iss: "acme", sub: user.id, name: user.displayName, avatar: user.avatarUrl },
process.env.CHAT_SIGNING_SECRET, // kept server-side, never shipped to the client
{ algorithm: "HS256", expiresIn: "1h" }, // short-lived; refresh on expiry
);
}
import jwt, time # PyJWT
def chat_token(user):
now = int(time.time())
return jwt.encode(
{"iss": "acme", "sub": user.id, "name": user.name, "avatar": user.avatar,
"iat": now, "exp": now + 3600}, # 1 hour
CHAT_SIGNING_SECRET, algorithm="HS256",
)
Tell us who your users areبه ما بگویید کاربرانتان چه کسانی هستند
A name and avatar normally arrive in that user's own token. So someone who hasn't opened the chat yet has no name to show — start a conversation with them and they render blank until they reply. Push what you already know instead, and the problem disappears: نام و تصویر هر کاربر معمولاً از توکنِ خودش میآید. پس کسی که هنوز چت را باز نکرده نامی برای نمایش ندارد — با او گفتگو شروع میکنید و تا وقتی جواب ندهد خالی دیده میشود. کافی است چیزی را که خودتان میدانید برای ما بفرستید تا این مشکل حل شود:
# Server-to-server, with your API key. Up to 1000 users per call.
curl -X POST https://chat.titanapp.dev/api/v1/users \
-H "Authorization: Bearer $API_KEY" \
-d '{"users":[
{"external_user_id":"seller-42",
"display_name":"Ali Rezaei",
"avatar_url":"https://cdn.example/a.jpg"}
]}'
# Users that do not exist yet are created — so you can seed your whole
# directory before anyone logs in, and it fixes people already blank.
Pushing a profile makes it yours. From then on that user's token claims stop overwriting it — so a token minted without a name can't blank out a good value. Push again whenever the name changes; if you'd rather let tokens drive it, just don't push for that user. Note this is deliberately a backend call: if an end user could name other people, they could make arbitrary text render as somebody else's identity.
هر پروفایلی که بفرستید، از آنِ شما میشود. از آن پس ادعاهای توکنِ آن کاربر دیگر رویش بازنویسی نمیکنند — پس توکنی که بدون name ساخته شده نمیتواند یک مقدار درست را خالی کند. هر وقت نام عوض شد دوباره بفرستید؛ و اگر ترجیح میدهید توکنها تعیینکننده باشند، برای آن کاربر اصلاً چیزی نفرستید. توجه کنید که این عمداً یک فراخوانیِ سمتِ بکاند است: اگر کاربرِ عادی میتوانست برای دیگران نام تعیین کند، میشد هر متنی را بهجای هویتِ شخصِ دیگری نمایش داد.
Frontend: install & connectفرانتاند: نصب و اتصال
Install the SDK, create a client with your base URLs and the token from step 2, then connect. You are now receiving messages in real time — the SDK reconnects and backfills missed messages for you. SDK را نصب کنید، یک کلاینت با آدرسهای پایه و توکنی که در مرحلهٔ ۲ ساختید بسازید، و متصل شوید. از همین حالا پیامها را بهصورت بیدرنگ دریافت میکنید — اتصال مجدد و جبران پیامهای جامانده را خودِ SDK انجام میدهد.
npm install @basalam-saas/chat-sdk
import { ChatClient } from "@basalam-saas/chat-sdk";
const chat = new ChatClient({
baseUrl: "https://chat.titanapp.dev/api/v1",
wsUrl: "wss://chat.titanapp.dev/ws",
token, // fetched from your backend (step 2)
});
// Receive in real time. Dedup + reconnect-backfill are handled for you —
// just render what arrives.
chat.on("message", (m) => renderIncoming(m));
chat.connect();
The SDK has zero runtime dependencies and uses the platform's global fetch / WebSocket. In the browser and on Node ≥ 18 it works out of the box; on older runtimes pass fetchImpl / webSocketImpl.
این SDK هیچ وابستگی زمان اجرا ندارد و از fetch و WebSocketِ سراسری خودِ پلتفرم استفاده میکند. در مرورگر و روی Node نسخهٔ ۱۸ به بالا بدون هیچ کار اضافهای کار میکند؛ روی نسخههای قدیمیتر کافی است fetchImpl و webSocketImpl را پاس بدهید.
Send & receiveارسال و دریافت
Conversations are addressed by your own user ids (external_user_id) — you never deal in our internal ids. A 1:1 is idempotent: the same pair always returns the same conversation, so you can “create” it on every page load.
گفتگوها با شناسهٔ کاربرانِ خودتان (external_user_id) آدرسدهی میشوند — هیچوقت با شناسههای داخلیِ ما سروکار ندارید. گفتگوی دونفره idempotent است: همان دو نفر همیشه همان گفتگو را برمیگردانند، پس میتوانید در هر بار بارگذاریِ صفحه بیخطر «بسازیدش».
// 1:1 — idempotent, so calling this twice is safe
const convo = await chat.createConversation({
type: "direct",
participantExternalIds: ["seller-42"],
});
// …or a group
const group = await chat.createConversation({
type: "group",
title: "Order #991",
participantExternalIds: ["u2", "u3"],
});
await chat.sendMessage(convo.id, {
body: "Hi!",
clientMsgId: crypto.randomUUID(), // makes a retry return the SAME message
});
Replying to a specific messageپاسخ به یک پیام مشخص
Quote one earlier message from the same conversation. The quote is re-read on every fetch rather than copied, so an edit to the original shows through and a delete blanks it everywhere. میتوانید یک پیامِ قبلی از همان گفتگو را نقلقول کنید. نقلقول در هر بار خواندن دوباره خوانده میشود و کپی نمیشود؛ برای همین اگر پیامِ اصلی ویرایش شود متنِ تازه دیده میشود و اگر حذف شود، همهجا خالی میشود.
await chat.sendMessage(convo.id, {
body: "on it",
replyToMessageId: someMessage.id, // must be in THIS conversation
});
// on every read:
// m.reply_to = { id, seq, sender_user_id, type, body, deleted_at }
// body === null -> the quoted message was deleted; render a placeholder
Quoting a message from a different conversation is refused with 400. That's deliberate: the quote echoes the original's text back to you, so allowing it would turn a guessed id into a way to read messages you aren't part of.
نقلقول از گفتگوی دیگر با خطای 400 رد میشود. این عمدی است: چون نقلقول متنِ پیامِ اصلی را به شما برمیگرداند، اجازهدادنش یعنی هرکسی با حدسِ یک شناسه میتوانست پیامهایی را بخواند که عضوشان نیست.
History & the inboxتاریخچه و صندوق گفتگوها
// History is a seq CURSOR, not an offset — pages never shift under you.
const { items, has_more } = await chat.getMessages(convo.id, { limit: 50 });
// older: getMessages(id, { beforeSeq: items[0].seq })
// newer: getMessages(id, { afterSeq: lastSeq })
// Inbox: every row already carries the newest message — no request per row.
const { items: convos } = await chat.listConversations();
convos.forEach((c) => renderRow({
title: c.title,
preview: c.last_message?.body ?? "", // null until someone writes
unread: c.unread_count,
muted: c.my_muted,
}));
- Edit / delete your own:
chat.editMessage(id, body)·chat.deleteMessage(id)— everyone getsmessage.updated/message.deleted. A deleted message keeps its place in the order but itsbodybecomesnull.ویرایش/حذف پیام خودتان:chat.editMessage(id, body)وchat.deleteMessage(id)— همه رویدادmessage.updatedیاmessage.deletedمیگیرند. پیامِ حذفشده جایش را در ترتیب نگه میدارد ولیbodyآنnullمیشود. - Don't re-implement reliability. The SDK dedups by
(conversation_id, seq)and backfills whatever a dropped socket missed. Render what arrives.قابلیت اطمینان را دوباره ننویسید. SDK با کلید(conversation_id, seq)تکراریها را حذف میکند و هرچه در قطعیِ سوکت جا مانده را جبران میکند. شما فقط چیزی که میرسد را نمایش دهید.
Read state & “seen”وضعیت خواندن و «دیدهشده»
We never store one receipt per message per person — at scale that would be more read-state than messages. Each member has a single read cursor, and a message counts as seen by everyone whose cursor has passed it. You don't have to do that maths: it arrives pre-computed on every message. ما هیچوقت بهازای هر پیام و هر شخص یک رسیدِ جدا ذخیره نمیکنیم — در مقیاس بالا حجمِ این رسیدها از خودِ پیامها بیشتر میشد. هر عضو فقط یک نشانگرِ خواندن دارد و هر پیامی که نشانگرِ کسی از آن گذشته باشد، برای او خواندهشده حساب میشود. لازم نیست خودتان این حساب را بکنید: نتیجه از قبل روی هر پیام محاسبه شده و میرسد.
// Mark read when the user actually sees the messages.
await chat.markRead(convo.id, lastVisibleSeq); // -> { last_read_seq, unread_count }
// Every message carries its own state. Render ticks straight from it:
const ticks = m.seen_by_all ? "✓✓" : `✓ ${m.seen_by_count}`;
// Stay live: `read` arrives whenever anyone's cursor moves.
chat.on("read", (e) => applyRead(e)); // { conversation_id, user_id, last_read_seq }
| Fieldفیلد | Onروی | Meansیعنی |
|---|---|---|
seen_by_count | Message | How many other members read it. You never count as having seen your own message.چند نفرِ دیگر آن را خواندهاند. خودِ فرستنده هیچوقت جزوِ خوانندهها حساب نمیشود. |
seen_by_all | Message | Everyone else read it. In a 1:1 that's exactly “the other person saw it”. Stays false when there is nobody else.همهٔ افرادِ دیگر خواندهاند. در گفتگوی دونفره یعنی «طرف مقابل دید». اگر کسِ دیگری نباشد، false میماند. |
unread_count | Conversation | Messages after your cursor — the badge number.پیامهای بعد از نشانگرِ شما — همان عددِ نشانِ اعلان. |
last_read_seq | Member | That member's cursor. Lets you work out who saw a message, not just how many.نشانگرِ همان عضو. با آن میفهمید چه کسانی پیام را دیدهاند، نه فقط چند نفر. |
Who saw it, not just how manyچه کسانی دیدند، نه فقط چند نفر
Each member in conversation.members[] carries their own last_read_seq. That's the piece the socket alone can't give you: the read event only reports cursor moves, so this is how you know where everyone already stood when the page loaded.
هر عضو در conversation.members[] مقدارِ last_read_seq خودش را دارد. این همان چیزی است که سوکت بهتنهایی نمیدهد: رویدادِ read فقط جابهجاییِ نشانگر را خبر میدهد، پس فقط از این طریق میفهمید موقعِ بارگذاریِ صفحه هر کس تا کجا خوانده بوده است.
const readers = convo.members
.filter((mem) => mem.user_id !== m.sender_user_id && mem.last_read_seq >= m.seq)
.map((mem) => mem.external_user_id); // -> ["ali", "sara"]
// Keep it current — the event carries an ABSOLUTE cursor, so this is idempotent.
chat.on("read", (e) => {
const mem = convo.members.find((x) => x.external_user_id === e.user_id);
if (mem) mem.last_read_seq = e.last_read_seq;
});
Three things that catch people out. (1) The counts are a snapshot at fetch time — keep them live from the read event. (2) They are not replayed by changedSinceEvent, so after a long disconnect refetch the recent page to resync. (3) The read event goes to every member including you, so your own other tabs and devices stay in sync — and read.user_id is your external id, while sender_user_id on a message is our internal one. members[] carries both if you need to map between them.
سه نکتهای که معمولاً غافلگیرکننده است. (۱) این اعداد یک عکسِ لحظهای در زمانِ دریافتاند — با رویدادِ read زنده نگهشان دارید. (۲) با changedSinceEvent دوباره پخش نمیشوند، پس بعد از یک قطعیِ طولانی صفحهٔ اخیر را دوباره بگیرید. (۳) رویدادِ read برای همهٔ اعضا از جمله خودتان میآید تا تبها و دستگاههای دیگرتان هم هماهنگ بمانند — و read.user_id شناسهٔ کاربر در سیستمِ شماست، در حالی که sender_user_id روی پیام شناسهٔ داخلیِ ماست. اگر لازم شد این دو را به هم نگاشت کنید، members[] هر دو را دارد.
Groups, members & mutingگروهها، اعضا و بیصدا کردن
A group has an owner (whoever created it) and members. Owners and admins manage the roster; anyone can leave. Membership changes are pushed to everyone, so open clients update without a refetch. هر گروه یک مالک (همان کسی که ساخته) و تعدادی عضو دارد. مالک و ادمینها فهرست اعضا را مدیریت میکنند و هر کسی میتواند خودش خارج شود. تغییرات اعضا برای همه ارسال میشود، پس کلاینتهای باز بدون درخواستِ دوباره بهروز میشوند.
// Owner/admin only. Ids are YOUR external_user_ids; unknown users get created.
await chat.addMembers(group.id, ["seller-42", "support-7"]);
// Remove someone (owner/admin). The owner can't be removed.
await chat.removeMember(group.id, "seller-42"); // -> updated Conversation
// Leave it yourself -> resolves to NOTHING: you're not a member any more,
// so there's no conversation left to hand back. Just drop it from your UI.
await chat.leave(group.id, myExternalUserId);
// Mute/unmute FOR YOURSELF. Read it back from convo.my_muted.
await chat.setMuted(group.id, true);
chat.on("member.added", (e) => refreshRoster(e.conversation_id));
chat.on("member.removed", (e) => e.removed === myId
? dropConversation(e.conversation_id) // you were removed / you left
: refreshRoster(e.conversation_id));
Muting is advisory — it's your job to honour it. It's a per-member flag, private to that member, and it changes nothing on our side: messages still arrive over the socket, still count toward unread_count, and still fire the offline webhook. Check conversation.my_muted and skip the sound or badge in your UI, and skip the push in your webhook handler. Mute a conversation and keep sending pushes, and the user still gets notified.
بیصدا کردن فقط یک علامت است — رعایتش با شماست. این یک فلگِ مخصوصِ هر عضو و خصوصی است و سمتِ ما هیچ چیزی را عوض نمیکند: پیامها همچنان از سوکت میرسند، در unread_count شمرده میشوند و وبهوکِ آفلاین را هم فعال میکنند. مقدار conversation.my_muted را بررسی کنید و صدا یا نشانِ اعلان را در رابط کاربری نشان ندهید و در هندلرِ وبهوک هم نوتیفیکیشن نفرستید. اگر گفتگویی را بیصدا کنید ولی همچنان پوش بفرستید، کاربر باز هم نوتیف میگیرد.
- Roles:
owner·admin·member, on each entry ofconversation.members[]alongsidemy_rolefor the caller.نقشها:owner،adminوmember، روی هر عضو درconversation.members[]و همچنینmy_roleبرای خودِ فراخوان. - Direct conversations have no roster ops — add/remove reject them. Groups only.گفتگوهای دونفره مدیریتِ اعضا ندارند — افزودن و حذف برای آنها رد میشود. فقط گروهها.
- A removed user is told directly on their own channel, so their client can drop the conversation immediately.به کاربرِ حذفشده مستقیماً روی کانالِ خودش خبر داده میشود تا کلاینتش بلافاصله گفتگو را حذف کند.
Attachments: images & filesپیوستها: تصویر و فایل
File bytes never pass through the chat API. We hand your browser a short-lived signed URL, it uploads straight to object storage, and we only keep the metadata. That keeps big uploads off our request path — and means a slow upload can't slow anyone's chat down. بایتهای فایل هیچوقت از API چت عبور نمیکنند. ما یک لینکِ امضاشدهٔ کوتاهعمر به مرورگرِ شما میدهیم، مرورگر مستقیم روی فضای ذخیرهسازی آپلود میکند و ما فقط متادیتا را نگه میداریم. اینطوری آپلودهای حجیم از مسیرِ درخواستهای ما بیرون میمانند — و یک آپلودِ کُند نمیتواند چتِ بقیه را کُند کند.
// One call does all three steps: presign -> PUT the bytes -> confirm.
const attachmentId = await chat.upload(file);
// Attach it to a message (with or without text).
await chat.sendMessage(convo.id, { attachmentId });
await chat.sendMessage(convo.id, { body: "the invoice", attachmentId });
// Reading it back — a fresh signed link each time, members only.
const url = await chat.getDownloadUrl(m.attachment.id);
render(<img src={url} />);
A message with a file comes back with type of image or file (we infer it from the content type) and an attachment object carrying id, content_type, size_bytes, filename, and width/height for images.
پیامی که فایل دارد با type برابرِ image یا file برمیگردد (نوعش را از content type تشخیص میدهیم) و یک شیءِ attachment دارد شاملِ id، content_type، size_bytes، filename و برای تصاویر width/height.
- Download links expire (15 minutes by default). Fetch one when you're about to render, don't store it.لینکهای دانلود منقضی میشوند (پیشفرض ۱۵ دقیقه). درست قبل از نمایش لینک را بگیرید و ذخیرهاش نکنید.
- Only members of the conversation can get a link — anyone else is refused with
403.فقط اعضای همان گفتگو میتوانند لینک بگیرند — بقیه با403رد میشوند. - Uploads that are never attached are cleaned up automatically, so an abandoned file picker costs you nothing.آپلودهایی که به هیچ پیامی وصل نمیشوند خودکار پاکسازی میشوند، پس یک انتخابِ فایلِ نیمهکاره هزینهای ندارد.
- Deleting a message hides its attachment from the payload along with the body.با حذفِ پیام، پیوستِ آن هم مثل متنِ پیام از خروجی حذف میشود.
Broadcast channelsکانالهای اطلاعرسانی
A channel is an announcement room: your admins post, everyone else reads. Reading is identical to any other conversation — it shows up in the inbox with an unread count, has history, and new posts arrive on the same message event. What's different is that subscribers can't post, and can't see each other.
کانال یک فضای اطلاعرسانی است: ادمینهای شما پیام میفرستند و بقیه فقط میخوانند. خواندنش دقیقاً مثل هر گفتگوی دیگری است — در صندوق گفتگوها با تعداد نخوانده دیده میشود، تاریخچه دارد و پیامهای تازه با همان رویدادِ message میرسند. تفاوتش این است که مشترکان نمیتوانند پیام بفرستند و همدیگر را هم نمیبینند.
# Channels are created and populated server-to-server, with your API key —
# so an end user can't create one and mass-subscribe your users to it.
curl -X POST https://chat.titanapp.dev/api/v1/channels -H "Authorization: Bearer $API_KEY" -d '{"title":"Announcements","admin_external_ids":["ops-1"]}'
# Subscribe people in bulk (one request, not one per person).
curl -X POST https://chat.titanapp.dev/api/v1/channels/$ID/subscribers -H "Authorization: Bearer $API_KEY" -d '{"external_user_ids":["u1","u2","u3"]}'
# The subscriber list is paginated and API-key only — it is the ONLY way
# to enumerate subscribers, deliberately.
curl "https://chat.titanapp.dev/api/v1/channels/$ID/subscribers?page=1" -H "Authorization: Bearer $API_KEY"
// Users can opt themselves in and out.
await chat.subscribe(channelId);
await chat.unsubscribe(channelId);
// From there, reading is exactly like any conversation.
const { items } = await chat.getMessages(channelId, { limit: 50 });
chat.on("message", (m) => render(m)); // posts arrive the same way
| On a channelدر کانال | Behaviourرفتار |
|---|---|
type | "channel" |
subscriber_count | how many subscribe (null on direct/group)تعداد مشترکان (برای دونفره و گروه null است) |
members | the admins only — subscribers are not listed hereفقط ادمینها — مشترکان اینجا فهرست نمیشوند |
| Postingارسال پیام | admins only; anyone else gets 403فقط ادمینها؛ بقیه 403 میگیرند |
seen_by_count · seen_by_all | always 0 / falseهمیشه 0 و false |
typing · read | not emittedارسال نمیشوند |
unread_count | works normallyمثل همیشه کار میکند |
Why a channel isn't just a big group. A group delivers a message by publishing once per member — right for a handful of people, hopeless for thousands. A channel publishes once, so the cost of a post stops depending on how many people are listening. The same reasoning is why it reports no “seen” and no typing: both are per-member work that would grow with your audience and mean nothing on a broadcast.
چرا کانال فقط یک گروهِ بزرگ نیست. در گروه، هر پیام بهازای هر عضو یک بار منتشر میشود — برای چند نفر مناسب است و برای چند هزار نفر اصلاً جواب نمیدهد. در کانال پیام یک بار منتشر میشود، پس هزینهٔ ارسال دیگر به تعداد شنوندهها وابسته نیست. به همین دلیل هم «دیدهشده» و «در حال تایپ» ندارد: هر دو کارِ بهازای هر عضواند که با بزرگشدن مخاطب رشد میکنند و در پخش همگانی معنایی ندارند.
Typing & presenceتایپ و حضور
The two “alive” signals. Both are ephemeral — they're never stored, they expire on their own, and a missed one is harmless. Send them freely. دو سیگنالی که چت را «زنده» نشان میدهند. هر دو گذرا هستند — ذخیره نمیشوند، خودشان منقضی میشوند و اگر یکیشان از دست برود مشکلی پیش نمیآید. با خیال راحت بفرستیدشان.
// Typing — call it on keystroke; it's cheap and self-expiring.
chat.sendTyping(convo.id, true);
chat.on("typing", (e) => showTyping(e)); // { conversation_id, user_id, is_typing }
// Presence — pull a snapshot, then keep it live.
const who = await chat.getPresence(["seller-42"]); // { "seller-42": { online, last_seen } }
chat.on("presence", (e) => setOnline(e.user_id, e.online));
- Presence is pushed on connect and disconnect to everyone who shares a conversation with that user — you don't poll.حضور هنگام اتصال و قطعِ اتصال برای همهٔ کسانی که با آن کاربر گفتگوی مشترک دارند ارسال میشود — نیازی به poll کردن نیست.
- Multi-device is handled: a user with two tabs open shows online until the last one closes.چنددستگاهی پشتیبانی میشود: کاربری که دو تب باز دارد تا بستهشدنِ آخرین تب آنلاین نشان داده میشود.
Backend: offline push (recommended)بکاند: نوتیفیکیشن برای کاربر آفلاین (پیشنهادی)
When a message targets a user with no live connection, the chat service calls your backend so you send the push — we never touch APNs/FCM. Register a webhook once with your API key, then verify the HMAC signature on every delivery. وقتی پیامی برای کاربری فرستاده میشود که کانکشن باز ندارد، سرویس چت به بکاندِ شما درخواست میزند تا خودتان نوتیف را بفرستید — ما هیچوقت سراغ APNs/FCM نمیرویم. یک بار با کلید API وبهوک را ثبت کنید و بعد امضای HMAC را روی هر تحویل بررسی کنید.
# server-to-server: authenticate with your API key
curl -X POST https://chat.titanapp.dev/api/v1/webhook-endpoints \
-H "Authorization: Bearer $API_KEY" \
-d '{"url":"https://you.example/chat-hook","events":["message.created"]}'
# -> returns a "secret" (shown once). Store it to verify signatures.
import crypto from "crypto";
app.post("/chat-hook", express.raw({ type: "*/*" }), (req, res) => {
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body).digest("hex");
if (expected !== req.get("X-Chat-Signature")) return res.sendStatus(401);
const { message, offline_recipient_ids } = JSON.parse(req.body);
sendPush(offline_recipient_ids, message); // your APNs / FCM
res.sendStatus(200); // non-2xx -> we retry with backoff
});
Before you ship: production checklistقبل از انتشار: چکلیست پروداکشن
- Refresh tokens. Tokens are short-lived. Before expiry, mint a fresh one and call
chat.setToken(newJwt)— it re-auths the live socket in place. Listen forauth.expiredas a backstop.توکن را تازه کنید. توکنها کوتاهعمرند. پیش از انقضا یک توکن جدید بسازید وchat.setToken(newJwt)را صدا بزنید — همانجا سوکتِ باز را دوباره احراز هویت میکند. برای اطمینان، به رویدادauth.expiredهم گوش بدهید. - Secrets stay server-side. The API key and signing secret must never reach the browser.کلیدها فقط سمت سرور. کلید API و کلید امضا هیچوقت نباید به مرورگر برسند.
- Handle the envelope. Every REST response is
{ is_successful, message:{en,fa}, data, error }. Showmessage.fa/en; expect401,403,404,409,422, and429(back off).قالب پاسخ را مدیریت کنید. هر پاسخِ REST به شکل{ is_successful, message:{en,fa}, data, error }است. متنِmessage.fa/enرا نشان دهید و منتظر کدهای401،403،404،409،422و429(کاهش نرخ درخواست) باشید. - Don't re-implement reliability. The SDK already dedups by
(conversation_id, seq)and backfills on reconnect. Just render events.قابلیت اطمینان را دوباره ننویسید. SDK همین حالا با کلید(conversation_id, seq)پیامهای تکراری را حذف میکند و بعد از اتصال مجدد، جاماندهها را جبران میکند. شما فقط رویدادها را نمایش دهید. - Webhook receiver verifies
X-Chat-Signature, returns 2xx quickly, and is idempotent.گیرندهٔ وبهوک بایدX-Chat-Signatureرا بررسی کند، سریع کد ۲xx برگرداند و idempotent باشد.
Scaling is our problem, not yours. Your integration is identical whether the platform serves 5 tenants or 100 — more pods, replicas, and shards are transparent to your code. Keep tokens short-lived, let the SDK handle reconnects, and you're done.
مقیاسپذیری دغدغهٔ ماست، نه شما. فرقی نمیکند پلتفرم به ۵ مستأجر سرویس بدهد یا ۱۰۰ — یکپارچهسازی شما دقیقاً یکسان است و افزودن pod و رپلیکا و شارد برای کدِ شما کاملاً نامرئی است. توکنها را کوتاهعمر نگه دارید، اتصال مجدد را به SDK بسپارید، تمام.
What you get backچه چیزی برمیگردد
The three objects you'll actually render. Every REST response is wrapped in the envelope { is_successful, message:{en,fa}, data, error } — the SDK unwraps it and hands you data.
سه شیئی که واقعاً نمایششان میدهید. هر پاسخِ REST داخل قالبِ { is_successful, message:{en,fa}, data, error } است — SDK آن را باز میکند و data را به شما میدهد.
interface Message {
id: string; conversation_id: string;
seq: number; // per-conversation order key, never changes
sender_user_id: string | null; // null = system message
type: "text" | "image" | "file" | "system";
body: string | null; // null when deleted
client_msg_id: string | null;
updated_event_seq: number; // change-feed cursor (edits/deletes)
edited_at: string | null;
deleted_at: string | null;
attachment: Attachment | null;
reply_to: MessageReply | null; // the quoted message, if any
seen_by_count: number; // how many OTHERS read it
seen_by_all: boolean;
created_at: string;
}
interface Conversation {
id: string; type: "direct" | "group" | "channel";
title: string | null; metadata: Record<string, unknown>;
members: Member[];
last_message_seq: number; last_message_at: string | null;
last_message: Message | null; // the inbox preview line
version: number; // bumps on every edit/delete
unread_count: number; // YOUR unread
subscriber_count: number | null; // channels only; members[] holds admins only
my_role: string; my_muted: boolean;
created_at: string;
}
interface Member {
user_id: string; // our internal id
external_user_id: string; // YOUR id
display_name: string | null; avatar_url: string | null;
role: "owner" | "admin" | "member";
last_read_seq: number; // their cursor -> derive "who saw it"
joined_at: string;
}
API at a glanceنمای کلی API
Everything below is on the SDK too — the method name is in the third column. The full interactive reference is at /docs (Swagger). همهٔ موارد زیر در SDK هم هستند — نامِ متد در ستون سوم آمده است. مرجع کامل و تعاملی در /docs (Swagger) در دسترس است.
Conversations & membersگفتگوها و اعضا
| Endpointاندپوینت | Doesکارکرد | SDKSDK |
|---|---|---|
GET /conversations | inbox — unread, last_message, membersصندوق — نخواندهها، last_message، اعضا | listConversations() |
GET /conversations/{id} | one conversation + rosterیک گفتگو بههمراه اعضا | getConversation() |
POST /conversations | start a direct (idempotent) or groupشروع گفتگوی دونفره (idempotent) یا گروهی | createConversation() |
POST /conversations/{id}/members | add members — owner/adminافزودن عضو — مالک/ادمین | addMembers() |
DEL /conversations/{id}/members/{ext} | remove a member, or leave (your own id)حذف عضو، یا خروج خودتان (شناسهٔ خودتان) | removeMember() · leave() |
POST /channels/{id}/subscribe · DEL | subscribe / unsubscribe yourself to a channelعضویت یا لغو عضویت خودتان در کانال | subscribe() · unsubscribe() |
POST /conversations/{id}/mute | mute/unmute for yourself (advisory)بیصدا کردن برای خودتان (فقط علامت) | setMuted() |
Messagesپیامها
| Endpointاندپوینت | Doesکارکرد | SDKSDK |
|---|---|---|
GET /conversations/{id}/messages | history by seq cursor (after_seq/before_seq/changed_since_event)تاریخچه با مکاننمای seq (after_seq/before_seq/changed_since_event) | getMessages() |
POST /conversations/{id}/messages | send — client_msg_id, attachment_id, reply_to_message_idارسال — client_msg_id، attachment_id، reply_to_message_id | sendMessage() |
PATCH /messages/{id} | edit your ownویرایش پیام خودتان | editMessage() |
DEL /messages/{id} | delete your own (tombstone)حذف پیام خودتان | deleteMessage() |
POST /conversations/{id}/read | advance your read cursorجلو بردن نشانگر خواندن | markRead() |
Files, presence & identityفایلها، حضور و هویت
| Endpointاندپوینت | Doesکارکرد | SDKSDK |
|---|---|---|
POST /uploads → POST /attachments/{id}/confirm | presign, then confirm after the PUTگرفتن لینک امضاشده و تأیید بعد از آپلود | upload(file) |
GET /attachments/{id}/url | short-lived download link (members only)لینک دانلود کوتاهعمر (فقط اعضا) | getDownloadUrl() |
GET /presence?user_ids= | online + last-seen for a set of usersآنلاین بودن و آخرین بازدید چند کاربر | getPresence() |
GET /me | the caller's identity (created on first use)هویت فراخوان (بار اول خودکار ساخته میشود) | me() |
Server-to-server — API keyسرور به سرور — با کلید API
| Endpointاندپوینت | Doesکارکرد |
|---|---|
POST /webhook-endpoints | register an offline-push webhookثبت وبهوک برای نوتیف آفلاین |
POST /channels | create a broadcast channelساخت کانال اطلاعرسانی |
POST DEL /channels/{id}/subscribers | bulk-subscribe / unsubscribe usersعضو کردن گروهی یا حذف مشترک |
GET /channels/{id}/subscribers | paginated subscriber list — the only way to enumerate themفهرست صفحهبندیشدهٔ مشترکان — تنها راه دیدن آنها |
GET /tenant | your tenant's settings & limits — also a credential checkتنظیمات و محدودیتهای شما — و بررسی صحت کلید |
WebSocket eventsرویدادهای WebSocket
Connect at wss://chat.titanapp.dev/ws?token=<jwt>. The SDK surfaces these as chat.on(...).اتصال از wss://chat.titanapp.dev/ws?token=<jwt>. SDK اینها را بهصورت chat.on(...) میدهد.
| Eventرویداد | Payloadداده |
|---|---|
message | a full Message (deduped by seq) — same shape as historyیک Message کامل (تکراریها حذفشده) — همان ساختار تاریخچه |
message.updated · message.deleted | the edited / tombstoned messageپیام ویرایششده یا حذفشده |
read | { conversation_id, user_id, last_read_seq } — to all members, you includedبرای همهٔ اعضا، از جمله خودتان |
typing | { conversation_id, user_id, is_typing } |
presence | { user_id, online } |
member.added | { conversation_id, added: string[], by } |
member.removed | { conversation_id, removed, by } |
open · reconnect · close · auth.expired | connection lifecycleچرخهٔ حیات اتصال |
Status codes you should handleکدهای وضعیتی که باید مدیریت کنید
| Codeکد | Whenچه زمانی |
|---|---|
401 | token missing, expired, or the tenant is suspended → mint a fresh oneتوکن نیست، منقضی شده، یا مستأجر معلق است ← یک توکن تازه بسازید |
403 | you're not allowed — editing someone else's message, a non-admin managing a roster, a non-member fetching a fileاجازه ندارید — ویرایش پیام دیگری، مدیریت اعضا بدون نقش ادمین، یا گرفتن فایل بدون عضویت |
404 | not found or not yours — we don't distinguish, so existence isn't leakedپیدا نشد یا مالِ شما نیست — این دو را از هم جدا نمیکنیم تا وجود داشتنشان لو نرود |
400 | a semantic problem — e.g. replying to a message from another conversationمشکل معنایی — مثلاً پاسخ به پیامی از گفتگوی دیگر |
422 | the body failed validation (message too long, bad field)بدنهٔ درخواست معتبر نیست (پیام خیلی بلند، فیلد نادرست) |
429 | rate limited — back off and retryمحدودیت نرخ — کمی صبر کنید و دوباره تلاش کنید |