# xdataapi.io

> Read-only HTTP API for public X (Twitter) data: profiles, tweets, timelines, threads, replies, quotes and search.
> One credit is one tweet or one profile returned. Empty results and failed requests cost nothing.
> Credits are bought once and never expire. Base URL: https://api.xdataapi.io. Auth: header x-api-key.

Give an agent everything in one line: MCP server at https://api.xdataapi.io/mcp (Streamable HTTP, header x-api-key).
Machine-readable contract: https://api.xdataapi.io/openapi.yaml (OpenAPI 3.1). Live health: https://api.xdataapi.io/status (JSON) and https://xdataapi.io/status.
SDKs: npm xdataapi (TypeScript), PyPI xdataapi (Python).
Every response has credits_charged, balance_remaining, cache and request_id. Errors are JSON with a stable code.


---

# Quickstart

Source: https://xdataapi.io/docs

xdataapi.io is a read-only HTTP API for public X (Twitter) data: profiles, tweets, timelines, threads and search.
One credit is one tweet or one profile returned. Empty results and failed requests cost nothing.

## 1. Get a key [#1-get-a-key]

Sign in at [xdataapi.io/dashboard](https://xdataapi.io/dashboard) with your email. No password: you get a six-digit
code. Create a key there. Every new account starts with 5,000 free credits.

## 2. Make a request [#2-make-a-request]

Send the key in the `x-api-key` header. All endpoints are `GET`.

```bash
curl "https://api.xdataapi.io/v1/users/x" \
  -H "x-api-key: xd_live_..."
```

```json
{
  "data": {
    "id": "783214",
    "handle": "X",
    "name": "X",
    "followers": 60738793,
    "created_at": "2007-02-20T14:35:54Z"
  },
  "items": 1,
  "credits_charged": 1,
  "balance_remaining": 4999,
  "cache": "miss",
  "request_id": "3d4f3d1c-..."
}
```

Every response carries `items`, `credits_charged`, `balance_remaining`, `cache` and `request_id`.
Keep `request_id` when you report a problem.

## 3. Search [#3-search]

```bash
curl "https://api.xdataapi.io/v1/tweets/search?q=from:x%20since:2026-09-01&count=20" \
  -H "x-api-key: xd_live_..."
```

`q` takes the same operators as the search box on x.com. See [Search](/docs/search).

## Endpoints [#endpoints]

| Endpoint                             | Returns                           | Credits                                           |
| ------------------------------------ | --------------------------------- | ------------------------------------------------- |
| `GET /v1/users/{handle}`             | one profile                       | 1                                                 |
| `GET /v1/users/{user}/tweets`        | latest tweets of a user           | 1 per tweet                                       |
| `GET /v1/tweets/{id}`                | one tweet                         | 1                                                 |
| `GET /v1/tweets/{id}/thread`         | the tweet, its thread and replies | 1 per tweet                                       |
| `GET /v1/tweets/{id}/replies`        | direct replies to a tweet         | 1 per reply                                       |
| `GET /v1/tweets/{id}/quotes`         | tweets that quote it              | 1 per tweet                                       |
| `GET /v1/tweets/{id}/retweeters`     | accounts that retweeted it        | 0.5 per profile                                   |
| `POST /v1/users/batch`               | up to 100 profiles                | 1 per profile                                     |
| `POST /v1/tweets/batch`              | up to 100 tweets                  | 1 per tweet                                       |
| `GET /v1/tweets/search`              | advanced search                   | 1 per tweet, or per profile with `product=People` |
| `GET /v1/users/{user}/followers`     | followers                         | 0.1 per profile                                   |
| `GET /v1/users/{user}/followers/ids` | follower ids only, no profiles    | 0.02 per id                                       |
| `GET /v1/users/{user}/following`     | accounts followed                 | 0.1 per profile                                   |
| `GET /v1/me`                         | your balance and rate limit       | 0                                                 |

`{user}` accepts a numeric id or a handle. The full contract is in the [API reference](/reference).


---

# Authentication

Source: https://xdataapi.io/docs/authentication

## The key [#the-key]

A key looks like `xd_live_` followed by 32 characters. It is shown once when created. Only a hash is stored on our side.
Send it in the `x-api-key` header, or as `Authorization: Bearer xd_live_...`.

A key is tied to one wallet. Several keys can share a wallet, for example one per environment.

## Rate limit [#rate-limit]

The limit is per account, shared by all its keys. It follows the total you have paid over the life of the
account, and it only goes up.

| Total paid   | Requests per second |
| ------------ | ------------------- |
| $0           | 1                   |
| $10 or more  | 20                  |
| $50 or more  | 50                  |
| $200 or more | 100                 |
| $600 or more | 200                 |

Five starter packs count the same as one builder pack. A small pack after a large one changes nothing.
Above the limit the API answers `429` with a `retry-after` header. Nothing is charged. Need more?
Write to [hello@xdataapi.io](mailto:hello@xdataapi.io).

### Read the headers instead of guessing [#read-the-headers-instead-of-guessing]

Every response carries the limit and what is left of it, so a client never has to
discover its ceiling by hitting it (RFC 9331):

```http
RateLimit: limit=20, remaining=19, reset=1
RateLimit-Policy: "per-second"; q=20; w=1
```

These are on refusals too, and on a `401`, where `RateLimit-Policy` quotes the rate
an account starts on. A `429` adds `Retry-After` in whole seconds.

The keyless endpoints — `GET /`, `/status`, `/healthz` and `/openapi.yaml` on
`api.xdataapi.io` — have their own ceiling of 10 requests per second per address,
reported under the policy name `public`. No key is needed for them and none is
counted against your account.

The limit counts requests. Cached and batch requests run at the full rate. Fresh data comes from X at
the pace of the account pool behind the API, so a burst of many distinct uncached reads takes longer
than the rate limit alone would suggest. Measured on 2026-09-20, shared by all customers:

| Fresh reads                           | Per second |
| ------------------------------------- | ---------- |
| Tweets, profiles, timelines           | about 30   |
| Search, threads, followers, following | about 12   |

These are floors from a burst test, and they rise as the pool grows. A response served from cache does
not count against them, and costs half.

## Check a key [#check-a-key]

```bash
curl https://api.xdataapi.io/v1/me -H "x-api-key: xd_live_..."
```

```json
{ "key_prefix": "xd_live_GPE4", "balance": 4895.5, "rate_limit_qps": 25 }
```

## Keep it secret [#keep-it-secret]

Never ship the key in a browser or a mobile app. Call the API from your server and put your own auth in front.
Revoke a leaked key in the [dashboard](https://xdataapi.io/dashboard) and create a new one.


---

# Credits and billing

Source: https://xdataapi.io/docs/credits

One credit is one tweet or one profile returned to you. You buy credits in packs and spend them at any pace.

## Packs [#packs]

| Pack    | Price | Credits     | Per 1,000 |
| ------- | ----- | ----------- | --------- |
| Free    | $0    | 5,000, once | $0        |
| Starter | $10   | 80,000      | $0.125    |
| Builder | $50   | 500,000     | $0.100    |
| Growth  | $200  | 2,500,000   | $0.080    |
| Scale   | $600  | 10,000,000  | $0.060    |

Enterprise packs from $2,000 with invoice billing and a signed SLA: [hello@xdataapi.io](mailto:hello@xdataapi.io).

## Buying a pack [#buying-a-pack]

Card checkout runs on Stripe. Ask the API for a checkout link and open it:

```bash
curl -X POST https://api.xdataapi.io/v1/billing/checkout \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{"pack":"builder"}'
```

```json
{ "url": "https://checkout.stripe.com/c/pay/cs_...", "pack": "builder", "usd": 50, "credits": 500000 }
```

The credits land in the wallet within seconds of payment, and the rate limit rises to the pack's. Check with `/v1/me`.
Invoice billing for Enterprise: [hello@xdataapi.io](mailto:hello@xdataapi.io).

## Rules [#rules]

* **Credits never expire.** Paid or free, they stay in the wallet until you spend them.
* **No per-call minimum.** A request that returns nothing costs nothing.
* **Failed requests are free.** Any status other than `200` charges zero.
* **Cache hits cost half.** Responses are cached for a short time per endpoint; a hit is marked `"cache": "hit"`.
* **`fresh=true` bypasses the cache** at double the price.
* A charge never takes the balance below zero. At zero the API answers `402` with a link to buy more.
* One wallet per account. All packs land in the same balance; the oldest credits are spent first.
* Full refund of an unused pack within 14 days, on request to [hello@xdataapi.io](mailto:hello@xdataapi.io).

## Price per item [#price-per-item]

| Item                               | Credits           |
| ---------------------------------- | ----------------- |
| Tweet from any endpoint            | 1                 |
| Profile                            | 1                 |
| Follower or following entry        | 0.1               |
| Follower id, from the ids endpoint | 0.02              |
| Retweeter entry                    | 0.5               |
| Cached item                        | half of the above |
| `fresh=true` item                  | double the above  |
| Empty result, error, `404`, `403`  | 0                 |

## Reading the bill [#reading-the-bill]

Every response has `credits_charged` and `balance_remaining`. The same numbers are in the `x-credits-charged` and
`x-balance-remaining` headers, so a proxy can meter without parsing the body.

## SLA credit [#sla-credit]

For each full hour where the [status page](/status) shows success under 99.9 percent, every customer who made a failed
request in that hour gets 2 percent of their last pack back as credits. Applied automatically. The number that counts is
the outside probe, which calls the public API every minute from a different network.


---

# Pagination

Source: https://xdataapi.io/docs/pagination

List endpoints return a page and, when there is more, a `next_cursor`. Pass it back as `cursor` to get the next page.

```bash
curl "https://api.xdataapi.io/v1/users/x/tweets?count=20" -H "x-api-key: $KEY"
# ... "next_cursor": "DAABCgABG..."
curl "https://api.xdataapi.io/v1/users/x/tweets?count=20&cursor=DAABCgABG..." -H "x-api-key: $KEY"
```

* Cursors are opaque strings. Do not parse or store them for longer than a session.
* A page without `next_cursor` is the last one.
* A page holds exactly `count` items while there are that many, from 1 up to 100 for timelines,
  50 for search and 200 for followers. You are charged for the items you receive.
* Cursors can point into the middle of a page we already hold; the next call is then served
  from cache at half price. Pass them back as they are.
* Timelines are newest first. Search with `product=Latest` is newest first; `Top` is by relevance where available.


---

# Search

Source: https://xdataapi.io/docs/search

`GET /v1/tweets/search?q=...` runs X advanced search. `q` accepts the operators from the x.com search box.

## Operators that work [#operators-that-work]

| Operator                                                      | Example                             | Meaning                       |
| ------------------------------------------------------------- | ----------------------------------- | ----------------------------- |
| `from:`                                                       | `from:x`                            | tweets by an account          |
| `to:`                                                         | `to:x`                              | replies to an account         |
| `@`                                                           | `@x`                                | mentions                      |
| `since:` `until:`                                             | `since:2026-09-01 until:2026-09-17` | date range, UTC               |
| `min_faves:` `min_retweets:` `min_replies:`                   | `min_faves:100`                     | engagement floor              |
| `-filter:replies`                                             |                                     | no replies                    |
| `filter:links` `filter:media` `filter:images` `filter:videos` |                                     | only tweets with that content |
| `lang:`                                                       | `lang:de`                           | language                      |
| `"..."`                                                       | `"exact phrase"`                    | phrase                        |
| `OR` `-` `()`                                                 | `(cat OR dog) -filter:retweets`     | boolean                       |
| `url:`                                                        | `url:github.com`                    | link domain                   |

## Products [#products]

| `product`          | Returns                | Credits       |
| ------------------ | ---------------------- | ------------- |
| `Latest` (default) | tweets, newest first   | 1 per tweet   |
| `People`           | profiles               | 1 per profile |
| `Top`              | tweets by relevance    | 1 per tweet   |
| `Photos`, `Videos` | tweets with that media | 1 per tweet   |

`Top` is not served to every reading session at X. When it is not available the API answers `403 product_unavailable`
and charges nothing. `Latest` is always available.

## Limits [#limits]

* Up to 50 tweets per page. Use `cursor` for more.
* Search results are cached for 60 seconds. A repeated query within that window costs half.
* Deleted, protected and age-restricted tweets do not appear.


---

# Batch reads

Source: https://xdataapi.io/docs/batch

Two endpoints take a list instead of one handle or id. They cost the same per object as the single endpoints,
answer in one round trip, and never charge for an input that returned nothing.

| Endpoint                | Input     | Max | Credits             |
| ----------------------- | --------- | --- | ------------------- |
| `POST /v1/users/batch`  | `handles` | 100 | 1 per profile found |
| `POST /v1/tweets/batch` | `ids`     | 100 | 1 per tweet found   |

Both also accept `GET` with a comma list: `/v1/users/batch?handles=x,github,vercel`.

## Request [#request]

```bash
curl -X POST https://api.xdataapi.io/v1/users/batch \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{"handles": ["x", "github", "nobody_here_12345"]}'
```

```json
{
  "data": [
    { "id": "783214", "handle": "X", "followers": 60738793, "...": "..." },
    { "id": "13334762", "handle": "github", "followers": 2900000, "...": "..." }
  ],
  "errors": [
    { "input": "nobody_here_12345", "code": "not_found", "message": "no user" }
  ],
  "items": 2,
  "credits_charged": 2,
  "balance_remaining": 4998,
  "cache": "miss",
  "cache_hits": 0,
  "request_id": "3d4f3d1c-..."
}
```

## Rules [#rules]

* `data` holds the objects that were found, in input order. Every input that returned nothing is in `errors`
  with a `code`: `not_found`, `access_denied`, `upstream_error`, or `no_credits`.
* Duplicates are folded. `@x` and `X` are the same handle.
* The cache is shared with the single endpoints. An object read in the last 10 minutes (profiles) or 5 minutes
  (tweets) is served from cache at half price; `cache_hits` says how many, and `cache` is `mixed` when a batch
  has both.
* When the balance covers only part of the list, the API fetches what it can pay for and returns the rest as
  `no_credits`. Send those again after a top-up.
* When nothing is found and every failure is transient, the answer is `503` with `retry-after`, and nothing is
  charged.
* `fresh=true` bypasses the cache for the whole list, at 2x.

Batch by numeric user id is not available yet. Resolve ids through `/v1/users/{id}/tweets?count=20` or ask
[hello@xdataapi.io](mailto:hello@xdataapi.io) if you need it.


---

# MCP server

Source: https://xdataapi.io/docs/mcp

xdataapi.io serves a hosted [MCP](https://modelcontextprotocol.io) server at `https://api.xdataapi.io/mcp`.
Transport is Streamable HTTP. Nothing to install, nothing to run, and on most clients nothing to paste:
the server implements the MCP authorization flow, so a client signs you in through a browser and gets
its own credential. A key in the header works too, and is what a client with no browser needs.

Every tool call is one REST request under the hood, at the same price, through the same cache and rate limit.
The agent sees `credits_charged` and `balance_remaining` in every result.

## Connect [#connect]

**Claude Code**

```bash
claude mcp add --transport http xdataapi https://api.xdataapi.io/mcp
```

**Claude.ai and Claude Desktop**

Add `https://api.xdataapi.io/mcp` as a custom connector in Settings. These clients have no field for a
request header, so this is the only route that reaches them.

**Cursor, Windsurf, and other clients with an MCP config file**

```json
{
  "mcpServers": {
    "xdataapi": {
      "url": "https://api.xdataapi.io/mcp"
    }
  }
}
```

**Clients that speak stdio only**

The [mcp-remote](https://www.npmjs.com/package/mcp-remote) bridge speaks stdio to the client and
Streamable HTTP to us, and signs in on the client's behalf.

```json
{
  "mcpServers": {
    "xdataapi": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.xdataapi.io/mcp"]
    }
  }
}
```

## Connect with a key instead [#connect-with-a-key-instead]

A client that cannot open a browser — a server, a container, a CI job — sends the key in the same header
the REST API takes, or as `Authorization: Bearer xd_live_...`.

```bash
claude mcp add --transport http xdataapi https://api.xdataapi.io/mcp --header "x-api-key: xd_live_..."
```

```json
{
  "mcpServers": {
    "xdataapi": {
      "url": "https://api.xdataapi.io/mcp",
      "headers": { "x-api-key": "xd_live_..." }
    }
  }
}
```

For mcp-remote, pass the key through the environment. The header is written with no space after the
colon: mcp-remote splits the argument on the first colon, and a space there arrives as part of the key.

```json
{
  "mcpServers": {
    "xdataapi": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.xdataapi.io/mcp", "--header", "x-api-key:${XDATAAPI_KEY}"],
      "env": { "XDATAAPI_KEY": "xd_live_..." }
    }
  }
}
```

## Signing in, for people writing clients [#signing-in-for-people-writing-clients]

An unauthenticated call to `/mcp` answers `401` with a pointer to the discovery chain, which is all a
client needs to obtain its own credential:

| Step                            | Where                                                          |
| ------------------------------- | -------------------------------------------------------------- |
| The challenge                   | `WWW-Authenticate: Bearer resource_metadata="..."` on the 401  |
| Protected resource (RFC 9728)   | `https://api.xdataapi.io/.well-known/oauth-protected-resource` |
| Authorization server (RFC 8414) | `https://xdataapi.io/.well-known/oauth-authorization-server`   |
| Register a client (RFC 7591)    | `https://xdataapi.io/api/oauth/register`                       |
| Approve                         | `https://xdataapi.io/oauth/authorize`                          |
| Exchange the code               | `https://xdataapi.io/api/oauth/token`                          |
| Disconnect (RFC 7009)           | `https://xdataapi.io/api/oauth/revoke`                         |

Clients are public and PKCE is required, with `S256` only. There are no scopes: every tool is read-only
and there is one level of access, so a scope list would be several words that all mean the same thing.

What the exchange returns is an ordinary API key named after the app. It appears in the
[dashboard](https://xdataapi.io/dashboard/keys) beside keys you made by hand, spends the same balance
at the same prices, and stops the moment you revoke it. It does not expire, and there is no refresh
token: it is the same class of credential you mint yourself, with the same revocation story.

## Tools [#tools]

| Tool               | Arguments                         | Credits                |
| ------------------ | --------------------------------- | ---------------------- |
| `get_user`         | `handle`                          | 1                      |
| `get_users`        | `handles[]`, up to 100            | 1 per profile found    |
| `get_user_tweets`  | `user`, `count`, `cursor`         | 1 per tweet            |
| `get_followers`    | `user`, `count`, `cursor`         | 0.1 per profile        |
| `get_follower_ids` | `user`, `count`, `cursor`         | 0.02 per id            |
| `get_following`    | `user`, `count`, `cursor`         | 0.1 per profile        |
| `search_tweets`    | `q`, `product`, `count`, `cursor` | 1 per tweet or profile |
| `get_tweet`        | `id`                              | 1                      |
| `get_tweets`       | `ids[]`, up to 100                | 1 per tweet found      |
| `get_thread`       | `id`, `cursor`                    | 1 per tweet            |
| `get_replies`      | `id`, `cursor`                    | 1 per reply            |
| `get_quotes`       | `id`, `count`, `cursor`           | 1 per tweet            |
| `get_retweeters`   | `id`, `count`, `cursor`           | 0.5 per profile        |
| `get_balance`      |                                   | free                   |

Every tool takes `fresh: true` to bypass the cache at 2x. All tools are read-only and idempotent, and are
annotated as such, so clients that auto-approve read-only tools do not prompt on every call.

## Notes [#notes]

* The server is stateless. Each request carries the key; there is no session to expire.
* A REST error (`not_found`, `no_credits`, `rate_limited`) comes back as a tool error with the same JSON body,
  so the agent can read the `code` and act on it.
* Results are the same flat `Tweet` and `User` objects as the REST API, as JSON text.
* Keys are per account. Create one key per agent in the [dashboard](https://xdataapi.io/dashboard) so you can
  revoke it alone.


---

# For agents

Source: https://xdataapi.io/docs/agents

Everything an agent needs is a URL away. Pick the one that fits the client.

| Need                          | URL                                                                    |
| ----------------------------- | ---------------------------------------------------------------------- |
| Docs, short index             | `https://xdataapi.io/llms.txt`                                         |
| Docs, everything in one file  | `https://xdataapi.io/llms-full.txt`                                    |
| One docs page as Markdown     | add `.md` to its URL, for example `https://xdataapi.io/docs/search.md` |
| Contract, OpenAPI 3.1         | `https://api.xdataapi.io/openapi.yaml`                                 |
| Tools, MCP server             | `https://api.xdataapi.io/mcp` (signs itself in, or header `x-api-key`) |
| Live health, JSON             | `https://api.xdataapi.io/status`                                       |
| Directory of all of the above | `https://api.xdataapi.io/`                                             |

Every docs page also answers a request with `Accept: text/markdown` in Markdown.

## Prompt to paste [#prompt-to-paste]

```text
Use the xdataapi.io API to read public X (Twitter) data. Docs: https://xdataapi.io/llms-full.txt
Base URL https://api.xdataapi.io, header x-api-key: xd_live_... Every response has credits_charged and
balance_remaining; stop when balance_remaining is 0. Errors are JSON with a stable "code" field.
Pages return next_cursor; pass it back as cursor.
```

## MCP in one line [#mcp-in-one-line]

```bash
claude mcp add --transport http xdataapi https://api.xdataapi.io/mcp
```

No key in it: the server signs the client in through a browser and it gets its own credential. Other
clients, and the header form for a machine with no browser: see [MCP server](/docs/mcp). The 14 tools are
read-only and annotated as such, so clients that auto-approve read-only tools do not prompt on every call.

## Why the API is easy for agents [#why-the-api-is-easy-for-agents]

* **Flat objects.** A `Tweet` and a `User` are the same shape on every endpoint. No nested `legacy` or `result` wrappers.
* **Stable error codes.** `no_credits`, `not_found`, `rate_limited`, `access_denied`, `upstream_error`. The
  [errors page](/docs/errors) says what to do for each. Failed requests cost nothing.
* **Costs in the response.** `credits_charged` and `balance_remaining` on every call, so an agent can budget without
  a second request.
* **Cursors, not page numbers.** `next_cursor` is opaque; pass it back as `cursor`. No cursor means the end.
* **Batch.** Up to 100 handles or ids in one call, one charge, errors per item. See [batch](/docs/batch).
* **Idempotent reads.** Everything is a read. Retrying is always safe; a repeat inside the cache window costs half.
* **A key per agent.** Make one key per agent in the [dashboard](https://xdataapi.io/dashboard) so one can be revoked alone.


---

# SDKs

Source: https://xdataapi.io/docs/sdks

Both clients are generated from the API's OpenAPI document, so they carry every endpoint, parameter and
response type, and they update with the API. They are thin: one HTTP call per method, no retries or state,
the same `credits_charged` and `balance_remaining` fields as the raw API.

## TypeScript [#typescript]

```bash
npm install xdataapi
```

```ts
import { xdataapi } from 'xdataapi';

const api = xdataapi({ apiKey: process.env.XDATAAPI_KEY! });

const { data: profile } = await api.getUser({ path: { handle: 'x' } });
console.log(profile?.data?.followers, profile?.credits_charged);

const { data: page } = await api.searchTweets({ query: { q: 'from:x since:2026-09-01', count: 20 } });
for (const t of page?.data ?? []) console.log(t.id, t.text);
```

Every call returns `{ data, error, response }`. Node 18+, Bun, Deno, browsers, edge runtimes.

## Python [#python]

```bash
pip install xdataapi
```

```python
from xdataapi import AuthenticatedClient
from xdataapi.api.users import get_user
from xdataapi.api.tweets import search_tweets

client = AuthenticatedClient(base_url="https://api.xdataapi.io", token=KEY, prefix="", auth_header_name="x-api-key")

profile = get_user.sync(client=client, handle="x")
print(profile.data.followers, profile.credits_charged)

page = search_tweets.sync(client=client, q="from:x since:2026-09-01", count=20)
for t in page.data:
    print(t.id, t.text)
```

Every operation has `sync`, `sync_detailed`, `asyncio` and `asyncio_detailed` forms.

## Methods [#methods]

| Method                                | Endpoint                             |
| ------------------------------------- | ------------------------------------ |
| `getMe` / `get_me`                    | `GET /v1/me`                         |
| `getUser` / `get_user`                | `GET /v1/users/{handle}`             |
| `getUsers` / `get_users`              | `POST /v1/users/batch`               |
| `getUserTweets` / `get_user_tweets`   | `GET /v1/users/{user}/tweets`        |
| `getFollowers` / `get_followers`      | `GET /v1/users/{user}/followers`     |
| `getFollowerIds` / `get_follower_ids` | `GET /v1/users/{user}/followers/ids` |
| `getFollowing` / `get_following`      | `GET /v1/users/{user}/following`     |
| `searchTweets` / `search_tweets`      | `GET /v1/tweets/search`              |
| `getTweet` / `get_tweet`              | `GET /v1/tweets/{id}`                |
| `getTweets` / `get_tweets`            | `POST /v1/tweets/batch`              |
| `getThread` / `get_thread`            | `GET /v1/tweets/{id}/thread`         |
| `getReplies` / `get_replies`          | `GET /v1/tweets/{id}/replies`        |
| `getQuotes` / `get_quotes`            | `GET /v1/tweets/{id}/quotes`         |
| `getRetweeters` / `get_retweeters`    | `GET /v1/tweets/{id}/retweeters`     |
| `listPacks`, `createCheckout`         | billing                              |

Other languages: the [OpenAPI document](https://api.xdataapi.io/openapi.yaml) works with any generator, and
the [MCP server](/docs/mcp) covers agents without code.


---

# Errors

Source: https://xdataapi.io/docs/errors

Errors are JSON with a stable `code`, a human `message` and the `request_id`. No error is charged.

```json
{ "error": { "code": "no_credits", "message": "balance is zero; buy a credit pack" }, "request_id": "..." }
```

Match on `code`, never on `message`. The code is part of the contract and will
not change under you; the message is written for a person and may be reworded.

| Status | Code                                                   | Meaning                                             | What to do                                      |
| ------ | ------------------------------------------------------ | --------------------------------------------------- | ----------------------------------------------- |
| 400    | `bad_request`                                          | a parameter is missing or malformed                 | fix the request                                 |
| 401    | `missing_key`, `invalid_key`                           | no key, or a revoked key                            | check the header                                |
| 402    | `no_credits`                                           | balance is zero                                     | buy a pack                                      |
| 403    | `access_denied`                                        | X does not serve this object to the reading session | try later, or a different object                |
| 403    | `product_unavailable`                                  | search product not available, for example `Top`     | use `Latest`                                    |
| 404    | `not_found`                                            | no such user or tweet, or it is protected           | nothing                                         |
| 429    | `rate_limited`                                         | over your requests-per-second limit                 | wait `retry-after` seconds                      |
| 503    | `upstream_rate_limited`, `no_account`, `busy`          | X or our pool is saturated right now                | retry after `retry-after` seconds, with backoff |
| 502    | `upstream_error`, `query_id_stale`, `request_rejected` | X changed something on its side                     | retry once; if it persists we are already paged |
| 500    | `internal`                                             | our bug                                             | send us the `request_id`                        |

## RFC 9457 problem+json [#rfc-9457-problemjson]

Send `Accept: application/problem+json` and the same failure comes back in the
shape a generic HTTP client already understands. Nothing new is reported: `type`,
`title`, `status` and `detail` are the registered names for what `error.code` and
`error.message` already said, and the native `error` object is still there, so one
parser reads either shape.

```json
{
  "type": "https://xdataapi.io/docs/errors#no_credits",
  "title": "Balance is zero",
  "status": 402,
  "detail": "balance is zero; buy a credit pack",
  "code": "no_credits",
  "error": { "code": "no_credits", "message": "balance is zero; buy a credit pack" },
  "request_id": "..."
}
```

Without that header you get the native shape, which is what both SDKs read. Neither
is going away.

## Retry policy we recommend [#retry-policy-we-recommend]

Retry `503` and `502` up to three times with 2, 4 and 8 seconds between attempts. Do not retry `4xx`.
Because failed requests are free, retries never cost credits.

Read `retry-after` when it is present rather than guessing, and read `RateLimit`
on every response to throttle before a `429` happens at all. See
[Authentication](/docs/authentication) for the rate ladder.

## Every code [#every-code]

### missing\_key [#missing_key]

`401`. No key was sent. Put it in `x-api-key`, or send `Authorization: Bearer xd_live_…`.
Free.

### invalid\_key [#invalid_key]

`401`. The key is unknown or has been revoked. Check it has not been deleted in the
dashboard, and that you are not sending a key from another environment. Free.

### rate\_limited [#rate_limited]

`429`. Over the account's requests-per-second limit, which is shared by every key
on the account. Wait `retry-after` seconds. The limit and what is left of it are on
every response in the `RateLimit` header, so a client that reads it need never hit
this. Free.

### no\_credits [#no_credits]

`402`. The balance is zero. Buy a pack in the dashboard. There is no overage and no
invoice: the API stops rather than spending money you did not agree to. Free.

### bad\_request [#bad_request]

`400`. Something about the request itself is wrong: a missing required parameter, a
malformed cursor, a batch list over 100, an unparseable body. The message names the
problem. Retrying unchanged will fail again. Free.

### not\_found [#not_found]

`404`. There is no such user or tweet, or it is protected, suspended or deleted. Not
a fault and not retryable. Free.

### access\_denied [#access_denied]

`403`. X refused to serve this object to the session that asked (its error 37). Often
object-specific rather than account-wide, so another read may well succeed. Free.

### product\_unavailable [#product_unavailable]

`403`. The search product asked for is not available, typically `Top`. Use `Latest`.
Free.

### upstream\_rate\_limited [#upstream_rate_limited]

`503`. X is rate limiting our pool right now. Retry after `retry-after` seconds with
backoff. Free.

### no\_account [#no_account]

`503`. No healthy upstream account is available for this read at this moment. Transient;
retry with backoff. Free.

### upstream\_error [#upstream_error]

`502`. X answered with something we could not use. Retry once. If it persists, it is
already paging us. Free.

### query\_id\_stale [#query_id_stale]

`502`. X rotated the identifiers behind its own site and our client has not caught up
yet. This is the failure mode the whole repair loop exists for; it is measured on the
[status page](/status) and fixed in minutes, not days. Retry with backoff. Free.

### request\_rejected [#request_rejected]

`502`. X rejected the shape of the upstream request. Same handling as `upstream_error`.
Free.

### billing\_disabled [#billing_disabled]

`503`. Card checkout is not configured on this deployment. Nothing you can fix from the
client. Free.

### internal [#internal]

`500`. Our bug. Send the `request_id` to [hello@xdataapi.io](mailto:hello@xdataapi.io) and we will find the exact
call in the logs. Free.


---

# Versioning

Source: https://xdataapi.io/docs/versioning

The major version is in the path. Every endpoint begins `/v1/`, and that is the
only version marker: there is no version header, no date pinning, and no
`?version=` parameter to set.

## What can change inside v1 [#what-can-change-inside-v1]

Inside a major version we only add.

| Change                                                | Happens inside `/v1/` |
| ----------------------------------------------------- | --------------------- |
| A new endpoint                                        | yes                   |
| A new optional query parameter                        | yes                   |
| A new field on `Tweet`, `User` or a response envelope | yes                   |
| A new value in an `error.code` enum                   | yes                   |
| A field removed or renamed                            | no                    |
| A field's type changed                                | no                    |
| A parameter becoming required                         | no                    |
| A status code changing meaning                        | no                    |

**Parse defensively.** A new field is not a breaking change, so ignore what you
do not recognise rather than failing on it. Both SDKs already do. If you hand
responses to a strict schema validator, configure it to allow unknown keys.

## What happens before anything is removed [#what-happens-before-anything-is-removed]

A change that would break a caller opens `/v2/` instead of altering `/v1/`. When
that happens, `/v1/` keeps answering for **at least 12 months** from the day the
deprecation is announced, and every `/v1/` response carries the notice for the
whole of that period:

| Header                         | Meaning                                                 |
| ------------------------------ | ------------------------------------------------------- |
| `Deprecation`                  | The date the version was declared deprecated. RFC 9745. |
| `Sunset`                       | The earliest date it may stop answering. RFC 8594.      |
| `Link: <…>; rel="deprecation"` | What changed, and how to move.                          |

```http
Deprecation: Wed, 01 Jul 2026 00:00:00 GMT
Sunset: Thu, 01 Jul 2027 00:00:00 GMT
Link: <https://xdataapi.io/docs/versioning>; rel="deprecation"
```

**No header, no sunset.** The absence of `Sunset` on a response is the promise
that the version you are calling is not going away inside the notice period.
Nothing is ever removed from `/v1/` without it, so a client that watches for the
header needs no other signal. Today `/v1/` sends none: it is current.

Deprecations are also posted on [the status page](/status) and announced by email
to every account that called the affected endpoint in the previous 30 days.

## Checking from code [#checking-from-code]

```bash
curl -sS -D- -o /dev/null \
  -H "x-api-key: $XDATAAPI_KEY" \
  https://api.xdataapi.io/v1/me | grep -i '^sunset\|^deprecation'
```

An empty result means the version is current. Anything else is the date you have
to move by, and the `Link` header is where to read what changed.

## The machine-readable contract [#the-machine-readable-contract]

The same policy is stated in the OpenAPI document, which is the thing to generate
clients from:

* [https://xdataapi.io/openapi.json](https://xdataapi.io/openapi.json)
* [https://api.xdataapi.io/openapi.yaml](https://api.xdataapi.io/openapi.yaml)
