The bird is dead
All good things come to an end. It is 2026 and enshittification is the name of the game. Twitter is now X(itter), tweets are posts, verified badges are a subscription product, and the official API costs somewhere between a mortgage and a hostage negotiation.
Opening the official Twitter app (still not calling it X) feels like reading a group chat while Grok-generated slop, community-note cage matches, and subscription peddling fight for the screen.
Then there was the original Harpy app: a beautiful Flutter client with timelines, profiles, themes, filters, gestures, and the kind of polish that only happens when one person cares more than the market rewards. It depended on an API that had been taken outside and shot. Still, it felt like finding an unmodded Nissan Skyline GT-R R33 in a world where every car was now an electric SUV: old, impractical, and much more fun than the replacement.
My Discord friend Draff decided to revive it on a whim. His vaguely relevant experience was sending GLM after a Tachiyomi extension until it worked. Mine was React—not React Native, actual web React. Obviously, this was a flawless foundation for reviving a dead Flutter client.
The project is now called reHarpy (possibly still private). Draff discovered and documented X’s internal APIs. I stripped the app down to one Android APK: no free/pro tier, App Store upsell, or paywall protecting revenue for a business that no longer exists.
Why put this much effort into a project nobody asked for? I wanted the app back, and the job market had left me with a suspicious amount of motivation to build something unnecessarily difficult.
We did not rebuild Harpy around X. We taught one class to lie convincingly enough that the rest of Harpy never noticed. The bridge translates the old v1.1-shaped calls into X’s web GraphQL and internal endpoints, then reshapes the responses before they reach the app.
For a while, the lie worked. Then, on July 14, every write operation died at once.
API necromancy, but with more JSON.
The home timeline: translated posts, themed cards, and the same Flutter UI Harpy always had.
The useful lie
Harpy already had a stable internal path:
widget
-> Riverpod provider
-> TwitterApi service
-> v1.1-shaped request
-> Tweet and User models
-> UI
The obvious implementation was to teach every Harpy feature about X GraphQL. Timeline providers would call HomeTimeline, profile pages would know about UserByScreenName, and tweet actions would construct mutation bodies. Eventually the application would become forty unrelated reverse-engineering experiments wearing the same theme.
We did not do that. That is stupid.
Instead, the new path looks like this:
Harpy feature
-> existing TwitterApi service
-> old v1.1-shaped URL
-> WebTwitterClient
-> X web GraphQL or internal endpoint
-> v1-compatible JSON
-> existing Tweet and User models
-> existing UI
The central piece is WebTwitterClient, a drop-in implementation of the HTTP client expected by our fork of dart_twitter_api.
If Harpy asks for:
/1.1/statuses/home_timeline.json
the bridge routes it to X’s HomeTimeline operation.
If Harpy asks for:
/1.1/statuses/retweet/123.json
the bridge sends a CreateRetweet mutation.
If Harpy asks for an old route that has no mapping, the bridge fails instead of quietly returning a cheerful pile of nothing. This sounds less friendly, but honest failures are considerably easier to debug than timelines that pretend nobody has posted since 2023.
The old v1 interface is not technically true anymore, but it is a very good saddle. Change the horse underneath; keep the part the rider already knows.
Unfortunately, the horse is a browser
The official version of Harpy used Twitter OAuth, which now costs $42,000 per month. reHarpy does not add consumer keys, developer portals, password login, or a mysterious textbox asking users to paste credentials into an APK from GitHub.
Authentication happens in a WebView.
The user signs into X through the real website. reHarpy captures the authenticated browser session after both required cookies exist:
auth_tokenct0
It also keeps the surrounding browser cookie jar. This matters because a modern authenticated X session is not simply two strings wearing a trench coat. The browser accumulates cookies for identity, Cloudflare, personalization, and other session state that can affect whether a request looks legitimate.
The canonical authentication fields are stored separately in encrypted preferences. Additional browser cookies are persisted as a flattened cookie header.
That flattening is one of the project’s honest compromises. A browser cookie jar understands domains, paths, expiration, host-only rules, and SameSite. reHarpy stores name/value pairs and rebuilds a Cookie header. That is better than keeping only auth_token and ct0, but it does not reproduce Chromium’s selection rules exactly.
This is the kind of distinction that seems theoretical until one client gets HTTP 200 and another gets told to go away.
X’s web API is not an API contract
Calling X’s web endpoints is easy. Keeping them working is a FromSoftware side quest: one dependency moved to another bundle, a previously optional header is now mandatory, and the only documentation is a dead account saying “works for me.”
The website uses GraphQL operations with names such as:
HomeTimelineTweetDetailCreateTweetCreateRetweetFavoriteTweetBookmarksUserByScreenName
Each operation also has a query ID embedded in X’s current JavaScript bundle. Those IDs rotate. Hard-coding the ID observed on Tuesday is a good way to make the app break shortly after someone writes “works perfectly” in the release notes.
reHarpy therefore discovers query IDs at runtime:
fetch x.com homepage with session cookies
-> locate main.HASH.js
-> fetch that bundle from abs.twimg.com without cookies
-> extract operation names and query IDs
-> atomically replace the discovered registry
Compiled fallback IDs still exist, but runtime discovery is preferred. A failed discovery keeps the previous valid registry rather than replacing it with an empty map and sending the entire app to the void.
Concurrent discovery is shared too. Without it, three requests could fetch three registries and let the oldest bundle finish last, effectively “updating” the app backward.
Query IDs were annoying. Transaction IDs were where the project began demanding an actual browser.
Current X web requests can include an x-client-transaction-id generated from live browser state: DOM state, animations and computed CSS, Web Crypto, WebRTC, and X’s JavaScript modules. We had a pure-Dart imitation. We deleted it. At some point, “implement a browser inside Dart” stops being a clever optimization and becomes the part where the Evangelion runs out of budget.
Android now uses an authenticated headless WebView. The command-line tools use an isolated Chromium CDP session. The runtime finds the current transaction generator inside X’s own bundle and produces a fresh value for the exact HTTP method and request path.
Browsers and apps can both be right
A Chromium traffic audit captured a healthy authenticated load of https://x.com/home: HomeTimeline succeeded as GET, every GraphQL request had a transaction ID, and the browser held richer cookies than auth_token and ct0. It was a useful control sample. It was not proof that reHarpy should switch its timeline request to GET.
The app intentionally uses POST for several timeline routes because non-browser transports have historically received 404 responses for equivalent GraphQL GET requests. The browser and the app can use different methods and both work because transport fingerprints matter.
On Android, reHarpy prefers Cronet for its Chrome-like TLS and HTTP/2 stack. Desktop tests use Dart’s deterministic http.Client, which is less authoritative for answering, “Will X think this came from a browser-shaped Android application?”
A browser success is a control. A desktop failure is evidence. Android with Cronet is the path the application actually ships.
July 14: Worldline 1.048596
On July 14, we crossed into a slightly worse worldline.
Authentication still worked. Timelines loaded. Transaction IDs were still being generated. Every write in the app failed.
The undocumented platform had moved again, but only enough to make the system look healthy while breaking anything that changed state.
My first guess was query-ID rotation, because the boring answer is usually right. It was wrong. X had started exposing two initialized transaction-generator candidates in the bundle: one stale, one current. The runtime found both and selected the stale one, producing transaction IDs that looked valid but were no longer accepted.
Selecting the newest live candidate restored writes.
Neither of us knows why X shipped both. I have decided that is not my question to answer.
Mapped writes require a valid transaction ID. If generation is unavailable—or the runtime cannot identify a current generator—the bridge returns a sanitized 503 before sending the mutation.
This is deliberate. “Maybe X will accept a write that we know is malformed” is not a recovery strategy.
The bugs hiding behind the scary bug
Read-only tests are useful because they do not leave strange posts on an account at two in the morning. They also do not prove that writes work.
Once the transaction generator was fixed, we expected a live smoke test to expose some exotic anti-bot failure. Instead, retweet and unretweet, reply, quote, favorite and unfavorite, then delete uncovered three extremely normal bugs. Every created post and action state was cleaned up afterward. “The test passed” feels less comforting when the test account contains six posts announcing internal fixture names.
One bug was response-shape drift, one was request-payload drift, and one was a route we had simply never mapped.
Replies and the disappearing result
A direct CreateTweet request succeeded, but the app-facing facade received {}.
LOGIC: The tweet exists.
PERCEPTION: Not in that drawer.
X returned the created tweet under:
data.create_tweet.tweet_results.result
The bridge still expected the older singular path:
data.create_tweet.tweet_result.result
The live response also omitted __typename, while the reshaper required it to equal Tweet.
The mutation worked. The bridge then looked in the wrong drawer, decided nothing was there, and handed an empty object to Tweet.fromJson.
The fix was narrow:
- accept the current plural
tweet_results.resultpath before the historical singular path; - accept a missing
__typename; - continue rejecting explicit non-
Tweettypenames.
After that change, an app-shaped reply returned a real v1-compatible tweet, preserved its parent tweet ID, and was deleted successfully.
This is why the reshaper exists as one explicit compatibility seam. X can move a result node without requiring the compose screen, timeline state, and tweet widgets to develop opinions about GraphQL archaeology.
Quotes that were not quotes
The first quote smoke produced a normal post with text but no quoted post.
The bridge was sending quote_tweet_id. X’s current composer expected the original status URL through attachment_url.
Bundle inspection showed the active web client forwarding:
attachment_url
The bridge changed to match it, while validating that the attachment was actually a status URL. The next smoke produced is_quote_status: true and the correct quoted tweet ID.
This bug was especially polite. It returned HTTP 200, created a post, and did almost everything except the one thing the user asked for.
Liking worked. Unliking did not exist.
The favorite mutation returned 200. Cleanup then reached:
/1.1/favorites/destroy.json
and found no mapped route.
The app could like a post but not unlike it, which is less of a feature and more of a tiny commitment trap.
Adding the route to UnfavoriteTweet fixed the full cycle. The live response was the string "Done" rather than a nested tweet object. That is acceptable for the application because favorite state is optimistic: the UI updates locally, rolls back on failure, and only needs the service call to complete without throwing.
Not every successful mutation needs to cosplay as a complete tweet.
After these fixes, all tested write paths completed and cleaned up successfully. Authentication and transaction bootstrap were working. The failures were ordinary contract drift and one missing route.
Ordinary is good. Ordinary can be fixed.
A Vocaloid voicebank opened in Notepad
My favorite bug from this stretch was not a write bug at all. Every Japanese post rendered as garbage.
X serves JSON as application/json with no charset. Dart’s response.body sees no encoding, falls back to Latin-1, and every Japanese post looks like a Vocaloid voicebank opened in Notepad. The fix is to read bytes and decode them properly:
utf8.decode(response.bodyBytes)
One line. It took considerably longer than one line to find, because nothing anywhere reported an error. The request succeeded, the JSON parsed, the tweet rendered, and the text was simply wrong.
Conservative by construction
Do not retry uncertainty
reHarpy retries read queries once after:
- HTTP
403; - HTTP
404; - a non-empty GraphQL error list with no usable data.
That recovery refreshes homepage cookies, query IDs, and transaction runtime state before replaying the read.
Mapped writes are more conservative. They may recover once after HTTP 403/404 or recognized automation responses such as X codes 226, 334, and 344.
They do not retry:
- timeouts;
- connection failures;
- HTTP
429; - HTTP
5xx; - raw media or internal REST calls;
- a
CreateTweetresponse that may already contain a created result.
If a create request times out after reaching X, the client cannot know whether the post exists. Retrying might create a duplicate. The honest response is an ambiguous failure, not optimism with side effects.
The same principle applies to session persistence. Rotated cookies are collected during a mutation, but persistence waits until the outer request finishes. A preferences failure is logged with sanitized metadata and never causes a successful write to replay.
The general rule became:
Retry only when the bridge can prove replay is bounded and safe.
This is less exciting than “self-healing API client,” but it produces fewer accidental replies.
Debugging without stealing the account
Debugging a web-session client requires enough information to compare a failing app request with a working browser request.
It does not require logging the user’s session.
reHarpy keeps the last twenty request records, and the split is deliberate. What goes in: operation name, query ID, method, path without query parameters, HTTP status, summarized GraphQL errors, safe rate-limit headers, query-discovery classification, and the recovery attempt and its outcome. Cookies appear by name. The transaction ID appears as a boolean.
What stays out: authentication and CSRF values, bearer and guest tokens, transaction-ID values, cookie values, request variables, bodies, full URLs, raw responses, Set-Cookie, sessions, and credential files.
We needed the shell of the request, not the ghost of the session.
The distinction I kept returning to is presence versus value. “This request had a transaction-ID header” answers almost every question we actually had during an outage. “This request had transaction-ID abc123” answers the same question and also hands anyone reading the logs a piece of a live session.
The Chromium analyzer follows the same model. It reads response bodies only long enough to extract bounded structural metadata, then discards them.
A diagnostic tool that fixes one outage by creating an account takeover incident is not a diagnostic tool. It is an exchange program for problems.
What survived
Most of the port exists so the old app can continue behaving like itself. Reply loading, for example, could no longer rely on the old to:screenName search. reHarpy now loads TweetDetail, builds a parent-to-children map, and reconstructs nested reply trees locally.
Media upload now uses X’s chunked web endpoint, converting selected videos to H.264/AAC first. View counts, Grok translations, custom share domains, system sharing, and image export also crossed the facade without making the old widgets understand X’s current response shapes.
Open post, post actions, and the share-as-image export preview.
The honest bit
reHarpy is not a complete X clone. It is the real-world equivalent of microwaving a banana and hoping we discover time travel.
Direct messages are not implemented. Other users’ likes are private, list ownership and subscriptions remain fragile, and notifications currently expose only mentions.
Media upload remains sensitive to file validity, transport fingerprinting, and rate limits; alt text and subtitle metadata are not implemented. Cookie storage still flattens browser scope, desktop transport does not perfectly represent Android, and X-side bot detection can reject even correct query IDs and transaction IDs.
reHarpy is production-ready under the traditional AUR definition: the maintainer used it successfully yesterday.
Long live the useful lie
reHarpy answered its original question: Harpy can survive without Twitter’s API as long as its v1-shaped facade stays intact and one class lies consistently.
Tooling footnote: GLM 5.2 did implementation grunt work, GPT 5.6 Sol got its hands dirty with the web API reverse engineering, and Grok 4.5 blazed through the slog at 80 TPS. Not Anthropic, though. Opus 5 thought I was hacking the Pentagon.
Harpy survives for roughly the same reason Doom runs on calculators: somebody interpreted platform death as a personal challenge.
The project is still fragile and deliberately narrow, but it made the app usable again. That was the point. Some of it is ugly. Some of it is interesting. All of it is just software. Long live the useful lie.