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

  1. Call getWsTicket to obtain a short-lived (~30s), single-use WebSocket ticket for a community.
  2. Pass an auth callback that fetches a fresh ticket to useCommunityEvents or CommunityRealtimeClient. The callback runs on the initial connect and on every reconnect, so sessions survive backoff without stale tickets.
  3. The client handles reconnection (exponential backoff + jitter) automatically.

One socket per community. A connection is scoped to a single tokenAddress server-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).

eventTypeHandlerPayloadFired when
message_updateonMessageCommunityMessageEventA message is persisted (including your own async post), or its content changes
like_updateonLikeLikeUpdatePayloadA message's like state changes
moderation_updateonModerationModerationUpdatePayloadA 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:

  1. Store pending Message objects with synthetic optimistic-* ids.
  2. On postMessage success, prepend a pending message with the submitted content, media URL, author, and community fields.
  3. 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'].
  4. On moderation_update, toast only when the messageId is one of the real ids this client authored.
  5. 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();