Skip to content

Instantly share code, notes, and snippets.

@0xdevalias
Last active March 17, 2026 04:29
Show Gist options
  • Select an option

  • Save 0xdevalias/8885b10795eb3267b703ed5943087953 to your computer and use it in GitHub Desktop.

Select an option

Save 0xdevalias/8885b10795eb3267b703ed5943087953 to your computer and use it in GitHub Desktop.
Notes on API/userscript to improve Twitter 'Notifications Timeline'

Notes on API/userscript to improve Twitter 'Notifications Timeline'

Table of Contents

Questions

  • Can we get a 'peek notifications' userscript without triggering marking them read?

Tools/Libraries/Etc

Tweets

NotificationsTimeline API Deep Dive and Pagination Cursor / ID Structure Decoding (Dec 2025)

Analysis of X's NotificationsTimeline GraphQL API, including request parameters, instruction types, entry and cursor structures, and pagination behaviour. Covers cursor/ID encoding (Base64 / Apache Thrift) and shows that older notifications appear to be unretrievable via the API beyond a cutoff, with notes on unread-state mutation instructions and limited recovery via advanced search.

  • https://x.com/_devalias/status/2003233313339900130
    • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

      I didn't realise there was a cap on how far back twitter notifications go...

      Guess I shouldn't have ignored looking through / triaging them for so long 😅

      October 10th seems to be my oldest.

    • https://x.com/_devalias/status/2003237160418410999
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        I wonder if this is just another UI limitation thing, and the raw requests would actually let me keep 'scrolling'.

        @sucralose__ I feel like you might have explored something adjacent to this at one point; did you know off hand if it's possible by chance?

    • https://x.com/_devalias/status/2003240898059223269
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Looks like the main GraphQL API request is to:

        https://x.com/i/api/graphql/Ev6UMJRROInk_RMH2oVbBg/NotificationsTimeline?variable...&features=...
        

        With the variables param set to:

        {
          "timeline_type": "All",
          "count": 20
        }

        (The features param is also a large encoded JSON object, but less directly relevant here.)

    • https://x.com/_devalias/status/2003244816260645126
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        That returns a deep JSON object structure; where the most interesting parts (for this use case at least) seem to be at:

        .data.viewer_v2.user_results.result.notification_timeline.timeline.instructions[]
        
    • https://x.com/_devalias/status/2003245626012893338
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Within that array, there seem to be a bunch of different object types, such as:

        TimelineClearCache TimelineRemoveEntries (entry_ids: []) TimelineAddEntries (entries: []) TimelineClearEntriesUnreadState TimelineMarkEntriesUnreadGreaterThanSortIndex (sort_index: "")

    • https://x.com/_devalias/status/2003247614649860534
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        In TimelineAddEntries's .entries, we can see that each entry is an object with the shape:

        {
          "entryId": "",
          "sortIndex": "",
          "content": {}
        }

        Where entryId has shapes like:

        cursor-top-[sortIndex-timestamp] notification-[id] cursor-bottom-[sortIndex-timestamp]

    • https://x.com/_devalias/status/2003251212230246907
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        For the cursor-[top/bottom]-[sortIndex] entries, their content object looks like:

        {
          "entryType": "TimelineTimelineCursor",
          "__typename": "TimelineTimelineCursor",
          "value": "",
          "cursorType": ""
        }

        Where cursorType is one of Top / Bottom, and value is the cursor ID.

    • https://x.com/_devalias/status/2003275081490075953
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        While it's probably not super relevant, the cursor value doesn't seem to be fully opaque; it appears to be Base64 encoded bytes that seem to be Apache Thrift struct format.

        https://thrift.apache.org/docs/types

    • https://x.com/_devalias/status/2003275987283509537
    • https://x.com/_devalias/status/2003276696259383746
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        For our needs here, we probably don't need to bother going much deeper at this stage; though I will note that the i64 in field 1 appears to match the .data.viewer_v2.user_results.result.rest_id value.

    • https://x.com/_devalias/status/2003281283544416517
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Also the .data.viewer_v2.user_results.result.notification_timeline.id appears to be a Base64 encoded string prefix `Timeline:``, followed by Apache Thrift struct bytes that contains 2 fields:

        • i64 (8 bytes) matching my user rest_id
        • i32 (4 bytes) containing the number 1
    • https://x.com/_devalias/status/2003287594646528158
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Climbing out of that rabbit hole.. back to trying the most basic thing, which is to just take the bottom cursor and query for more notification entries with that; we unfortunately seem to get an essentially empty result, which only includes a cursor-top-[sortIndex] entry.

    • https://x.com/_devalias/status/2003287893884948891
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        So it looks like, at least based on the above research, as far as accessing via this API goes; those older notifications are likely lost forever :(

        Guess that is incentive for me to check more often... 😅

    • https://x.com/_devalias/status/2003289523879575825
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Though something else I did notice is that the 'bottom' cursor I used, and the 'top' cursor I received in that empty response only differed by the Apache Thrift field ID.

        • 1 (DAAB...) is a 'Top' cursor
        • 2 (DAAC...) is a 'Bottom' cursor
    • https://x.com/_devalias/status/2003290867835285632
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Also worth noting for future is the .data.viewer_v2.user_results.result.notification_timeline.timeline.instructions[] types:

        TimelineClearEntriesUnreadState TimelineMarkEntriesUnreadGreaterThanSortIndex

        As everything getting marked read when I open notifications is annoying.

    • https://x.com/_devalias/status/2003291231129072099
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        There'll no doubt be corresponding API calls triggered based on that that actually updates the 'unread' status on the backend; but that can be a future distraction to explore.

    • https://x.com/_devalias/status/2003295675518447966
      • Glenn ‘devalias’ Grant @_devalias (Dec 23, 2025)

        Back to the 'lost' notifications, I can use advanced search to attempt to figure out which where replies I never got back to at least:

        (to:_devalias) (-from:_devalias) since:2025-01-01 until:2025-12-31
        

        And then click onto the 'Latest' tab to get chronological ordering.

Notification Deep Dive and React-Based Tweet Counting (May 2025)

A spontaneous attempt to catch up on two weeks of notifications leads into a DOM and React fibre deep-dive to extract and count tweet data, quantify the backlog, and lay groundwork for future tooling.

Ideas for Improving Twitter Notification UX (Dec 2024)

Suggestions for features like unread tracking, author grouping, and timeline recall for accounts marked “notify on post.” Includes related gists and prior threads.

Manual Notification Triage and Workflow Notes (May 2024 – Dec 2024)

Walkthrough of the manual backlog review process, challenges of staying on top of notifications, and efforts to batch/sort tweet URLs for focused review.

Gists for Workflow Optimization and Sorting (May 2024)

Links and commentary on scripts for sorting tweet URLs and improving the triage workflow.

Twitter API Limitations and Browser-Based Alternatives (Apr 2024)

Discussion with @sucralose_ on API restrictions and fallback approaches using browser scripting and frontend scraping._

  • https://x.com/sucralose__/status/1783665885305061783
    • Michael Skyba @sucralose__ Apr 26, 2024 Wait can you not fetch Tweets at all in the Free tier? Elon why

    • https://x.com/_devalias/status/1783675392488034540
      • Glenn 'devalias' Grant @_devalias Apr 26, 2024 Yeah, the Twitter API got butchered.. it's sad; so much potential, but for a random independent dev hacking on a personal project, there's just no way that it's worth that kind of $$

    • https://x.com/sucralose__/status/1783678183092392316
      • Michael Skyba @sucralose__ Apr 26, 2024 Fortunately the frontend seems pretty simple in how it gets its posts. Not sure if I want to waste an account getting banned by trying it though

    • https://x.com/_devalias/status/1783678936712400983
      • Glenn 'devalias' Grant @_devalias Apr 26, 2024 Yeah, that was kind of my view as well. Depends on the project idea, some I can probably achieve with browser userscripts (so that'll probably be the approach I take); but the fact that the API isn't available just sort of puts a damper on my mind coming up with other cool ideas

Twitter Blue vs Building a Custom ‘Top Articles’ Tool (Jul 2022)

Considering whether to pay for Twitter Blue or recreate its link aggregation feature using the Twitter API and custom tooling.

Unsorted

Timeline Notes 1 (Dec 11, 2023)

On Twitter, from my notifications page:

When I click on the 'New post notifications for...' section, I am taken to a new page:

As part of this page, an API call is made to fetch the tweets to be shown:

This returns JSON data, from which we can extract the Tweets, and sort them by post time with something like jq:

pbpaste | jq '.globalObjects.tweets | to_entries | sort_by(.value.created_at | strptime("%a %b %d %H:%M:%S %z %Y")) | reverse | from_entries' | subl

This data will then look something like this:

{
  "1734010714510049396": {
    "created_at": "Mon Dec 11 00:42:25 +0000 2023",
    "id": 1734010714510049300,
    "id_str": "1734010714510049396",
    "full_text": "Don’t be S-A-D! https://t.co/fkHsvfwuGG",
    "truncated": false,
    "display_text_range": [
      0,
      15
    ],
    "entities": { /* ..snip.. */ },
    "extended_entities": { /* ..snip.. */ },
    "source": "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>",
    "in_reply_to_status_id": null,
    "in_reply_to_status_id_str": null,
    "in_reply_to_user_id": null,
    "in_reply_to_user_id_str": null,
    "in_reply_to_screen_name": null,
    "user_id": 44196397,
    "user_id_str": "44196397",
    "geo": null,
    "coordinates": null,
    "place": null,
    "contributors": null,
    "is_quote_status": false,
    "retweet_count": 5203,
    "favorite_count": 56940,
    "reply_count": 3883,
    "quote_count": 410,
    "conversation_id": 1734010714510049300,
    "conversation_id_str": "1734010714510049396",
    "conversation_muted": false,
    "favorited": false,
    "retweeted": false,
    "possibly_sensitive": false,
    "lang": "en",
    "ext": {
      "superFollowMetadata": {
        "r": {
          "ok": {}
        },
        "ttl": -1
      }
    }
  },
  // ..snip..
}

From that info (and matching against the DOM itself), it should be possible to implement a userscript/similar that can:

  • mark where the last tweet I saw was (both automatically, and manually)
  • hide/dim tweets older than that
  • tell me how many tweets there are till I am 'caught up'
  • etc

Timeline Notes 2 (Dec 22, 2024)

(ChatGPT Ref (private): https://chatgpt.com/c/67679a82-16fc-8008-8cbe-9c932f4cdfab)

On Twitter, from my notifications page:

When I click on the 'New post notifications for...' section, I am taken to a new page:

In the source of that page there is a script tag with a window.__INITIAL_STATE__ object containing lots of settings config type things. Among these are some keys mentioning graphql, including:

{
  // ..snip..
  graphql_is_translatable_rweb_tweet_is_translatable_enabled: {
    value: true,
  },
  graphql_mutation_retweet_mode: { value: 'rest_only' },
  graphql_mutation_update_mode: { value: 'graphql_only' },
  graphql_timeline_v2_bookmark_timeline: { value: true },
  graphql_timeline_v2_user_favorites_by_time_timeline: {
    value: true,
  },
  graphql_timeline_v2_user_media_timeline: { value: true },
  graphql_timeline_v2_user_profile_timeline: { value: true },
  graphql_timeline_v2_user_profile_with_replies_timeline: {
    value: true,
  },
  // ..snip..
}

There are also additional script tags loading various bundles:

In Chrome DevTools, using the 'Search' panel, we can search across the source files for graphql_timeline_v2_user_profile_timeline, which gives an interesting looking hit in the 'main' bundle:

  • https://abs.twimg.com/responsive-web/client-web/main.e541178a.js
    • fetchUserTweets: ({count: d, cursor: t, userId: a}) => e.graphQL(M(), {
        userId: a,
        count: d,
        cursor: t,
        includePromotedContent: !0,
        withQuickPromoteEligibilityTweetFields: !0,
        ...(0,
        r.d)(n),
        withVoice: n.isTrue("voice_consumption_enabled"),
        withV2Timeline: n.isTrue("graphql_timeline_v2_user_profile_timeline")
      }, f, k(n)).then((e => {
        let n = y.cY;
        return e?.user?.result && "User" === e.user.result.__typename && (n = e.user.result.timeline?.timeline || e.user.result.timeline_v2?.timeline || y.cY),
        n
      }
      )),
    • Also within that same module are similar functions:
      • fetchUserTweets
      • fetchUserTweetsAndReplies
      • fetchUserSuperFollowsTweets
      • fetchUserHighlightsTweets
      • fetchUserArticlesTweets
      • fetchUserPromotableTweets
      • fetchLikes
      • fetchUserMedia
      • fetchBusinessProfileTeam

This could be an interesting place to look deeper. Also, of particular note is e.graphQL, which is likely the main GraphQL client, and could lead to us being able to find and/or monitor other interesting points of data fetching. In that same module, we can see that e is destructured from a named parameter apiClient:

, v = ({apiClient: e, featureSwitches: n}) => ({

If we search for apiClient across the code, we get a LOT of hits across many different modules including:

  • https://abs.twimg.com/responsive-web/client-web/main.e541178a.js
    • requestGuestToken: e.post("guest/activate"
    • requestSsoInitToken: e.post("onboarding/sso_init"
    • logout: e.post("account/logout"
    • cleanupMulti: e.post("account/multi/clean"
    • list: e.get("account/multi/list"
    • add: e.post("account/multi/add"
    • switch: e.post("account/multi/switch"
    • logoutAll: e.post("account/multi/logout_all"
    • authenticatePeriscope
    • fetchBadgeCount: e.getURT("badge_count/badge_count"
    • fetchTweetDetail
    • acceptConversation: e.post("dm/conversation/
    • addParticipants: e.post("dm/conversation/
    • addWelcomeMessageToConversation: e.post("dm/welcome_messages/add_to_conversation"
    • deleteConversations: e.post("dm/conversation/bulk_delete"
    • editMessage: e.post("dm/edit"
    • fetchConversation: e.get("dm/conversation/
    • fetchConversationFromParticipants: e.get("dm/conversation"
    • fetchPermissions: e.get("dm/permissions"
    • fetchSecretPermissions: e.get("dm/permissions/secret"
    • fetchUserInbox: e.get("dm/inbox_initial_state"
    • fetchInboxHistory: e.get("dm/inbox_timeline/
    • fetchUserUpdates: e.get("dm/user_updates"
    • leaveConversation: e.post("dm/conversation/
    • search: e.post("dm/search/query"
    • sendMessage: e.post(k
    • markRead: e.post("dm/conversation/
    • reportSpam: e.post("direct_messages/report_spam"
    • reportDSA: e.post("dm/report"
    • updateConversationAvatar: e.post("dm/conversation/
    • updateLastSeenEventId: e.post("dm/update_last_seen_event_id"
    • updateConversationName: e.post("dm/conversation/
    • disableNotifications: e.post("dm/conversation/
    • enableNotifications: e.post("dm/conversation/
    • updateMentionNotificationsSetting: e.post("dm/conversation/
    • updateTyping: e.post("dm/conversation/
    • muteDMUser: e.post("/dm/user/update_relationship_state"
    • fetch: e.get("help/settings"
    • fetchLanguages: e.get("help/languages"
    • fetchFollowersYouFollow: e.get("friends/following/list"
    • fetchPendingFollowers: e.get("friendships/incoming"
    • fetchFollowing: e.get("friends/list"
    • acceptPendingFollower: e.post("friendships/accept"
    • declinePendingFollower: e.post("friendships/deny"
    • updateFriendship: e.post("friendships/update"
    • createAllFriendships: e.post("friendships/create_all"
    • destroyAllFriendships: e.post("friendships/destroy_all"
    • fetchBlockedAccountsImportedGraphql
    • fetchBlockedAccountsAllGraphql
    • fetchFollowers
    • fetchFollowersYouKnow
    • fetchFollowing
    • fetchSuperFollowers
    • fetchCreatorSubscriptions
    • fetchCreatorSubscribers
    • fetchVerifiedFollowers
    • fetchModeratedTimeline
    • fetchMutedAccounts
    • fetchGenericTimelineById
    • fetchHome
    • fetchHomeLatest
    • fetchHomeCreatorSubscriptions
    • clientEvent: e.post("jot/client_event"
    • errorLog: e.post("jot/error_log"
    • externalReferer: e.post("jot/external_referer"
    • fetchLiveEventMetadata: e.get("live_event/1
    • updateRemindMeSubscription: e.post("live_event/1
    • task: e.post("onboarding/task"
    • syncContacts: e.post("onboarding/contacts_authorize"
    • getContactsImportStatus: e.get("onboarding/contacts_import_status"
    • getVerificationStatus: e.get("onboarding/verification_status"
    • callInteractiveSpinnerPath: e.get
    • callOnboardingPath: e.post("onboarding/
    • referer: e.post("onboarding/referrer"
    • removeContacts: e.post("contacts/destroy/all"
    • setUserPwaLaunched: e.put("strato/column/User/
    • verifyUserIdentifier: e.post("onboarding/begin_verification"
    • verificationLink: e.post("onboarding/verify"
    • getBrowsableNuxRecommendations: e.post("onboarding/fetch_user_recommendations"
    • fetchUserTweets
    • fetchUserTweetsAndReplies
    • fetchUserSuperFollowsTweets
    • fetchUserHighlightsTweets
    • fetchUserArticlesTweets
    • fetchUserPromotableTweets
    • fetchLikes
    • fetchUserMedia
    • fetchBusinessProfileTeam
    • log: e.post("promoted_content/log"
    • fetch: e.get("account/settings"
    • fetchRateLimits: e.get("application/rate_limit_status"
    • fetchHashflags: e.get("hashflags"
    • update: e.post("account/settings"
    • deleteSSOConnection: e.post("sso/delete_connection"
    • deleteLocationData: e.post("geo/delete_location_data"
    • deleteContacts: e.post("contacts/destroy/all"
    • fetchNotificationFilters: e.get("mutes/advanced_filters"
    • updateNotificationFilters: e.post("mutes/advanced_filters"
    • updateProfile: e.post("account/update_profile"
    • removeProfileBanner: e.post("account/remove_profile_banner"
    • updateProfileAvatar: e.post("account/update_profile_image"
    • updateProfileBanner: e.post("account/update_profile_banner"
    • fetchPlaceTrendSettings: e.getURT("guide/get_explore_settings"
    • updatePlaceTrendSettings: e.postURT("guide/set_explore_settings"
    • usernameAvailable: e.dispatch({ path: "/i/users/username_available.json"
    • fetchApplications: e.get("oauth/list"
    • revokeApplication: e.post("oauth/revoke"
    • revokeOauth2Token: e.postUnversioned("/2/oauth2/revoke_token_hash"
    • changePassword: e.postI("account/change_password"
    • deactivate: e.post("account/deactivate"
    • fetchWoeTrendsLocations: e.get("trends/available"
    • fetchPlaceTrendsLocations: e.getURT("guide/explore_locations_with_auto_complete"
    • fetchLoginVerificationSettings: e.get("strato/column/User/{t}/account-security/twoFactorAuthSettings2"
    • fetchBackupCode: e.get("account/backup_code"
    • fetchNewBackupCode: e.post("account/backup_code"
    • fetchTemporaryPassword: e.post("account/login_verification/temporary_password"
    • fetchEncryptedDMsPublicKeysAndDevices: e.get("keyregistry/extract_public_keys/{d}"
    • deregisterDevice: e.delete("keyregistry/delete/{n.registrationToken}"
    • fetchSessions
    • fetchUserPreferences
    • enableVerifiedPhoneLabel
    • disableVerifiedPhoneLabel
    • fetchUserProfilePhoneState
    • revokeSession: e.postUnversioned("/account/sessions/revoke"
    • revokeAllSessions: e.postUnversioned("/account/sessions/revoke_all"
    • enrollIn2FA: e.post("bouncer/opt_in"
    • disable2FA: e.delete("account/login_verification_enrollment"
    • disable2FAMethod: e.post("account/login_verification/remove_method"
    • rename2FASecurityKey: e.post("account/login_verification/rename_security_key_method"
    • verifyPassword: e.post("account/verify_password"
    • fetchAltTextPromptPreference
    • updateAltTextPromptPreference
    • fetchDataUsageSettings
    • updateDataUsageSettings
    • updateDmNsfwMediaFilter
    • updateSharingAudiospacesListeningDataWithFollowers
    • fetchTopicLandingPage
    • fetchTopicsManagementPage
    • fetchOneTopic
    • fetchTopicsPickerPage
    • fetchViewingOtherUsersTopicsPagePage
    • follow
    • unfollow
    • notInterested
    • undoNotInterested
    • fetchTopicsToFollowSidebar
    • n.post("statuses/update"
    • fetch
    • fetchMultiple
    • show
    • fetchTranslation
    • mute
    • unmute
    • like
    • unlike
    • retweet
    • unretweet
    • bookmark
    • unbookmark
    • highlight
    • undoHighlight
    • pin
    • unpin
    • hideReplyGraphQL
    • unhideReplyGraphQL
    • removeTag
    • sendTweet
    • destroy
    • changeConversationControls
    • removeConversationControls
    • unmentionUserFromConversation
    • fetch
    • fetchCommunityInviteUsers
    • fetchCommunityMembers
    • fetchPaymentsUsers
    • prefetchUsers
    • fetchExplore
    • fetchExploreGraphQL
    • fetchTrendHistory
    • fetchExploreSidebarGraphQL
    • fetchTrendRelevantUsersGraphQL
    • fetchGlobalCommunitiesLatestPostSearch
    • fetchGlobalCommunitiesPostSearch
    • fetchExploreTopic
    • fetchGeneric
    • fetchBookmarkSearch
    • fetchListSearch
    • fetchLiveEventTimeline
    • fetchNotifications
    • fetchNotificationsUnreadCount
    • fetchNUXUserRecommendations
    • fetchRichConnectTimeline
    • fetchRichSuggestedTimeline
    • fetchSearch
    • fetchSearchGraphQL
    • fetchSimilarPosts
    • fetchReactiveInstructions
    • fetchTestFixtures
    • fetchTestGraphqlFixtures
    • fetchUserMoments
    • postCustomEndpoint
    • submitTimelinesFeedback
    • updateNotificationsLastSeenCursor
    • fetchUsers
    • follow
    • unfollow
    • cancelPendingFollow
    • block
    • unblock
    • dmBlock
    • dmUnblock
    • mute
    • unmute
    • fetchProfileTranslation
    • fetchViewer
    • fetchOneUserByScreenName
    • fetchOneUser
    • fetchUsers
    • removeFollower
    • createLocalId: e.postUnversioned("/12/measurement/dcm_local_id"
    • post
    • fetch
    • fetchDevicePermissionsState: e.get("strato/column/None/${r}"
    • fetchInfo: e.get("users/email_phone_info"
    • resendConfirmationEmail: e.post("account/resend_confirmation_email"
    • removeDevice: e.post("device/unregister"
    • updateDevicePermissionsState: e.put("strato/column/None/${r}"
    • getNotificationSettingsLogin: e.post("notifications/settings/login"
    • getNotificationSettings: e.post("notifications/settings/checkin"
    • updateNotificationSettings: e.post("notifications/settings/save"
    • removePushDevices: e.post("notifications/settings/logout"
    • putClientEducationFlag
    • fetchPreferences: e.get("account/personalization/p13n_preferences")
    • updatePreferences: e.post("account/personalization/p13n_preferences")
    • fetchData: e.get("account/personalization/p13n_data")
    • fetchTwitterInterests: e.get("account/personalization/twitter_interests")
    • fetchPartnerInterests: e.get("account/personalization/partner_interests")
    • createAudienceDownload: e.post("account/personalization/email_advertiser_list")
    • createDataDownload: e.post("account/personalization/email_your_data")
    • updateCookies: e.get("account/personalization/set_optout_cookies")
    • syncSettings: e.post("account/personalization/sync_optout_settings")
    • fetchPinnedTimelines
    • pinTimeline
    • unpinTimeline
    • fetchUserClaims
  • https://abs.twimg.com/responsive-web/client-web/bundle.Grok.2fe94cba.js
    • TODO: add summary of specific API names/endpoints here
  • https://abs.twimg.com/responsive-web/client-web/loader.AppModules.9e1a436a.js
    • updateSubscriptions: e.post("live_pipeline/update_subscriptions", o, {}, a, "")
    • metadataCreate: e.post("media/metadata/create"
    • attachSubtitles: e.post("media/subtitles/create"
  • https://abs.twimg.com/responsive-web/client-web/loader.DMDrawer.bb6db51a.js
    • bookmarkTweetToFolder
    • createBookmarkFolder
    • deleteAll
    • deleteBookmarkFolder
    • editBookmarkFolder
    • removeTweetFromBookmarkFolder
    • fetchBookmarksTimeline
    • fetchBookmarkFolderTimeline
    • fetchBookmarkFoldersSlice
  • https://abs.twimg.com/responsive-web/client-web/loader.HWCard.1d1419aa.js
    • bookmarkTweetToFolder
    • createBookmarkFolder
    • deleteAll
    • deleteBookmarkFolder
    • editBookmarkFolder
    • removeTweetFromBookmarkFolder
    • fetchBookmarksTimeline
    • fetchBookmarkFolderTimeline
    • fetchBookmarkFoldersSlice
  • https://abs.twimg.com/responsive-web/client-web/loader.SideNav.ed72f65a.js
    • metadataCreate: e.post("media/metadata/create"
    • attachSubtitles: e.post("media/subtitles/create"
  • https://abs.twimg.com/responsive-web/client-web/loader.TimelineCardHandler.f5e9e45a.js
    • bookmarkTweetToFolder
    • createBookmarkFolder
    • deleteAll
    • deleteBookmarkFolder
    • editBookmarkFolder
    • removeTweetFromBookmarkFolder
    • fetchBookmarksTimeline
    • fetchBookmarkFolderTimeline
    • fetchBookmarkFoldersSlice
  • https://abs.twimg.com/responsive-web/client-web/loader.Typeahead.4504647a.js
    • metadataCreate: e.post("media/metadata/create"
    • attachSubtitles: e.post("media/subtitles/create"
  • https://abs.twimg.com/responsive-web/client-web/modules.audio.44d4466a.js
    • spacebar: e.getUnversioned("/fleets/v1/fleetline"
    • byId
    • subscribeToScheduledSpaceById
    • unsubscribeFromScheduledSpaceById
    • fetchTopics
    • search
    • addSharing
    • deleteSharing
    • fetchPresence: e.getUnversioned("/fleets/v1/avatar_content"
  • https://abs.twimg.com/responsive-web/client-web/shared~bundle.Articles~bundle.AudioSpaceDetail~bundle.AudioSpaceDiscovery~bundle.AudioSpacebarScreen~bundle.B.6db0364a.js
    • addToList
    • createList
    • editBannerImage
    • deleteList
    • deleteBannerImage
    • fetchList
    • fetchCombinedLists
    • fetchListsManagementPageTimeline
    • fetchTweetsGraphQL
    • fetchMembersGraphQL
    • fetchRecommendedUsersGraphQL
    • fetchSubscribersGraphQL
    • fetchSuggestedLists
    • fetchOwnershipsGraphQL
    • fetchMemberships: e.get("lists/memberships"
    • fetchMembershipsGraphQL
    • removeFromList
    • createSubscribers
    • destroySubscribers
    • toggleMute
    • editList
    • pin
    • unpin
    • fetch: e.post("videoads/v2/prerolls"
  • https://abs.twimg.com/responsive-web/client-web/shared~bundle.AudioSpaceDetail~bundle.AudioSpaceDiscovery~bundle.AudioSpacebarScreen~bundle.Birdwatch~bundle..e24934ea.js
    • fetchImmersiveMedia
    • fetchImmersiveProfile
    • fetchImmersiveProfileByUserId
    • fetchTVHomeMixerGraphQL
    • fetchTVUserProfileGraphQL
    • fetchTVTrendGraphQL
    • fetchTweetRelatedVideosGraphQL
    • generatePinCodeGraphQL
    • deviceIsVerifiedGraphQL
  • https://abs.twimg.com/responsive-web/client-web/shared~bundle.Grok~bundle.ReaderMode~bundle.Birdwatch~bundle.TwitterArticles~bundle.Compose~bundle.Settings~b.03592eba.js
    • bookmarkTweetToFolder
    • createBookmarkFolder
    • deleteAll
    • deleteBookmarkFolder
    • editBookmarkFolder
    • removeTweetFromBookmarkFolder
    • fetchBookmarksTimeline
    • fetchBookmarkFolderTimeline
    • fetchBookmarkFoldersSlice
    • clearConversations
    • setPreferences
    • fetchConversation
    • fetchGrokShareGraphQL
    • fetchGrokHome
    • fetchHistory
    • fetchMediaHistory
    • searchConversations
    • deleteMessage
    • uploadFile: e.postForm("grok/attachment"
    • getQuickPromoteEligibility
    • getCoupons
    • getBudgets
    • getAudienceEstimate
    • getBoostAudienceEstimate
    • getPaymentMethods
    • deletePaymentMethod
    • setDefaultPaymentMethod
    • createPromotion
    • enrollCoupon
    • getAdAccounts
    • fetchArticleDomainsGraphQL
    • fromShare
    • getTargetableLocations: e.getUnversioned("/12/targeting_criteria/locations"
    • getQueriedTargetableLocations: e.getUnversioned("/12/targeting_criteria/locations"
  • https://abs.twimg.com/responsive-web/client-web/shared~bundle.TwitterArticles~loader.TweetCurationActionMenu.956f177a.js
    • createDraftArticle
    • fetchArticleEntity
    • deleteArticleEntity
    • updateArticleEntityContent
    • updateArticleEntityCoverMedia
    • updateArticleEntityTitle
    • publishArticleEntity
    • unpublishArticleEntity
    • fetchArticleEntitiesSlice
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.AppModules~loader.LoggedOutNotifications.94c3e97a.js
    • enableLoggedOutWebNotifications
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.AppModules~ondemand.SettingsRevamp~bundle.NotABot~bundle.TwitterBlue.a61dad2a.js
    • fetchSubscriptionProductDetails
    • fetchSubscriptionProductCheckoutUrl
    • fetchNotABotCheckoutUrl
    • fetchProductSubscriptions
    • switchTier
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.AudioDock~loader.DashMenu~loader.DashModal~loader.DMDrawer~bundle.Grok~bundle.Account~bundle.Re.635a6b9a.js
    • fetchLiveVideoStreamStatus: e.get("live_video_stream/status/"
    • metadataCreate: e.post("media/metadata/create"
    • attachSubtitles: e.post("media/subtitles/create"
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.DMDrawer~bundle.LiveEvent~bundle.Compose~bundle.DirectMessages~bundle.DMRichTextCompose~bundle..d0f9ae9a.js
    • fetchBroadcast
    • fetchLatestBroadcast
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.DMDrawer~ondemand.SettingsInternals~bundle.DirectMessages.6bf37dca.js
    • fetchDMAllSearch
    • fetchDMGroupSearch
    • fetchDMPeopleSearch
    • fetchDMMutedUsers
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.DashMenu~loader.SideNav~loader.AppModules~loader.DMDrawer~bundle.Grok~bundle.MultiAccount~bundl.2aa3c46a.js
    • createCommunity
    • fetchCommunity
    • fetchCommunityWithoutRelay
    • fetchAboutTimeline
    • fetchCommunityLoggedOutTweets
    • fetchCommunityTweets
    • fetchCommunityMediaLoggedOutTweets
    • fetchCommunityMediaTweets
    • fetchCommunityRankedLoggedOutTweets
    • fetchCommunityMemberships
    • fetchRecentCommunityMemberships
    • fetchCommunitiesMembershipsSlice
    • fetchModerationCasesSlice
    • fetchTweetModerationLogSlice
    • fetchCommunitiesMainDiscoveryModule
    • fetchCommunitiesMainTimeline
    • fetchCommunitiesRankedTimeline
    • fetchCommunitiesExploreTimeline
    • fetchCommunityHashtagsTimeline
    • fetchCommunityDiscoveryTimeline
    • keepCommunityTweet
    • joinCommunity
    • requestToJoinCommunity
    • leaveCommunity
    • inviteToCommunity
    • updateCommunityRole
    • createDraftTweet
    • deleteDraftTweet
    • editDraftTweet
    • fetchDraftTweets
    • fetchPlace: e.get("geo/id/
    • search: e.get("geo/places"
    • scheduleTweet
    • fetchScheduledTweets
    • deleteScheduledTweet
    • editScheduledTweet
    • editCommunityName
    • editCommunityPurpose
    • editCommunityQuestion
    • editCommunityRule
    • createCommunityRule
    • removeCommunityRule
    • reorderCommunityRules
    • editCommunityBannerMedia
    • removeCommunityBannerMedia
    • fetchAuthenticatedUserTFLists
    • createTrustedFriendsList
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.Typeahead~bundle.Search.24bc9bda.js
    • fetch: e.get("saved_searches/list"
    • create: e.post("saved_searches/create"
    • destroy: e.post("saved_searches/destroy/
  • https://abs.twimg.com/responsive-web/client-web/shared~loader.WideLayout~loader.ProfileClusterFollow.d1ed818a.js
    • fetch: e.get("users/recommendations"
    • fetchSidebarUserRecommendations
  • https://abs.twimg.com/responsive-web/client-web/shared~ondemand.DirectMessagesCrypto~ondemand.SettingsRevamp.bcca9e0a.js
    • register: e.post("keyregistry/register"
    • extractPublicKeys: e.get("keyregistry/extract_public_keys/

From that, we can see that some of the apiClient features seem to relate to REST (get, post, postForm, postUnversioned, delete) endpoints, and others to graphQL.

For the graphQL parts of the apiClient, we can see that the structure tends to look something like this:

// ..snip..
{
  // ..snip..

  705929: (e, t, o) => {
    "use strict";

    // ..snip..

    var s = 
      // ..snip..
      , m = o(403807)
      , f = o.n(m)

    // ..snip..

    F = ({apiClient: e, featureSwitches: t}) => ({
      bookmarkTweetToFolder: t => e.graphQL(f(), {
        ...t
      }, (0, i.kj)((e => !e.bookmark_collection_tweet_put), "GQL Bookmark Folders: failed to Add Tweet to Bookmark Folder")),
      // ..snip..
    })

    // ..snip..
  }

  // ..snip..
}
// ..snip..

Here, we can see that the first param to graphQL is f(), which is the query/mutation. Tracing that backwards, we can find the module where it was imported from:

(From ChatGPT convo: https://chatgpt.com/c/6767a484-feb8-8008-ba03-5f802a5dd3ad)

1. The graphQL Call

In the bookmarkTweetToFolder function:

bookmarkTweetToFolder: t => e.graphQL(f(), {
  ...t
}, (0, i.kj)((e => !e.bookmark_collection_tweet_put), "GQL Bookmark Folders: failed to Add Tweet to Bookmark Folder"))
  • f() provides the query/mutation details for this call.
  • { ...t } supplies the variables (likely including tweet_id and bookmark_folder_id).
  • The third argument handles errors or conditions for failure.

2. f() Definition in This Module

f() is imported as:

var f = o.n(m);

Where m refers to:

var m = o(403807);

In this case, that GraphQL query/mutation module definition looks like this:

  403807: e => {
    e.exports = {
      queryId: "4KHZvvNbHNf07bsgnL9gWA",
      operationName: "bookmarkTweetToFolder",
      operationType: "mutation",
      metadata: {
        featureSwitches: [],
        fieldToggles: []
      }
    }
  }

(From ChatGPT convo: https://chatgpt.com/c/6767a484-feb8-8008-ba03-5f802a5dd3ad)

1. Details in Module 403807

Module 403807 exports the following:

e.exports = {
  queryId: "4KHZvvNbHNf07bsgnL9gWA",
  operationName: "bookmarkTweetToFolder",
  operationType: "mutation",
  metadata: {
    featureSwitches: [],
    fieldToggles: []
  }
};

Key parts:

  • queryId: "4KHZvvNbHNf07bsgnL9gWA", uniquely identifying this mutation.
  • operationName: "bookmarkTweetToFolder", the logical name of the mutation.
  • operationType: "mutation", indicating that it modifies data.
  • metadata: Additional metadata such as feature toggles, which are currently empty.

2. How queryId is Used

  • The queryId ("4KHZvvNbHNf07bsgnL9gWA") is sent to the server via the graphQL method.
  • On the server:
    • The queryId is mapped to a stored GraphQL mutation string.
    • This mapping is typically maintained in:
      • A server-side database or configuration file.
      • A GraphQL operation registry.
  • The server uses the mutation string mapped to the queryId to execute the operation with the provided variables.

3. Why Use queryId?

  • Efficiency: The client only sends the queryId instead of the full mutation string, reducing payload size.
  • Flexibility: The server can update or optimize the mutation without requiring changes to the client code.
  • Security: Prevents exposing the full query or mutation details in the client.

Timeline Notes 3 (May 19, 2024)

On Twitter, from my notifications page:

When I click on the 'New post notifications for...' section, I am taken to a new page:

A few potentially interesting data-testid / aria-label / etc type attributes we could potentially use for selectors:

<div data-testid="primaryColumn"
  <div aria-label="Home timeline"
    <section aria-labelledby="accessible-list-6" role="region" class="css-175oi2r">
      <h1 dir="auto" aria-level="1" role="heading" class="css-146c3p1 r-4iw3lz r-1xk2f4g r-109y4c4 r-1udh08x r-wwvuq4 r-u8s1d r-92ng3h" id="accessible-list-6">Posts</h1>

      <div aria-label="Timeline: Posts"
        <div data-testid="cellInnerDiv"
          <article data-testid="tweet"
            <div data-testid="User-Name"
            <time datetime="2025-05-06T07:20:52.000Z"

            <a href="/REDACTED/status/REDACTED" dir="ltr" aria-label="May 6" role="link" class="css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-16dba41 r-xoduu5 r-1q142lx r-1w6e6rj r-9aw3ui r-3s2u2q r-1loqt21" style="color: rgb(113, 118, 123);">
              <time datetime="2025-05-06T07:20:52.000Z">May 6</time>
            </a>

            <div data-testid="tweetText"

            <div data-testid="tweetPhoto"
              <div aria-label="Embedded video"
              <div data-testid="previewInterstitial"

From there, I decided to start diving a little deeper into the React Internals, specifically traversing through the fibers and props to get access the internal data stores/etc. The following were my snippets of exploratory code as I was investigating things, so it's more hacky prototype than clean final implementation:

const timelinePosts = $('div[aria-label="Timeline: Posts"]')
const reactPropsKey = Object.keys(timelinePosts).find(k => k.startsWith('__reactProps$'));
const timelinePostsProps = timelinePosts[reactPropsKey]

const seeminglyInterestingChild = timelinePostsProps.children[2]
const cacheKey = seeminglyInterestingChild.props.cacheKey
// Items seems to contain a slice of the tweets that are currently visible; or at least, their ID and some related metadata/etc
const items = seeminglyInterestingChild.props.items
console.log({ cacheKey, items, itemsLength: items.length })
// Other potentially interesting props/functions here:
// const { onAtEnd, onAtStart, onItemsRendered, onKeyboardRefresh, onNearEnd, onNearStart, onPositionRestored, renderer, sortIndexFunction, identityFunction, /* etc */ } = seeminglyInterestingChild.props

const interestingChildOwner = seeminglyInterestingChild._owner
const interestingChildOwnerElementTypeContextType = interestingChildOwner.elementType.contextType.$$typeof // Symbol(react.context)
const interestingChildOwnerElementTypeDefaultProps = interestingChildOwner.elementType.defaultProps // impressionCache, lingerCache, refreshControl, scroller, etc
const tweetNotificationsIDs = interestingChildOwnerElementTypeDefaultProps.impressionCache.get('tweet_notifications')
console.log({ tweetNotificationsIDs, tweetNotificationsIDsSize: tweetNotificationsIDs.size })

const interestingChildOwnerDependenciesFirstContextMemoizedValue = interestingChildOwner.dependencies.firstContext.memoizedValue // featureSwitches, history, isRestrictedSession, loggedInUserId, scrollManager, store, userAgent, userClaims, verifiedCrawlerName, viewerUserId
const store = interestingChildOwnerDependenciesFirstContextMemoizedValue.store // @@observable, dispatch, getState, replaceReducer, subscribe
const state = store.getState()
const stateKeys = Object.keys(state) // Some potentially interesting ones include: developer, directMessages, draftTweets, editTweet, entities, featureSwitch, grok, liveTweetCounts, session, settings, tweetComposer, urt, userClaim, etc
console.log({ state, stateKeys, stateKeysLength: stateKeys.length })

const stateEntities = state.entities
const stateEntitiesKeys = Object.keys(stateEntities) // Some potentially interesting ones include: cards, communities, conversations, genericNotifications, publishedArticles, tweets, users
console.log({ stateEntities, stateEntitiesKeys, stateEntitiesKeysLength: stateEntitiesKeys.length })

const stateEntitiesTweets = stateEntities.tweets
const stateEntitiesUsers = stateEntities.users
console.log({
  stateEntitiesTweets,
  stateEntitiesTweetsLength: Object.keys(stateEntitiesTweets.entities).length,
  stateEntitiesUsers,
  stateEntitiesUsersLength: Object.keys(stateEntitiesUsers.entities).length,
})

const tweetNotificationIDsArray = Array.from(tweetNotificationsIDs)
// const [entityType, entityKey] = tweetNotificationIDsArray[0].split('-')

const getEntity = (entityType, entityKey) => state.entities?.[`${entityType}s`]?.entities?.[entityKey]

const getUser = (userId) => {
  const user = getEntity('user', userId)
  const userLabel = !!user ? `${user.name} (@${user.screen_name})` : `Unknown (userId: ${userId})`

  return {
    userId,
    user,
    userLabel,
  }
}

const getNotificationTweetByFullEntityId = (fullEntityId) => {
  const [tweetEntityType, tweetEntityKey] = fullEntityId.split('-')
  return getEntity(tweetEntityType, tweetEntityKey)
}

const getNotificationTweetByIndex = (notificationIndex) => getNotificationTweetByFullEntityId(tweetNotificationIDsArray[notificationIndex])

const tweetNotifications = tweetNotificationIDsArray.map(id => getNotificationTweetByFullEntityId(id))

console.log({
  tweetNotifications,
  tweetNotificationsLength: tweetNotifications.length,
  tweetNotificationsNewestLoaded: tweetNotifications[0].created_at,
  tweetNotificationsOldestLoaded: tweetNotifications[tweetNotifications.length - 1].created_at,
})

function getTweetCounts(tweets) {
  const byUser = Object.create(null);
  const byDay = Object.create(null);
  const byUserDay = Object.create(null); // userLabel → { day → count, _total }
  const byDayUser = Object.create(null); // day → { userLabel → count }

  for (let i = 0; i < tweets.length; i++) {
    const tweet = tweets[i];

    const userId = tweet.user_id || tweet.user;
    const { userLabel } = getUser(userId);
    const day = tweet.created_at.slice(0, 10);

    // Count by user
    byUser[userLabel] = (byUser[userLabel] || 0) + 1;

    // Count by day
    byDay[day] = (byDay[day] || 0) + 1;

    // Count by user → day, plus track total
    if (!byUserDay[userLabel]) {
      byUserDay[userLabel] = Object.create(null);
      byUserDay[userLabel]._total = 0;
    }
    byUserDay[userLabel][day] = (byUserDay[userLabel][day] || 0) + 1;
    byUserDay[userLabel]._total++;

    // Count by day → user
    if (!byDayUser[day]) byDayUser[day] = Object.create(null);
    byDayUser[day][userLabel] = (byDayUser[day][userLabel] || 0) + 1;
  }

  return { total: tweets.length, byUser, byDay, byUserDay, byDayUser };
}

const {
  total: tweetNotificationsTotal,
  byUser: tweetNotificationsByUser,
  byDay: tweetNotificationsByDay,
  byUserDay: tweetNotificationsByUserDay,
  byDayUser: tweetNotificationsByDayUser
} = getTweetCounts(tweetNotifications);

console.log({
  tweetNotificationsTotal,
  tweetNotificationsByUser,
  tweetNotificationsByDay,
  tweetNotificationsByUserDay,
  tweetNotificationsByDayUser
});

But since that's all a bit messy, here is a bit of a quick ChatGPT refactor of the code gadgets/etc into more logical groupings:

I will no doubt clean up / refine / enhance these more specifically myself in future; and then build them into a toolkit that I can use to further build out into relevant user scripts and/or a Chrome extension, etc.

See Also

My Other Related Deepdive Gist's and Projects

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment