Skip to content

Messages ​

Everything here except the lookups needs a loaded identity.

Methods that take a recipient or target accept a numeric user id, a @handle, a { id } object or a conversation id. Handles are resolved once and cached. Use the conversation id (g...) for groups and for the chat with yourself.

Looking up users ​

These only need a logged-in session, no identity.

xchat.publicKey(userId) ​

A user's newest XChat identity key, or null if they aren't on XChat.

js
const key = await client.xchat.publicKey("44196397");
// { version, publicKey, signingPublicKey, identityPublicKeySignature, registrationMethod }

publicKey is the key others wrap conversation keys to, and signingPublicKey verifies the user's message signatures (see encryption).

xchat.publicKeys(userIds, opts?) ​

Profiles for one or more users in one request, in the shape profile returns. opts.includeTokens asks Twitter for the Juicebox realm token maps too.

js
const users = await client.xchat.publicKeys(["44196397", "tiagozip_"]);

xchat.profile(userId) ​

The parsed XChat profile: { userId, onXChat, isManagedPinUser, permissions, keys }.

js
const p = await client.xchat.profile("44196397");

xchat.permissions(userId) ​

The permission flags { canDm, canDmOnXChat, dmBlocking, passesPremiumCheck }. canDmOnXChat is about you: whether you can message this user over XChat.

js
const perms = await client.xchat.permissions("44196397");

xchat.canMessage(userId) ​

true when permissions().canDmOnXChat is set.

js
if (await client.xchat.canMessage("44196397")) console.log("reachable");

xchat.isOnXChat(userId) ​

Whether the user has published an XChat key.

js
await client.xchat.isOnXChat("44196397"); // true

xchat.fingerprint(key) ​

A SHA-256 fingerprint of a key, in colon-separated groups of four hex digits. Pass a key object from publicKey or a raw SPKI base64 string, then compare it with the other person out of band.

js
await client.xchat.fingerprint(await client.xchat.publicKey("44196397"));
// "8435:8166:a7fb:5c30:68dc:df90:dd47:b519:9eff:588c:c2bd:0772:36aa:c127:8e4b:b1c6"

xchat.callPermissions(userIds) ​

Whether you may call one or more users. Returns Twitter's per-user result, { can_dm, error_code }.

js
await client.xchat.callPermissions("44196397");

Sending messages ​

Every send is end-to-end encrypted and signed for you. Sends to a group reuse the group's newest conversation key. If a 1:1 chat has no key you can read, or a member's key changed, emusks creates a new one.

js
const { sequenceId } = await client.xchat.message("tiagozip_", "hey, encrypted ^w^");
await client.xchat.react("tiagozip_", sequenceId, "🔥");

Every send method returns { conversationId, recipientId, messageId, sequenceId, response } and throws when Twitter rejects the event. The error's code is Twitter's reason, such as RECIPIENT_KEY_HAS_CHANGED or TOO_MANY_MESSAGES.

xchat.message(recipient, text, opts?) ​

Sends a text message. Keep the returned sequenceId to react to, edit or delete it.

js
const sent = await client.xchat.message("44196397", "hello!");

await client.xchat.message(group.conversationId, "this one", { replyTo: sent.sequenceId });
await client.xchat.message("44196397", "look", { media: [await Bun.file("cat.jpg").bytes()] });
OptionTypeDescription
replyTostring | objectA sequence id, or a decoded message from read()
mediaarrayFiles to encrypt, upload and attach: Uint8Array, Buffer, ArrayBuffer, Blob, a file path, or { data, contentType?, filename?, width?, height? }
attachmentsarrayAttachments you already uploaded with uploadMedia, or post, url and card attachments
ttlMsecnumberDisappearing-message lifetime
shouldNotifybooleanWhether the recipient gets a notification. Default true.
messageIdstringYour own message id. Default is a random UUID.

A reply carries a preview of the original (sender, text, attachments, sequence id and message id), which other clients show above the reply. When replyTo is a sequence id, emusks fetches the original with events() first. With replyTo, media or attachments, the result also has attachments and replyTo.

xchat.uploadMedia(target, file, opts?) ​

Encrypts a file with the conversation key and uploads it in 1 MiB parts, the way the web client does. It returns a media attachment (mediaHashKey, mediaType, width, height, size, filename, attachmentId, messageId, ...) to pass to message() in attachments. Twitter ties the upload to a message id, so pass the same opts.messageId to both calls. message() with media does all of this for you.

js
const messageId = crypto.randomUUID();
const photo = await client.xchat.uploadMedia("44196397", "./cat.png", { messageId });
await client.xchat.message("44196397", "", { messageId, attachments: [photo] });

emusks reads image dimensions from PNG, JPEG, GIF and WebP headers. Pass width and height in the file object for anything else.

