# Simple format

> Opt-in compact JSON for every read endpoint. Add format=simple and get flat, camelCase tweets, users, lists, communities and jobs with a fixed schema and a cursor that is null on the last page.

HTML page: https://www.twittrapi.com/docs/simple-format

By default every endpoint returns X's raw JSON (see [Response format](https://www.twittrapi.com/docs/response-format)). That does not change. If you would rather work with small, flat objects, add `format=simple` to the query string, or send the header `X-Response-Format: simple`:

```bash
curl 'https://api.twittrapi.com/user/2244994945/tweets?count=20&format=simple' \
  --header 'X-API-KEY: YOUR_API_KEY'
```

```json
{
  "data": [
    {
      "id": "2019881223666233717",
      "url": "https://x.com/XDevelopers/status/2019881223666233717",
      "text": "Officially launching X API Pay-Per-Use … We’re so back. http://developer.x.com",
      "createdAt": "2026-02-06T21:09:45Z",
      "lang": "en",
      "author": { "id": "2244994945", "username": "XDevelopers", "name": "Developers", "...": "..." },
      "counts": { "likes": 5599, "retweets": 896, "replies": 791, "quotes": 596, "bookmarks": 2803, "views": 2552260 },
      "media": [],
      "urls": ["http://developer.x.com"],
      "hashtags": [],
      "mentions": [],
      "replyTo": null,
      "quoted": null,
      "retweetOf": null,
      "conversationId": "2019881223666233717",
      "isPinned": true,
      "source": null,
      "article": null
    }
  ],
  "nextCursor": "DAAHCgABHUS5pXT__-wLAAIAAAATMjEwMjQ0NzU2NzA0MDgyMzc2MAgAAwAAAAIAAA",
  "previousCursor": "DAAHCgABHUS5pXUAJxELAAIAAAATMjEwODk2ODM2MDY5MjQwNDI2MQgAAwAAAAEAAA",
  "hasNextPage": true
}
```

