What's Actually Inside a Tweet's JSON (Field by Field)
What's Actually Inside a Tweet's JSON (Field by Field)
You made the call, you got a response, and now you're looking at an object with thirty keys and no idea which ones you can trust.
That's the real problem with tweet JSON: it isn't complicated, it's conditional. Half the fields are null most of the time. Three of them look like they mean the same thing. One of them silently changes shape depending on whether the tweet is a retweet.
This walks through all of it with real responses — what each field holds, when it's null, and the specific places parsers break.
Every response below came from a live call while writing this. If you want to poke at the same shapes yourself, there's a free demo endpoint that needs no signup.
The shape, top down
A tweet object has four groups of fields, and keeping them separate makes the rest easier:
| Group | Fields | Reliability |
|---|---|---|
| Identity | id, url, conversation_id, created_at |
Always present |
| Content | text, lang, source |
Always present |
| Counters | like_count, retweet_count, reply_count, quote_count, bookmark_count, view_count |
Always present, often 0 |
| Relationships | is_reply, is_retweet, is_quote_status, and their *_id partners |
Conditional — this is where bugs live |
| Entities | hashtags, urls, photos, videos, symbols, mentioned_users |
Always arrays, often empty |
| Author | author (nested object) |
Always present |
Here's a real one, trimmed:
{
"id": "2118565802429632576",
"url": "https://x.com/elonmusk/status/2118565802429632576",
"conversation_id": "2118565802429632576",
"created_at": "Fri Nov 06 04:47:03 +0000 2026",
"text": "RT @AndrThie: This is a bigger deal than people...",
"lang": "en",
"source": "Twitter for iPhone",
"is_retweet": true,
"retweeted_tweet_id": "2118476679593312316",
"is_quote_status": true,
"quoted_tweet_id": "2118381463389307149",
"is_reply": false,
"in_reply_to_tweet_id": null,
"in_reply_to_user": null,
"in_reply_to_user_id": null,
"like_count": 0,
"retweet_count": 104,
"view_count": 217654,
"mentioned_users": ["AndrThie"],
"hashtags": [], "urls": [], "photos": [], "videos": [], "symbols": [],
"author": { "...": "..." }
}
Look at that object for a second. It's a retweet and a quote. Its like_count is 0 while retweet_count is 104. Both of those are correct, and both of them break naive parsers. We'll get to why.
Identity fields
id is a string, and it has to be
"id": "2118565802429632576"
Nineteen digits. That exceeds what JavaScript's Number can represent exactly — anything above 2^53 silently loses precision. Parse that as an integer in JS and you get a different tweet ID than the one you were sent, with no error.
// Wrong — silently corrupts the ID
const id = Number(tweet.id); // 2118565802429632600 ← note the tail
// Right — leave it a string
const id = tweet.id;
Python handles big integers natively so it won't corrupt, but you still want strings for consistency when the ID crosses into JSON, a URL, or a database column.
conversation_id tells you the thread root
For an original tweet, conversation_id === id. For anything in a thread, it points at the first tweet of that thread. This is how you group a conversation without walking the reply chain upward — and it pairs with advanced search syntax when you want a whole thread from one query.
created_at is in Twitter's own format
"created_at": "Fri Nov 06 04:47:03 +0000 2026"
Not ISO 8601. It's the old EEE MMM dd HH:mm:ss Z yyyy format, always UTC.
from datetime import datetime
dt = datetime.strptime(tweet["created_at"], "%a %b %d %H:%M:%S %z %Y")
// JS Date parses this correctly without a library
const dt = new Date(tweet.created_at);
The three relationship flags, and why they're not exclusive
This is the part that causes the most parser bugs, so it gets its own section.
There are three boolean flags, each with a companion ID field:
| Flag | Companion field | Means |
|---|---|---|
is_reply |
in_reply_to_tweet_id, in_reply_to_user, in_reply_to_user_id |
This tweet replies to another |
is_retweet |
retweeted_tweet_id |
This is a retweet of another |
is_quote_status |
quoted_tweet_id |
This quotes another tweet |
They are not mutually exclusive. The sample above is simultaneously a retweet and a quote — someone retweeted a tweet that was itself a quote tweet. A reply can also quote. In principle you can hit all three.
# Wrong — assumes one category
if tweet["is_retweet"]:
kind = "retweet"
elif tweet["is_quote_status"]:
kind = "quote"
elif tweet["is_reply"]:
kind = "reply"
else:
kind = "original"
That elif chain will classify the sample as a plain retweet and lose the quote relationship entirely. If you're building a thread view or a reply graph, that's a missing edge.
# Right — they're independent facts
kinds = set()
if tweet["is_retweet"]: kinds.add("retweet")
if tweet["is_quote_status"]: kinds.add("quote")
if tweet["is_reply"]: kinds.add("reply")
if not kinds: kinds.add("original")
The companion fields are null when the flag is false. Always check the flag before reading the ID, or guard with .get():
parent = tweet.get("in_reply_to_tweet_id") # None when not a reply
These flags are normalised the same way on every endpoint we serve, which is the point of a typed response — you write the branch once instead of per-endpoint. You can check the shape against a live response before writing any of it.
Counters: why a retweet shows zero likes
"like_count": 0,
"retweet_count": 104,
"view_count": 217654
That looks wrong until you know the rule: engagement belongs to the original tweet, not the retweet. When someone retweets, the retweet object carries its own counters, and almost all of them are zero because nobody engages with the retweet itself — they engage with the original.
If you're ranking by engagement and you don't filter retweets, your top results will be a wall of zeros mixed with real numbers.
# Rank only originals
originals = [t for t in tweets if not t["is_retweet"]]
originals.sort(key=lambda t: t["like_count"] + t["retweet_count"], reverse=True)
Two more counter facts worth knowing:
view_countis the only counter that keeps climbing long after posting. Likes and retweets plateau within hours; views don't. If you snapshot engagement, snapshot views separately or you'll conclude the tweet is still "going viral" days later.bookmark_countis often 0 even on large tweets. Bookmarks are private; the count is not always exposed. Treat 0 as "unknown", not "nobody bookmarked it".
If you're pulling engagement for many tweets at once, fetching them one ID at a time is the expensive way to do it — batching the requests changes the arithmetic considerably.
Entity arrays: always arrays, usually empty
"hashtags": [], "urls": [], "photos": [], "videos": [],
"symbols": [], "mentioned_users": ["AndrThie"]
Six entity fields. The useful property is that they're always arrays — never null, never a bare object. You can iterate without a guard:
for tag in tweet["hashtags"]: # safe even when empty
...
symbols holds cashtags ($TSLA). mentioned_users holds usernames without the @. photos and videos are separate arrays, so a tweet with mixed media populates both.
One gotcha on text and urls: the text field contains the shortened t.co link, not the destination. If you want the real URL, read it from the urls array rather than regexing the text.
The nested author object
Every tweet carries its author inline, which saves you a second call:
"author": {
"id": "44196397",
"username": "elonmusk",
"display_name": "Elon Musk",
"bio": "...",
"avatar": "https://pbs.twimg.com/profile_images/...",
"followers_count": 241452659,
"following_count": 1421,
"statuses_count": 111504,
"created_at": "Tue Jun 02 20:12:29 +0000 2009",
"location": "",
"url": "https://x.com/elonmusk",
"verified": false,
"blue_verified": true,
"verified_type": ""
}
Three things people get wrong here:
username vs display_name. username is the handle (stable, used in URLs). display_name is the shown name (changeable, can be emoji, can be empty). Key your database on id, not either of them.
verified vs blue_verified. These are different things. verified is the legacy checkmark; blue_verified is the subscription one. In the sample, verified: false and blue_verified: true — both correct. Checking only verified will tell you Elon Musk is unverified.
location is free text, often empty. It's whatever the user typed. Not geocoded, not validated, frequently a joke. Don't build geography features on it.
Null and empty: a quick reference
| Field | When it's null / empty | Safe default |
|---|---|---|
in_reply_to_* |
is_reply is false |
None |
retweeted_tweet_id |
is_retweet is false |
None |
quoted_tweet_id |
is_quote_status is false |
None |
hashtags / urls / photos / videos / symbols |
No such entity | [] (never null) |
author.location |
User left it blank | "" |
author.verified_type |
Not an org/gov account | "" |
bookmark_count |
Often, even on big tweets | treat 0 as unknown |
A defensive parser that handles all of the above:
def parse(t):
return {
"id": t["id"], # keep as string
"created": datetime.strptime(t["created_at"],
"%a %b %d %H:%M:%S %z %Y"),
"text": t["text"],
"author_id": t["author"]["id"], # key on id, not username
"is_rt": t["is_retweet"],
"is_quote": t["is_quote_status"],
"is_reply": t["is_reply"],
"parent": t.get("in_reply_to_tweet_id"),
"quoted": t.get("quoted_tweet_id"),
"likes": t["like_count"],
"views": t["view_count"],
"tags": t["hashtags"], # always a list
"verified": t["author"]["verified"] or t["author"]["blue_verified"],
}
That's the whole object handled, and it won't throw on any shape described above.
Keeping a parser like this working is most of the hidden cost of rolling your own — the field shapes move, and each scraping approach pays that tax differently.
If you'd rather not maintain this layer at all: our API returns exactly the structure in this article — the same field names, the same null rules — so the parser above runs against it unchanged. Signing up with Google includes $0.20 in credits, no card.
FAQ
What format is Twitter's JSON response?
A flat object per tweet with a nested author object. Roughly thirty top-level fields covering identity, content, counters, relationship flags and entity arrays. No envelope around the tweet itself beyond the response wrapper.
Why is the tweet ID a string and not a number? Because it's a 19-digit snowflake ID, which exceeds JavaScript's safe integer range (2^53). Parsing it as a number corrupts the last few digits silently. Keep it a string everywhere.
What does conversation_id mean in tweet JSON?
It's the ID of the first tweet in the thread. For an original tweet it equals its own id. Use it to group a whole conversation without walking replies upward one by one.
Why does created_at not parse as ISO 8601?
Twitter uses its own legacy format, EEE MMM dd HH:mm:ss Z yyyy, always in UTC. Python needs an explicit strptime format string; JavaScript's new Date() handles it natively.
Can a tweet be a retweet and a quote at the same time?
Yes. is_retweet and is_quote_status are independent booleans — someone retweeting a quote tweet produces both as true. A reply can also quote. Never treat them as mutually exclusive categories.
Why does a retweet show 0 likes? Engagement accrues to the original tweet. The retweet object has its own counters and they're usually zero. Filter retweets out before ranking by engagement, or you'll sort a mix of real numbers and structural zeros.
What's the difference between verified and blue_verified?
verified is the legacy checkmark; blue_verified is the paid subscription badge. They're separate booleans and an account can have one without the other. Checking only verified will misclassify most currently-badged accounts.
What's the difference between username and display_name?
username is the @handle used in URLs and is relatively stable. display_name is the shown name, can be changed freely, can contain emoji, and can be empty. Key your storage on the numeric id.
Are the entity arrays ever null?
No. hashtags, urls, photos, videos, symbols and mentioned_users are always arrays. When there's no such entity, you get []. You can iterate without a null guard.
How do I get the real URL instead of the t.co link?
Read the urls array rather than the text field. The text carries the shortened t.co form; the array carries the expanded destination.
Why is view_count so much higher than other counters? Views count impressions, not actions, and they keep accumulating long after likes and retweets have plateaued. If you snapshot engagement over time, treat views as a separate series.
Is bookmark_count accurate? Often not exposed, so it reads 0 even on large tweets. Treat 0 as "unknown" rather than "zero bookmarks".
What does the source field contain?
The client that posted the tweet, as a plain string — "Twitter for iPhone", "Twitter Web App", or a third-party app name. Useful for filtering automated posting.
What is possibly_sensitive? A boolean flag the platform sets on content it considers potentially sensitive. It's a hint for display decisions, not a moderation verdict.
How do I tell an original tweet from everything else? All three relationship flags false: not a reply, not a retweet, not a quote. That's the only reliable test — checking any single flag leaves gaps.
Does the author object always come with the tweet? Yes, nested inline on every tweet. You don't need a second call to resolve who posted it, which matters when you're processing thousands of tweets.
What's in the symbols array?
Cashtags — $TSLA, $BTC and similar. Separate from hashtags, which holds # tags.
How large is a single tweet object? Typically one to three kilobytes of JSON depending on how many entities and how long the bio is. Worth knowing when you're sizing a batch response or a queue message.
Can I rely on these field names staying stable? Field names in a normalised API like ours stay stable by contract. Reading X's internal endpoints directly does not give you that guarantee — the shapes change without notice, which is most of the maintenance cost of a DIY scraper.
What happens to the fields when a tweet is deleted? You stop being able to fetch it at all; you don't get a tombstone object with null fields. Handle the not-found case explicitly rather than expecting a partial record.
Do I need an X developer account to see this JSON shape? Not with a third-party read API — you use that provider's key. A developer account is required for official API access, which you'd want if you need to write (post, reply, follow).
How are emoji and non-Latin text encoded in the JSON?
As ordinary UTF-8 in the text field. The catch is display_text_range and entity indices, which count UTF-16 code units — so an emoji outside the Basic Multilingual Plane counts as 2, not 1. Slicing by Python string index will cut in the wrong place on tweets containing emoji.
Why do entity indices not line up with my string slicing?
Because X computes them in UTF-16 code units, matching JavaScript's String.length. In Python, convert the text with text.encode('utf-16-le') and index on that, or use the entity's own text value instead of slicing.
Does the JSON include the tweet's language? There's a language code on the tweet, but it's X's own detection and it's wrong often enough on short or mixed-language text that you shouldn't branch on it without a fallback.
Can two tweets share the same conversation_id? Yes — that's the point. Every reply in a thread carries the conversation_id of the thread's first tweet, so grouping by it reconstructs the thread.