xchat.react(recipient, messageSequenceId, emoji, opts?) ​

Reacts to a message. opts.attachmentId targets one attachment of the message.

js
await client.xchat.react("44196397", sequenceId, "🔥");

xchat.unreact(recipient, messageSequenceId, emoji, opts?) ​

Removes a reaction you added.

js
await client.xchat.unreact("44196397", sequenceId, "🔥");

xchat.edit(recipient, messageSequenceId, newText, opts?) ​

Edits one of your messages.

js
await client.xchat.edit("44196397", sequenceId, "edited text");

xchat.markRead(recipient, sequenceId, opts?) ​

Marks a conversation read up to a sequence id. opts.seenAtMillis sets the timestamp, which defaults to now.

js
await client.xchat.markRead("44196397", sequenceId);

xchat.markUnread(recipient, sequenceId, opts?) ​

Marks a conversation unread from a sequence id.

js
await client.xchat.markUnread("44196397", sequenceId);

xchat.deleteMessages(recipient, sequenceIds, opts?) ​

Deletes one or more messages from your own view. opts.forEveryone: true unsends them for everyone, which needs a loaded identity to sign the request. Returns Twitter's { error_code } result.

js
await client.xchat.deleteMessages("44196397", [sequenceId]);
await client.xchat.deleteMessages("44196397", [sequenceId], { forEveryone: true });

xchat.typing(recipient, opts?) ​

Sends a signed typing indicator over the chat WebSocket. emusks opens a socket with socket() on first use and reuses it, or you can pass your own as opts.socket. opts.activity defaults to "typing".

js
await client.xchat.typing("44196397");

Full example ​

js
import Emusks from "emusks";
import { existsSync, readFileSync, writeFileSync } from "node:fs";

const client = new Emusks();
await client.login("your_auth_token");

if (existsSync("xchat-identity.json")) {
  await client.xchat.loadIdentity(JSON.parse(readFileSync("xchat-identity.json", "utf8")));
} else {
  writeFileSync("xchat-identity.json", JSON.stringify(await client.xchat.createIdentity({ pin: "2468" })));
}

if (await client.xchat.canMessage("tiagozip_")) {
  const { sequenceId } = await client.xchat.message("tiagozip_", "I am Charlie Kirk");
  await client.xchat.react("tiagozip_", sequenceId, "✅");
}

Reading messages ​

Reading decrypts with your private key, so it needs a loaded identity.

js
for (const convo of await client.xchat.conversations()) {
  console.log(convo.conversationId, convo.type, convo.groupName, convo.latestMessage?.text);
}

const { messages } = await client.xchat.read("tiagozip_");
for (const m of messages) {
  if (m.kind === "message") console.log(m.senderId, m.text, m.attachments.length);
}

xchat.inbox(opts?) ​

One page of the inbox, newest first. Returns { conversations, cursor, requestsCount }. Pass cursor back as opts.cursor for the next page. It is null on the last page. requestsCount is the number of pending message requests.

js
let page = await client.xchat.inbox();
while (page.cursor) page = await client.xchat.inbox({ cursor: page.cursor });

Each conversation has:

FieldDescription
conversationIdminId:maxId for 1:1 chats, g<digits> for groups
type"direct" or "group"
selftrue for the chat with yourself (id:id)
participantsMember user ids. For 1:1 chats this is the other person, or just you in the self chat.
groupNameGroup name, decrypted when the group encrypts it
groupNameSealedtrue when the name is encrypted with a key you don't have
avatarUrlGroup avatar. Encrypted groups store it on ton.x.com; fetch it with media().
adminIds, removedIdsGroup admins and removed members
createdAt, ttlMs, inviteUrlGroup creation time, disappearing-message lifetime, invite link
isMutedWhether you muted it
deletedWhether you deleted it
latestSequenceIdSequence id of the newest event
latestMessageThe newest decoded event, of any kind
messagesEvery decoded event the inbox returned for it, oldest first
reads{ userId: seenUntilSequenceId } read receipts
lastReadSequenceIdYour own read position
unreadtrue when someone else sent a message after your read position

xchat.conversations(opts?) ​

The conversations array of one inbox page. Same as (await inbox(opts)).conversations.

js
const convos = await client.xchat.conversations();

xchat.read(target, opts?) ​

Reads and decrypts one page of a conversation. Pass opts.before (a sequence id) to page into older history. Returns { conversationId, type, hasMore, requestState, messages, keys }.

js
const group = (await client.xchat.conversations()).find((c) => c.type === "group");
const { messages, hasMore } = await client.xchat.read(group.conversationId);

Every message has sequenceId, messageId, senderId, conversationId, createdAt (milliseconds, as a string) and kind. Encrypted events also have keyVersion. When the key is missing the entry has encrypted: true, and when decryption fails it has decryptError: true.

