Realtime events
The SDK includes a WebSocket client for live community updates: new messages, like counts, and moderation outcomes. It is the other half of the posting flow — because posts are processed asynchronously, the realtime socket is how you learn the eventual status of a post you submitted (persisted, moderated, or rejected).
How it works
- Call
getWsTicketto obtain a short-lived (~30s), single-use WebSocket ticket for a community. - Pass an
authcallback that fetches a fresh ticket touseCommunityEventsorCommunityRealtimeClient. The callback runs on the initial connect and on every reconnect, so sessions survive backoff without stale tickets. - The client handles reconnection (exponential backoff + jitter) automatically.
One socket per community. A connection is scoped to a single
tokenAddressserver-side, and the SDK refcounts a single underlying socket per(baseUrl, tokenAddress)pair regardless of how many subscribers you mount.
Event types
Every event arrives as a discriminated envelope { eventType, data }. Branch on
eventType (constants live in RealtimeEventTypes).
eventType | Handler | Payload | Fired when |
|---|---|---|---|
message_update | onMessage | CommunityMessageEvent | A message is persisted (including your own async post), or its content changes |
like_update | onLike | LikeUpdatePayload | A message's like state changes |
moderation_update | onModeration | ModerationUpdatePayload | A message's isSpam / isHarmful flags change after creation |
interface CommunityMessageEvent {
id: string;
communityId: string;
businessId: string;
userId: string;
username: string;
userTwitterUrl: string;
profileImageUrl: string | null;
followerCount: number;
content: string;
mediaUrl: string | null;
isSpam: boolean;
isHarmful: boolean;
createdAt: string;
/** Non-null when the message is a reply. `null` for top-level posts. */
parentMessageId: string | null;
}
interface LikeUpdatePayload {
messageId: string;
liked: boolean;
}
interface ModerationUpdatePayload {
messageId: string;
communityId: string;
isSpam: boolean;
isHarmful: boolean;
}The message_update payload deliberately omits likeCount, replyCount, and
tokenAddress (the token address is implied by the connection). Read counts
from REST or track them via like_update.
React hook
import { api } from '@coin-communities/sdk';
import { realtime } from '@coin-communities/sdk/react';
function CommunityFeed({ tokenAddress }) {
realtime.useCommunityEvents(tokenAddress, {
auth: {
getTicket: async () => {
const { data } = await api.getWsTicket({ path: { token_address: tokenAddress } });
if (!data?.ticket) throw new Error('WebSocket ticket unavailable');
return data.ticket;
},
},
onMessage: (event) => {
// a message was persisted — reconcile against your optimistic posts
},
onModeration: (event) => {
// spam/harmful flags changed — hide the message and/or toast the author
},
onLike: (event) => {
// like state changed
},
onGap: () => {
// reconnected after a dropped connection — refetch via REST (see below)
},
});
}The hook subscribes on mount and unsubscribes on unmount. Handler callbacks are ref-wrapped so inline functions won't churn the connection.
Handling dropped connections (onGap)
If the socket drops and reconnects, events emitted during the gap are not
replayed. onGap fires exactly once per gap window, the next time the socket
successfully reconnects. Treat it as a signal to refetch from REST and
reconcile, so you don't miss a moderation outcome that landed while offline:
const queryClient = useQueryClient();
realtime.useCommunityEvents(tokenAddress, {
auth,
onGap: () => {
void queryClient.invalidateQueries({
queryKey: ['community', tokenAddress, 'messages'],
});
},
});Reconciling optimistic posts
This is the end-to-end recipe that pairs with
asynchronous posting.
After a postMessage 200, you insert the post optimistically, then let the
socket tell you whether it was persisted cleanly or rejected by moderation.
Because the write endpoint returns no id, you correlate the incoming
message_update to your pending post by content.
The React Query entry includes useOptimisticCommunityPosts, which does the
bookkeeping for you: it creates temporary Message objects, listens to realtime
events, drops or rejects matching optimistic copies, invalidates the message list
on clean resolution and socket gaps, and only surfaces later moderation updates
for messages this client authored.
import {
useMessages,
useOptimisticCommunityPosts,
usePostMessage,
} from '@coin-communities/sdk/react-query';
function CommunityFeed({ tokenAddress, auth, community, me }) {
const messages = useMessages(tokenAddress);
const optimistic = useOptimisticCommunityPosts(tokenAddress, {
auth,
communityId: community.id,
businessId: community.businessId,
author: {
userId: me.id,
username: me.username,
displayName: me.displayName,
userTwitterUrl: me.twitterUrl,
profileImageUrl: me.profileImageUrl,
followerCount: me.followerCount,
},
onReject: () => toast.error('Your post was removed by moderation.'),
onModeration: () => toast.error('Your post was removed by moderation.'),
});
const postMessage = usePostMessage(tokenAddress, {
onSuccess: (_result, body) => {
optimistic.addOptimisticPost({
content: body.content,
mediaUrl: body.mediaUrl ?? null,
});
},
});
const feed = optimistic.mergePendingPosts(messages.data?.messages ?? []);
return <Feed messages={feed} onPost={(body) => postMessage.mutate(body)} />;
}If you want to build the loop yourself instead of using the hook, keep the same five pieces:
- Store pending
Messageobjects with syntheticoptimistic-*ids. - On
postMessagesuccess, prepend a pending message with the submitted content, media URL, author, and community fields. - On
message_update, ignore replies, find a pending message whose trimmed content matches, track the real id, drop the optimistic copy, then either toast moderation rejection or invalidate['community', tokenAddress, 'messages']. - On
moderation_update, toast only when themessageIdis one of the real ids this client authored. - On
onGap, invalidate the messages query because missed events are not replayed.
When rendering, put pending posts on top of the fetched feed, deduping by content so an optimistic copy disappears the moment the real message arrives:
const visiblePending = pending.filter(
(p) => !fetched.some((m) => m.content.trim() === p.content.trim()),
);
const feed = [...visiblePending, ...fetched];Syncing the React Query cache automatically
If you just want realtime events to keep your React Query cache fresh (without
hand-writing reconciliation), wire bindCommunityEventsToQueryClient once. It
invalidates / patches the relevant cache keys on every event — message_update
invalidates the messages list, like_update patches the like count,
moderation_update invalidates filtered views, and a gap invalidates the
community subtree.
import { realtime } from '@coin-communities/sdk/react';
const dispose = realtime.bindCommunityEventsToQueryClient(queryClient, tokenAddress, {
baseUrl: 'https://api.coin-communities.xyz',
auth,
});
// Later: dispose();This pairs well with the optimistic loop above: the cache bridge handles the
refetch, while your onMessage / onModeration handlers own the optimistic
drop + moderation toast.
Low-level client
import { CommunityRealtimeClient } from '@coin-communities/sdk/react';
const client = CommunityRealtimeClient.getOrCreate({
baseUrl: 'https://api.coin-communities.xyz',
tokenAddress: '7eYw...',
auth: {
getTicket: async () => {
const { data } = await api.getWsTicket({ path: { token_address: '7eYw...' } });
if (!data?.ticket) throw new Error('WebSocket ticket unavailable');
return data.ticket;
},
},
});
const dispose = client.subscribe({
onMessage: (e) => console.log(e),
onModeration: (e) => console.log(e),
onGap: () => console.log('reconnected — refetch'),
onConnect: () => console.log('connected'),
onDisconnect: () => console.log('disconnected'),
});
// To stop receiving events from this handler:
dispose();