- Same price, same parameters, same errors. Only the body of a successful (200) response changes.
- The response carries the header `X-Response-Format: simple`, so you can tell which format you got.
- Every reference page has a **Raw / Simple** toggle on its example response.
- [Account actions](https://www.twittrapi.com/docs/account-actions) (`/client/*`) support it too: instead of the `{"status_code", "response"}` wrapper you get the new tweet, the user, the media ID, or `{"data": {"success": true}}` for actions such as like or bookmark. When X reports an error inside the wrapper, the wrapper is returned unchanged. [Direct messages](https://www.twittrapi.com/docs/account-actions#direct-messages) (`/client/dm/*`) return conversations, messages and settings in this format. `/premium/*` ignores the option.

## Envelope

Lists and timelines:

```json
{ "data": [ ... ], "nextCursor": "…" | null, "previousCursor": "…" | null, "hasNextPage": true | false }
```

Single objects:

```json
{ "data": { ... } }
```

`data` is `null` when X returns nothing for the ID or handle (for example `GET /user/by/username/{username}` for a handle that does not exist).

## Pagination

Pass `nextCursor` as `cursor` to get the next page. `nextCursor` is `null`, and `hasNextPage` is `false`, when there is nothing more to fetch:

- the page has no items,
- X returns the same cursor you sent,
- X's end marker (follower and following lists answer `0|…` on the last page).

So this loop always ends:

```python
import requests

cursor, tweets = None, []
while True:
    params = {"count": 20, "format": "simple", **({"cursor": cursor} if cursor else {})}
    page = requests.get("https://api.twittrapi.com/user/12/tweets", params=params,
                        headers={"X-API-KEY": "YOUR_API_KEY"}).json()
    tweets += page["data"]
    if not page["hasNextPage"]:
        break
    cursor = page["nextCursor"]
```

`previousCursor` is X's top cursor: use it to poll for newer items.

## Rules

- Field names are camelCase and **always present**. A value X did not send is `null`; a list X did not send is `[]`. Counts X does not send are `0`.
- IDs are strings. Times are ISO 8601 in UTC (`2026-02-06T21:09:45Z`).
- Promoted tweets are dropped. Conversation threads (modules) are flattened in display order. A tweet that appears twice on a page is listed once.
- Tweets X does not return (deleted, suspended author, withheld) appear as `{"id": "…", "unavailable": true}` when their ID is known, and are left out otherwise. The same placeholder is used for `quoted`.
- Error responses (any status other than 200) are unchanged, see [Errors](https://www.twittrapi.com/docs/helpful-infos/error-handling). A 200 response that only holds X's `errors` (no `data`) is also returned raw, so you see X's message.
- New fields may be added. Existing fields are not renamed or removed.

## Tweet

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | |
| `url` | string | `https://x.com/<username>/status/<id>` |
| `text` | string | Full text: the long version of long posts (note tweets), t.co links replaced by their targets, the trailing media link removed, `&amp;` `&lt;` `&gt;` decoded. Retweets: `RT @user: ` plus the full original text. |
| `createdAt` | string \| null | ISO 8601 |
| `lang` | string \| null | X's language code (`en`, `und`, …) |
| `author` | [User](#user) \| null | |
| `counts` | object | `likes`, `retweets`, `replies`, `quotes`, `bookmarks`, `views` (`views` is `null` when X hides it) |
| `media` | [Media](#media)[] | |
| `urls` | string[] | Expanded URLs in the text |
| `hashtags` | string[] | Without `#` |
| `mentions` | `{id, username}`[] | |
| `replyTo` | object \| null | `{tweetId, userId, username}` when the tweet is a reply |
| `quoted` | Tweet \| `{id, unavailable}` \| null | One level: the quoted tweet's own `quoted` is `null` |
| `retweetOf` | Tweet \| null | The original tweet of a retweet (with its `quoted`) |
| `conversationId` | string \| null | ID of the first tweet of the thread |
| `isPinned` | boolean | `true` for the pinned tweet at the top of a profile timeline |
| `source` | string \| null | App name, when X sends it (`Twitter Web App`) |
| `article` | object \| null | X Article: `{id, title, previewText, coverImageUrl, publishedAt, text}` |

## Media

| Field | Type | Notes |
| --- | --- | --- |
| `type` | string | `photo`, `video` or `gif` |
| `url` | string | Photos: the image. Videos and GIFs: the MP4 with the highest bitrate. |
| `previewUrl` | string | The image (poster frame for videos) |
| `width`, `height` | number | Original size |
| `altText` | string \| null | |
| `durationMs` | number \| null | Videos only |

## User

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | |
| `username` | string | Handle without `@` |
| `name` | string | Display name |
| `description` | string | Bio, t.co links expanded |
| `location` | string | As written by the user |
| `url` | string \| null | Website, expanded |
| `createdAt` | string \| null | ISO 8601 |
| `avatarUrl` | string \| null | Full size (not the 48 px `_normal` version) |
| `bannerUrl` | string \| null | |
| `verified` | boolean | Blue check |
| `verifiedType` | string \| null | e.g. `Business` or `Government`; `null` for none |
| `protected` | boolean | Private account |
| `counts` | object | `followers`, `following`, `tweets`, `likes`, `media`, `listed` |
| `pinnedTweetIds` | string[] | |
| `isAutomated` | boolean | Account carries X's "Automated" label |

`GET /user/about/{username}` adds `about`: `accountBasedIn`, `locationAccurate`, `createdCountryAccurate`, `source`, `usernameChanges`, `lastUsernameChangeAt`, `affiliateUsername`, `identityVerified`, `verifiedSince`.

## List

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `name`, `description` | string | |
| `createdAt` | string \| null | ISO 8601 |
| `memberCount`, `followerCount` | number | |
| `private` | boolean | |
| `bannerUrl` | string \| null | |
| `owner` | `{id, username}` \| null | |

## Community

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `name`, `description` | string | `description` is empty in search results |
| `createdAt` | string \| null | ISO 8601 |
| `memberCount`, `moderatorCount` | number | |
| `joinPolicy` | string \| null | As X sends it, e.g. `Open` |
| `nsfw` | boolean | |
| `topic` | string \| null | |
| `bannerUrl` | string \| null | |
| `creator` | `{id, username}` \| null | |
| `rules` | `{id, name, description}`[] | |

## Job

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `title` | string | |
| `location`, `url`, `description` | string \| null | `url` is the employer's application page |
| `company` | `{id, name, logoUrl}` \| null | |
| `postedBy` | `{id, username}` \| null | |

`GET /jobs/find-location/{term}` returns `{id, name}` items to use as the job search location.

## Trends

`GET /trends/{locationId}`: items `{name, query, url, tweetVolume}`; `query` is URL-decoded (`#GoLang`), `tweetVolume` is `null` when X has no count. `GET /misc/locations`: items `{woeid, name, country, countryCode, placeType, parentId}`.

## Endpoints

| Endpoint | `data` |
| --- | --- |
| `/search` (and its aliases), `/tweet/{tweetId}`, `/tweet/{tweetId}/article`, `/tweet/{tweetId}/quotes`, `/user/{userId}/tweets`, `/tweetsAndReplies`, `/media`, `/likes`, `/articles`, `/communities/{communityId}/tweets`, `/lists/tweets` | Tweet[] (search with `category=People`: User[]) |
| `/simpletweet/{tweetId}` | Tweet |
| `/tweet/{tweetId}/favorites`, `/retweets`, `/user/{userId}/followers`, `/followings`, `/verifiedFollowers`, `/user/users/by/ids`, `/communities/{communityId}/members`, `/members/search`, `/moderators`, `/lists/members`, `/lists/followers` | User[] |
| `/user/by/username/{username}`, `/user/{userId}` | User |
| `/user/about/{username}` | User with `about` |
| `/user/friendships/show` | `{source, target, following, followedBy, canDm, blocking, blockedBy, muting}` |
| `/communities/search/{searchTerm}` | Community[] |
| `/communities/{communityId}` | Community |
| `/lists/details` | List |
| `/jobs/search/{searchTerm}` | Job[] |
| `/jobs/{jobId}` | Job |
| `/jobs/find-location/{term}` | `{id, name}`[] |
| `/trends/{locationId}` | Trend[] |
| `/misc/locations` | TrendLocation[] |
| `/username/to/id/{username}`, `/user/usernames/to/ids`, `/user/usernames/status`, `/misc/emojis` | The usual response, unchanged, inside `{"data": ...}`. These are already small (or, for hashflags, a flat list), so there is nothing to simplify. |
| `POST /client/create`, `/client/reply` | Tweet (a scheduled `/client/create`: `{success: true, scheduledTweetId}`) |
| `POST /client/home-timeline`, `/client/bookmarks` | Tweet[] |
| `POST /client/schedule-overview` | `{id, text, executeAt, state, mediaIds}`[] |
| `POST /client/follow`, `/client/profile`, `/client/profile/image`, `/client/profile/banner` | User |
| `POST /client/media`, `/client/media/video` | `{mediaId, mediaKey, expiresAfterSecs}` |
| `POST /client/communities/create` | Community |
| `POST /client/retweet` | `{success: true, retweetId}` |
| `POST /client/like`, `/client/deleteTweet`, `/client/bookmark`, `/client/unbookmark`, `/client/schedule-delete`, `/client/report`, `/client/communities/remove` | `{success: true}` |
| `POST /client/dm/*` | Conversations, messages and DM settings; the fields are on each [endpoint page](https://www.twittrapi.com/docs/account-actions#direct-messages). |

## Size and speed

Across the 9 captured endpoints the simple format is **64 % smaller in bytes** (median, range 58–70 %) and uses **61 % fewer input tokens** (median, range 52–68 %). Tokens shrink a little less than bytes because the fields that are dropped are mostly long, repetitive keys and IDs, which tokenize efficiently.

| Endpoint | Raw bytes | Simple bytes | Bytes saved | Raw tokens | Simple tokens | Tokens saved |
| --- | --- | --- | --- | --- | --- | --- |
| `/user/{userId}/tweetsAndReplies` | 81.7 KB | 24.2 KB | 70 % | 23,009 | 7,274 | 68 % |
| `/user/{userId}/tweets` | 72.2 KB | 22.8 KB | 68 % | 20,484 | 6,866 | 66 % |
| `/tweet/{tweetId}/quotes` | 51.7 KB | 21.1 KB | 59 % | 14,008 | 6,444 | 54 % |
| `/user/{userId}/media` | 49.3 KB | 16.3 KB | 67 % | 14,883 | 4,919 | 67 % |
| `/communities/{communityId}/tweets` | 40.4 KB | 14.5 KB | 64 % | 11,296 | 4,392 | 61 % |
| `/tweet/{tweetId}` | 38.7 KB | 15.0 KB | 61 % | 10,601 | 4,669 | 56 % |
| `/search` | 34.3 KB | 14.4 KB | 58 % | 9,582 | 4,556 | 52 % |
| `/user/{userId}/followers` | 9.1 KB | 2.8 KB | 70 % | 2,272 | 801 | 65 % |
| `/user/{userId}/articles` | 8.6 KB | 3.3 KB | 62 % | 2,263 | 1,009 | 55 % |

How this was measured (2026-10-11): one real captured response per endpoint (anonymized and shortened to a few items, so a full page with the default `count` is several times larger in both formats), converted with the same code the API runs. Both formats are minified JSON. Tokens are counted with OpenAI's `o200k_base` tokenizer (`tiktoken`, used by GPT-4o and later); with `cl100k_base` the median is 61 %. Claude and Gemini use their own tokenizers, so exact counts differ, but the reduction is of the same size because it comes from the fields that are left out.

The conversion runs on our side in about half a millisecond for a 300 KB timeline, so it does not add noticeable latency.

What the smaller responses mean in practice: context windows on [Twitter/X data for AI agents](https://www.twittrapi.com/use-cases/ai-agents), and storage for datasets on [collecting X data for research](https://www.twittrapi.com/use-cases/research).

## When to use which

Use the simple format when you need the common fields and want code that does not break when X reshuffles its JSON. Use the raw format when you need something the simple format leaves out (cards, polls, edit history, community notes, X's ranking metadata): it has every field X sends.
