Posting messages
Messages are the core unit of activity in a community.
Requirements
Before posting, the user must:
- Be authenticated via Twitter OAuth (
accessTokenin hand). - Have at least one wallet linked to their account (
linkWallet). - The linked wallet must be the one specified in
walletAddress.
Post a message
await api.postMessage({
path: { token_address: '7eYw...mintAddr' },
body: {
content: 'This is my take on $TOKEN',
chainId: 'solana',
walletAddress: 'YourLinkedSolanaWallet',
},
throwOnError: true,
});Posts are processed asynchronously
This is the single most important thing to understand before you build a posting
UI. postMessage (and postReply) accept the post for processing and return
an empty 200 — there is no response body, no message id, and nothing
persisted yet:
const { data } = await api.postMessage({ path, body });
// data is undefined — the 200 only means "accepted for processing"After the 200, the backend runs the post through moderation, persists it, and
warms its caches. Only then does the message become readable via getMessages
and broadcast over the realtime socket. The post can also be rejected at this
stage (spam / harmful content), in which case it never appears in the feed.
Don't refetch on success — you'll race the backend
The naive flow — postMessage → invalidate the messages cache → refetch
getMessages — has a race condition: the refetch almost always wins, reaching
the read endpoint before the backend has finished persisting the new post.
The user posts and sees… nothing. A moment later a second, unrelated refetch
finally shows it. There is no synchronous read-after-write guarantee.
The pattern: optimistic insert + realtime reconciliation
The SDK is designed for this. The recommended flow is:
- Optimistically insert the post into your local feed the instant the
200lands, so it appears immediately. Because there's no serverid, mint a temporary client-side id (e.g.optimistic-<uuid>). - Subscribe to the community's realtime socket (see
Realtime events). The backend emits a
message_updateonce the post is persisted, and amoderation_updateif its spam/harmful flags change. - Reconcile:
- A clean
message_updatewhose content matches your pending post → drop the optimistic copy; the authoritative message arrives via a cache refetch. - A
message_update/moderation_updateflaggedisSpamorisHarmful→ remove the optimistic copy and surface a toast ("your post was removed"). - A dropped socket → refetch on the realtime
onGapsignal; persisted posts still show once the REST cache catches up.
- A clean
Because the write endpoint returns no id, you correlate the eventual
message_update to your pending post by content (the userId + content
pair), not by id. The Realtime events page shows the
SDK hook and the lower-level reconciliation loop.
The same applies to
postReply: it returns the same empty200 "accepted for processing"with no body. Replies surface asmessage_updateevents with a non-nullparentMessageId.
Attach media
Upload media first, then include the returned URL:
const { data: media } = await api.uploadCommunityMedia({
body: {
contentType: 'image/jpeg',
data: btoa(rawImageBytes), // base64
},
});
await api.postMessage({
path: { token_address: '7eYw...' },
body: {
content: 'Check this chart',
chainId: 'solana',
walletAddress: 'YourWallet',
mediaUrl: media.mediaUrl,
},
});Free-form URLs are rejected — only URLs from uploadCommunityMedia or a listed media provider are accepted.
Reply to a message
await api.postReply({
path: { token_address: '7eYw...', message_id: 'msg_123' },
body: { content: 'Agreed', chainId: 'solana', walletAddress: 'YourWallet' },
});Likes
await api.likeMessage({ path: { token_address: '7eYw...', message_id: 'msg_123' } });
await api.unlikeMessage({ path: { token_address: '7eYw...', message_id: 'msg_123' } });
const { data } = await api.getLikeCount({ path: { token_address: '7eYw...', message_id: 'msg_123' } });Report content
await api.reportMessage({
path: { token_address: '7eYw...', message_id: 'msg_123' },
body: { reason: 'spam' },
// reason: 'spam' | 'harassment' | 'hate_speech' | 'inappropriate_content' | 'other'
// when reason === 'other', include: text: 'describe the issue'
});