kindExtra fields
messagetext, entities, attachments, replyTo, forwarded, sentFrom, ctas
reaction_add, reaction_removetargetSequenceId, emoji, targetAttachmentId
edittargetSequenceId, text, entities
deletesequenceIds, forEveryone
read, unreadseenUntilSequenceId, seenAt
mark_read, mark_unreadthe same, sent as an encrypted entry by some clients
av_call_started, av_call_ended, av_call_missedaudioOnly, broadcastId, durationSeconds
accept_requestnone. The other side accepted your message request.
nicknameuserId, nickname
drafttext, a draft synced from your other devices
screen_capturecaptureType
pin_conversation, unpin_conversationtargetConversationId
user_metadatapinnedConversationIds, nicknames, verifiedUserIds (only in the self chat)
conversation_key_changekeyVersion, participantIds
metadata_changechange (mute, unmute, ttl_change, screen_capture_blocking_on, ...), ttlMs
group_creatememberIds, adminIds, title, avatarUrl
group_member_addmemberIds, currentMemberIds, currentAdminIds, title, createdBy
group_member_removememberIds
group_admin_add, group_admin_removeadminIds
group_renametitle, titleSealed
group_avataravatarUrl
group_descriptiondescription
group_invite_enable, group_invite_disable, group_join_request, group_join_rejectinvite details
member_account_deletememberId
typing, failure, conversation_delete, pin_message, unpin_message, grok_responseevent details

Attachments in m.attachments:

typeFields
mediamediaType (image, gif, video, audio, file, svg), mediaHashKey, width, height, durationMs, size, filename, attachmentId, encrypted, legacyUrl
postpostId, url
urlurl, title, banner, favicon
cardurl
money, jetfuel, payment_receipt, botraw details

replyTo is the preview the sender embedded: { sequenceId, messageId, senderId, senderName, text, attachments, attachmentId, call }. entities are { start, end, type } ranges, where type is url, mention, hashtag, cashtag, email, address or phone.

TIP

Like the official client, Twitter doesn't send your own new messages back to a fresh client right away. Keep the results of message() if you need your outgoing history immediately.

xchat.events(sequenceIds) ​

Fetches and decrypts specific events by sequence id, from any conversation. Returns { messages, notFound }.

js
const { messages } = await client.xchat.events([m.replyTo.sequenceId]);

xchat.media(target, attachment, opts?) ​

Downloads an attachment and decrypts it. attachment is a decoded media attachment or a URL string, such as a group's avatarUrl. Pass the message's keyVersion on the attachment or as opts.keyVersion so emusks tries that key first. Returns { bytes, contentType, size, encrypted }.

js
const m = messages.find((m) => m.attachments?.some((a) => a.type === "media"));
const photo = m.attachments.find((a) => a.type === "media");
const { bytes, contentType } = await client.xchat.media(m.conversationId, { ...photo, keyVersion: m.keyVersion });

Media migrated from legacy DMs has a legacyUrl and is fetched without decryption.

Conversations ​

xchat.pin(recipient, opts?) ​

Pins a conversation. Pins sync across your own devices.

js
await client.xchat.pin("44196397");

xchat.unpin(recipient, opts?) ​

Unpins a conversation.

js
await client.xchat.unpin("44196397");

xchat.setNickname(recipient, nickname, opts?) ​

Sets a nickname inside a conversation. It names the recipient unless you pass opts.userId to name another participant.

js
await client.xchat.setNickname("44196397", "the boss");

xchat.reportScreenCapture(recipient, opts?) ​

Sends the "screenshot detected" notice the official app sends when you screenshot a chat. opts.recording: true reports a screen recording instead. Idk why you'd need this but it's here.

js
await client.xchat.reportScreenCapture("44196397");

Message requests ​

Chats from people you don't follow start as message requests.

xchat.requests(opts?) ​

One page of pending requests. Returns { requests, total, cursor }. Pass cursor back as opts.cursor for more. opts.type narrows the list to "YouMayKnow" or "LowQuality".

js
const { requests, total } = await client.xchat.requests();
FieldDescription
conversationIdThe conversation the request belongs to
type"direct" or "group"
requestTypeTwitter's bucket for it, such as YouMayKnow
participantsWho is asking, from the conversation id and the message senders
latestMessage, messagesDecoded events, in the same shape as read()
socialProof{ id, name, screenName, avatar } of people you follow who know them

xchat.acceptRequest(target, opts?) ​

Accepts a request. If the conversation has no key you can read yet, emusks adds a new one in the same call. For group requests, pass opts.userId, the id of whoever added you.

js
await client.xchat.acceptRequest(requests[0].conversationId);

xchat.declineRequest(target) ​

Declines a request by deleting the conversation with a signed delete event.

js
await client.xchat.declineRequest(requests[0].conversationId);

not affiliated with X Corp.