Skip to content

Twitter Calls protocol ​

This page documents what client.xcalls sends on the wire. It was mapped from the abs.twimg.com/x-web/xcalls bundle and confirmed against live calls.

Hosts ​

hostused for
call.x.comthe web app, an RSC app that serves an empty text/plain body unless the request looks like a browser navigation
api.x.com/graphqlevery call operation, as Relay persisted queries
api.x.com/j/a/<n>/rtcthe Hydra RTC gateway, from gw_url with /janus swapped for /rtc
a.avcall.x.comTURN servers, on UDP 3478 and TLS 443
chat-ws.x.comthe call event socket

GraphQL ​

Requests use the web bearer token, origin: https://call.x.com, and either the session cookies with x-csrf-token and x-twitter-auth-type: OAuth2Session, or a guest token in x-guest-token. Queries are GET /graphql/<id>/<name>?variables=.... Mutations are POST with { queryId, variables }. No features object and no transaction id are needed.

viewerQuery and userHoverCardBodyQuery need the Relay provided variable __relay_internal__pv__appviewerisloggedinprovider, and viewerQuery aliases viewer_v2 as viewer.

The call operations return xchat_* fields:

operationroot fieldnotes
xCallsStartCallxchat_start_callcall, media_credentials { version, payload }, media_session_id
xCallsRequestTokenxchat_request_tokengw_url, stream_jwt, room_id, ice_urls, ice_username, ice_credential
xCallsRecoverMediaxchat_recover_mediasame shape as a token
xCallsJoinCallxchat_join_callneeds publisherFeedId from a live publish
xCallsLeaveCallxchat_leave_call
xCallsRequestJoinCallxchat_request_join_calllogged-in users only
xCallsGetCallPreJoinDataxchat_get_call_pre_join_dataalso returns an auth token
xCallsGenerateAuthTokenxchat_generate_call_auth_tokenthe chat-ws token
xCallsSendCallEventxchat_send_call_eventbase64 CallEventContents
xCallsSendSignal, xCallsFetchSignalsxchat_send_signal, xchat_fetch_signalsP2P calls only

media_credentials.payload is JSON. For group calls it says media_backend: "hydra", signaling: "service", role: "publisher", plus the room, stream name, STUN servers and datacenter.

The stream JWT's sub is the viewer id. For guests it is the guest id, and the RTC gateway compares it to the guest_id cookie (v1%3A<sub>).

The Hydra RTC gateway ​

Every request carries the stream JWT in x-signaling-jwt as well as the normal Twitter auth headers. Without them the gateway answers Bad Authentication data or a bare 403.

requestbody
POST /sessions{ room_id, slots?, paging_slots? } returns { session_id }
POST /sessions/<id>/offer{ connection_id, sdp, labels, seq }
POST /sessions/<id>/answer{ connection_id, sdp, seq }
POST /sessions/<id>/subscription{ connection_id, seq, video: [{ connection_id, track_ids }] }
POST /sessions/<id>/trickle{ connection_id, seq, candidate }
POST /sessions/<id>/restart{ connection_id, seq, sdp? }
POST /sessions/<id>/pins{ pins, seq }
GET /sessions/<id>?after=<cursor>&wait=10slong poll, returns { cursor, draining, events }
DELETE /sessions/<id>leave

Posts return 202 { seq }, and the outcome arrives later as an event with the same seq and a status of succeeded or failed. The web client sends them one at a time, and so does emusks.

Each client uses two peer connections. The publisher (con_p_xxxxxxxx) offers its tracks with labels mapping each mid to audio, camera or screen, plus data channels named xchat-call-data and xcalls-state, and gets the SFU's answer in the apply result. The subscriber (con_s_xxxxxxxx) is offered by the SFU after a subscription, and the client posts the answer.

Poll events include publisher_joined and publisher_left (with connection_id, user_id and tracks), offer (with streams mapping mids to publishers), candidate, slots, connection (a peer hung up), seq_gap and events_dropped.

The SFU only accepts Opus audio and H.264 video with f, h and q simulcast layers. Any other video codec is rejected with port 0.

After the publisher is live, xCallsJoinCall with the publisher's connection_id as publisherFeedId makes the viewer a participant.

The event socket ​

wss://chat-ws.x.com/?token=<xchat_generate_call_auth_token> sends binary frames. Each frame is a Thrift binary struct. Field 4 (CallEvent) is a struct whose field 100 holds a protobuf xai.xchat_calling.v1.CallEventContents. The client sends the 9-byte frame 0c 00 02 0c 00 02 00 00 00 every 30 seconds as a keepalive.

proto
message CallEventContents {
  optional string sequence_id = 1;
  optional string conference_id = 2;
  optional int64 created_at_msec = 3;
  optional CallMember initiating_member = 4;
  optional CallEventDetail call_event_detail = 5;
}

message CallMember {
  oneof member { XUser x_user = 1; GuestUser guest_user = 2; }
}

message CallEventDetail {
  oneof detail {
    MemberJoinEvent member_join_event = 1;
    MemberLeaveEvent member_leave_event = 2;
    MemberRequestJoinEvent member_request_join_event = 3;
    MemberRequestJoinAcceptEvent member_request_join_accept_event = 4;
    MemberRequestJoinRejectEvent member_request_join_reject_event = 5;
    MemberMuteEvent member_mute_event = 6;
    MemberUnmuteEvent member_unmute_event = 7;
    ChatEvent chat_event = 8;
    MemberRaiseHandEvent member_raise_hand_event = 9;
    MemberLowerHandEvent member_lower_hand_event = 10;
    CallMetadataEvent call_metadata_event = 11;
    MemberListEvent member_list_event = 12;
    CallTypeChangeEvent call_type_change_event = 13;
    MemberCameraOnEvent member_camera_on_event = 14;
    MemberCameraOffEvent member_camera_off_event = 15;
    SubscriberOfferEvent subscriber_offer_event = 16;
    MemberScreenShareOnEvent member_screen_share_on_event = 17;
    MemberScreenShareOffEvent member_screen_share_off_event = 18;
    CallDebugEvent call_debug_event = 19;
    RtcStatsReport rtc_stats_report = 21;
  }
}

Field 22 of CallEventDetail is not in the bundled schema. The web client reads it by hand as a P2P signal { sender_session_id = 1, seq = 2, kind = 3, payload = 4, sent_at_msec = 5 }.

xCallsSendCallEvent takes the same CallEventContents, base64 encoded, with conference_id and call_event_detail set. Call types are 1 open, 2 closed and 3 trusted.

not affiliated with X Corp.