X post and profile JSON, field by field: a reference for agents

Updated
Every field the free UnifAPI X endpoints return for posts, profiles, pages, rate limits and errors, with types and the edge cases to handle.
In short: the X endpoints on UnifAPI, free to use with no login and rate-limited per visitor, return three records (
post,profile, and thepagewrapper around lists) plus one error shape and three rate-limit headers. Field names are camelCase, ids are strings (they exceed 2^53), dates are ISO 8601 in UTC, counts are integers, and anything X does not report is absent rather than zero. This page lists every field with its type and the cases a program has to handle: a repost that carries the original post plusrepostedBy, a quote that nests one level, a protected account that returns its counts but no posts, andviewsthat X reports on most posts but not all.
Models are good at reading JSON they have seen. The difference between an agent that uses a data source correctly and one that guesses field names is whether the reference was in its context or in its training data. This is the reference. It is the same model the human viewer pages render, so what you see on /tools/x-profile-viewer is exactly what /tools/x/profile?handle=… returns.
Conventions
- Ids are strings. X ids are 64-bit; JSON numbers lose precision above 2^53. Every
idis a string, including in URLs and cursors. - Dates are ISO 8601, UTC.
createdAtandjoinedAtlook like2026-10-05T22:33:56.000Z. - Counts are integers. Never formatted strings, never nulls. A count X did not report is left out, not set to 0.
- Optional means absent. A field marked optional is missing from the object when there is no value. Test with
"views" in post.metrics, not=== 0. - URLs are canonical x.com URLs.
post.urlishttps://x.com/<handle>/status/<id>; media URLs point at X's CDN and are never re-hosted.
post
Returned by /tools/x/post (as post), and as each item of posts.items on profile, media, and search, and of replies.items on replies.
| Field | Type | Meaning |
|---|---|---|
id | string | Post ID |
text | string | Full text. Long posts (notes) are returned in full; the trailing media shortlink is removed when media is attached |
createdAt | string (ISO 8601) | Publish time |
language | string, optional | BCP-47 code X detected (en, ja, …) |
conversationId | string, optional | Id of the root post of the thread this post belongs to |
inReplyToPostId | string, optional | The post this one replies to, when it is a reply |
possiblySensitive | boolean | X's own flag |
url | string | Canonical x.com URL |
author | profile or null | The author; null only when X withheld it |
metrics | object | See below |
media | array of media | Empty when none |
repostedBy | object or null | Set when this entry is a repost: { id, handle, name } of the account that reposted. The rest of the record is the original post |
quoted | post or null | The quoted post when this is a quote post, nested one level (a quote inside a quote is not expanded) |
metrics
| Field | Type | Meaning |
|---|---|---|
likes | integer | |
reposts | integer | Reposts (retweets), not counting quotes |
replies | integer | X's reply count; can exceed the number of readable public replies |
quotes | integer | Quote posts |
bookmarks | integer | |
views | integer, optional | Impressions. X reports them on most posts; absent when it does not |
media
| Field | Type | Meaning |
|---|---|---|
kind | photo | video | animated_gif | |
url | string, optional | Full-size image, or the poster frame for video and GIF |
previewUrl | string, optional | Same as url today; reserved for a smaller rendition |
width, height | integer, optional | Original dimensions |
durationMs | integer, optional | Video length |
videoUrl | string, optional | Highest-bitrate MP4 X exposes, on X's CDN |
profile
Returned by /tools/x/profile, /tools/x/media, and /tools/x/followers (as profile), as post.author, and as each item of users.items on followers.
| Field | Type | Meaning |
|---|---|---|
id | string | User ID |
handle | string | Without the @, in X's casing (NASA) |
name | string | Display name |
verified | boolean | Any verification (blue, government, business) |
verifiedType | string, optional | Government, Business, … when X names one |
protected | boolean | Posts are not public. The endpoints return counts but no posts |
bio | string | May be empty |
location | string | Free text, may be empty |
website | string, optional | Expanded URL, not the t.co shortlink |
joinedAt | string (ISO 8601) | |
avatarUrl | string, optional | 400×400 rendition |
bannerUrl | string, optional | |
followers | integer | |
following | integer | |
posts | integer | Lifetime post count |
listed | integer, optional | Lists the account appears on, when reported |
pinnedPostId | string, optional | |
url | string | https://x.com/<handle> |
/tools/x/profile adds engagementRate: average of likes, replies, reposts, and quotes per original post on the page, divided by followers, as a percentage with four significant digits. Reposts are excluded from the average. For an account with 90 million followers it is a small number (0.0058), which is correct, not a bug.
page
Every list is wrapped:
{ "items": [ … ], "hasMore": true, "nextCursor": "DAAHCgABHUAq3YL__-wLAAIAAAATMj…" }
| Field | Type | Meaning |
|---|---|---|
items | array | post records, or profile records on followers, or community records |
hasMore | boolean | True exactly when nextCursor is set |
nextCursor | string or null | Opaque. Pass it back as cursor= to get the next page |
Page sizes: 10 posts on profile, 12 on media, 20 on replies, search, and communities, 50 on followers, 30 trends. Cursors are opaque; some look like 20@, some like X's own DAAH… strings. Never parse them.
Trends and communities
/tools/x/trends returns { woeid, locationSlug, generatedAt, trends: [ { rank, name, postCount?, url } ] }. postCount is present only for the trends X reports a volume for; for most locations today that is none, so treat it as optional. url is the live search on x.com.
/tools/x/communities returns { query, communities: page } where each item is { id, name, description, memberCount?, access?, joinPolicy?, createdAt?, url }.
Rate-limit headers
On every response, success or error:
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1791357984
Reset is Unix seconds when the current 10-minute window ends. A search costs 2 units, everything else 1. A 429 adds Retry-After in seconds.
Errors
Every non-2xx response is:
{ "error": { "type": "not_found", "message": "X user not found: @nobody_here_123" } }
type | HTTP | When |
|---|---|---|
validation_error | 400 | Missing or malformed input (Missing handle: pass ?handle=…) |
not_found | 404 | No such account or post; suspended accounts say so in the message |
rate_limited | 429 | Free limit reached; body adds retryAfterSeconds and upgrade |
upstream_error | 502 | X's data layer answered with an error |
upstream_unavailable | 503 | Timed out or unreachable |
not_configured | 503 | The deployment has no upstream credentials |
Branch on type; message is for humans and can change.
Markdown mode
Add format=md or send Accept: text/markdown to any endpoint and the same record is rendered as Markdown: a heading with the author, the text, a line of metrics, media links, the URL, and a footer naming the next cursor. It carries every field above except the raw ids inside the author object, and it is the cheapest way to put a profile or a thread into a model's context.
Where the samples live
Each tool's Markdown page carries a captured real response for its endpoint, trimmed to a few items: /tools/x-profile-viewer/index.md, /tools/x-post-viewer/index.md, /tools/x-search/index.md, and so on. /llms.txt lists all eight with an example URL each. The metered X / Twitter API uses the same field names behind a workspace key, so code written against the free endpoints moves without edits.