---
title: Import a post history
description: Import accessible account history with explicit limits, coverage, and retry handling.
sidebar:
  order: 4
  icon: download
---

One request, one account, as much history as you ask for. `GET /{handle}/posts` walks X's timeline **in parallel**, keeps every field upstream sends, and remembers what it collected so the next import only fetches what is new.

Set `X_MD_API_KEY` to your issued API key (see [Authentication](#authentication)).

```bash
curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
  'https://x.pcstyle.dev/paulg/posts?since=2025-09-01&max_posts=2000'
```

For a complete interaction graph, combine this route with [the circle integration guide](/docs/x-circle). Imports contain the account’s own posts, replies and reposts; incoming mentions use search.

## Authentication

Set `X_MD_API_KEY` to the API key you were issued, then send it in the `Authorization: Bearer` header shown in each example. This is an x.md API key, not an X account token. Do not put the key in the URL; query-string keys and custom headers are not accepted.

```bash
export X_MD_API_KEY='your-api-key'
```

Deployments that require authentication reject requests without a valid key with `401 unauthorized`. On deployments that allow public access, the header is optional and a valid key uses your key's import allowance. An invalid or disabled key returns `401 invalid_key`. Check the response's `X-Api-Key-Status` header for `valid` to confirm the key was accepted.

The same header applies to JSON imports, NDJSON streams and `index=true` requests on both URL forms. See [authentication details](/auth.md) for how keys are issued and revoked.

## Quickstart

1. **Pick a range**

    Three shapes cover an onboarding slider:

    **Last 3 months**

    ```bash
    curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
      "https://x.pcstyle.dev/api/v1/profiles/paulg/posts?since=$(date -u -d '-90 days' +%F)&max_posts=2000"
    ```

    **Last year**

    ```bash
    curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
      "https://x.pcstyle.dev/api/v1/profiles/paulg/posts?since=$(date -u -d '-1 year' +%F)&max_posts=5000"
    ```

    **Everything available**

    ```bash
    curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
      'https://x.pcstyle.dev/api/v1/profiles/paulg/posts?max_posts=5000'
    ```
    Without `since` the walk goes as far back as X serves — roughly the 3200 most recent timeline entries — and `meta.floor_reached` is `true` when it got there.

2. **Read the body**

    ```json
    {
      "profile": { "screen_name": "paulg", "name": "Paul Graham", "followers": 2081394, "…": "…" },
      "posts": [
        {
          "id": "2097693527450816692",
          "text": "…",
          "created_at": "Wed Sep 09 14:28:04 +0000 2026",
          "author": { "screen_name": "paulg", "…": "…" },
          "replying_to": { "screen_name": "tlbtlbtlb", "status": "2097690000000000000" },
          "likes": 412, "replies": 37, "retweets": 21, "quotes": 4, "views": 88120, "bookmarks": 63,
          "url": "https://x.com/paulg/status/2097693527450816692"
        }
      ],
      "meta": {
        "count": 1379, "since": "2025-09-01T00:00:00.000Z", "until": "2026-09-09T19:30:47.788Z",
        "oldest": "2026-06-19T08:02:11.000Z", "newest": "2026-09-09T14:28:04.000Z",
        "truncated": false, "floor_reached": false,
        "windows": 46, "pages": 130, "duration_ms": 7822,
        "archive": { "count": 1379, "served": 0, "added": 1379, "walked": ["refresh"], "…": "…" }
      }
    }
    ```

    `posts` is newest first and raw: every field FxTwitter returns is passed through untouched — `id`, `text`, `created_at`, `author`, `replying_to`, `quote`, `reposted_by`, `media`, `poll`, `likes`, `replies`, `retweets`, `quotes`, `views`, `bookmarks`, `lang`, `source`, `url`.

3. **Stream it if you render as you go**

    ```bash
    curl -sN -H "Authorization: Bearer $X_MD_API_KEY" \
      'https://x.pcstyle.dev/api/v1/profiles/paulg/posts?since=2026-06-01&format=ndjson'
    ```

    ```text
    {"post":{"id":"2097693527450816692","text":"…"}}
    {"post":{"id":"2097379186289607139","text":"…"}}
    …
    {"meta":{"count":787,"…":"…"},"profile":{"…":"…"}}
    ```

    One JSON object per line. Posts arrive newest first; the trailing `meta` line means the walk finished. If it fails part-way the last line is `{"error": <problem document>}`. The selected posts are emitted after the walk completes, so JSON and NDJSON contain the same set. Headers are sent immediately. Do not assume the first post arrives immediately.

## Parameters

| Parameter | Default | Behavior |
| --- | --- | --- |
| `since` | — | Oldest post to include. Omit to go as far back as upstream allows. |
| `until` | now | Newest post to include, inclusive. |
| `max_posts` | `500` | Most posts to return, newest first, maximum **5000**. `meta.truncated` is `true` when the range held more; continue with `until=<meta.next_until>` and drop the one boundary post you already have. |
| `with_replies` | `true` | Include the account's replies. Replies are where tone, opinions and short-form style live; that is why they are on by default here and off on the paged profile read. |
| `with_reposts` | `true` | Include reposts. A repost is returned as the **original** post with `reposted_by` set, so it carries the original's `id` and `created_at`. |
| `only_replies` | `false` | Replies only. |
| `concurrency` | `16` | Parallel upstream chains, maximum **32** on the public contract. Higher values are not a speed guarantee. Queue requests when `503 import_busy` is returned. |
| `format` | `json` | `json` or `ndjson`. |
| `refresh` | `false` | Re-walk the whole range instead of serving what the archive already holds. Use it when you need current engagement counts. |
| `index` | `false` | `true` answers with the archive index only — no walk, no import quota spent. |

Dates accept `2026-01-01`, a full ISO datetime, or a unix timestamp in seconds or milliseconds. Both surfaces take the same parameters: `/{handle}/posts` and `/api/v1/profiles/{handle}/posts`.

## The archive: collect once, reuse forever

Every import stores what it walked, per account. The next import of that account reads the archive and only walks the gaps:

- **top-up** — newer than what is covered (a user comes back a week later: one or two pages, not the whole range)
- **backfill** — older than what is covered (the slider moves from 3 months to a year: only the extra nine months are walked)
- nothing at all — the same request within a minute, or a range the archive already covers down to X's floor

Ask what is held without spending anything:

```bash
curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
  'https://x.pcstyle.dev/api/v1/profiles/paulg/posts?index=true'
```

```json
{
  "handle": "paulg",
  "archive": {
    "handle": "paulg", "count": 2820,
    "oldest": "2026-01-29T15:32:56.000Z", "newest": "2026-09-09T14:45:53.000Z",
    "covered_since": "2026-01-01T00:00:00.000Z", "covered_until": "2026-09-09T22:48:23.000Z",
    "updated_at": "2026-09-09T22:48:23.000Z", "floor_reached": true
  },
  "persistent": true
}
```

Every import response carries the same block as `meta.archive`, plus `served` (posts that came from the archive), `added` (entries fetched and written by this request, including updates and overlap; not newly unique archive IDs) and `walked` (which gaps were walked). On the hosted service the archive lives in a shared Redis and survives deploys; `persistent: false` means an in-memory fallback that lasts for the life of the process.

:::note
Archived posts keep the engagement counts from the walk that stored them. Text and metadata do not change; likes and views do. Pass `refresh=true` when those numbers matter.
:::

## Coverage and completion

`count` is the number of posts in this response. `archive.count` is the total number of unique post IDs stored for the account; it can be larger than the response. `archive.served` counts returned IDs not fetched again by this request. `archive.added` counts entries fetched by its walks, including overlap and existing IDs updated. These counters do not necessarily add up to `count`.

`truncated=true` means the limit or an incomplete walk prevented full coverage. Follow `next_until` when present, preserving the same `since` and deduplicating IDs. A `warnings` array requires a retry of the **same range**, not merely pagination past the gap. `floor_reached=true` means the walk exhausted accessible history, including reaching the account’s creation date. It does not prove all posts ever published are available. Both flags may be false when the requested `since` was reached without exhaustion.

Reposts carry the original post’s time, not the repost event’s time. Date-filtered archive responses use the original timestamp. An exact repost-time history cannot be reconstructed from this data. `next_until` paginates original-post time; use profile `nextCursor` pagination when timeline position and repost coverage matter. An archive-only response may omit `profile`; use `include_posts=false` on the profile route for identity and avatar enrichment.

Filtered imports may need to inspect up to 5000 unfiltered entries to fill the requested count. They can return fewer matching posts with `truncated=true`. Previously collected posts retain their engagement values until refreshed. The archive is a collected snapshot, not proof that an old post remains public or unchanged.

## Limits and recovery

- **Metering:** 10 imports per IP per 15 minutes; 60 per issued key by default, with per-key overrides advertised in `RateLimit-Policy`. `index=true` and HEAD spend only the general request allowance.
- **Depth:** X exposes a limited recent timeline, often around 3200 entries. Counts include replies, reposts and conversation context upstream; the number of returned own posts may be smaller.
- **Concurrency:** the hosted service admits two fresh walks at once across instances. Excess demand returns `503 import_busy` with `Retry-After: 5`. Busy rejections refund the import allowance; the ordinary request limit still applies. Archive-only reads do not use a fresh-walk slot. This is an admission limit, not a throughput guarantee.
- **Time:** the route allows 120 seconds. Actual duration depends on activity, range, cache state and provider availability. Give JSON imports a 125-second client timeout. NDJSON can end with a problem line despite HTTP 200.
- **Retry:** on 429, `import_busy`, `upstream_unavailable` or `upstream_rate_limited`, wait at least `Retry-After`, then add jitter. Retry the identical request at most twice before reporting a recoverable failure. Do not reduce the specified wait or retry 400/401 errors.
- **Parameters:** booleans accept only `true`, `false`, `1`, or `0`; invalid counts and conflicting aliases return 400. `only_replies=true` conflicts with `with_replies=false`.

A successful HTTP response is not a completeness guarantee: inspect `meta.truncated`, `meta.warnings`, `meta.floor_reached`, and the returned date bounds before labeling an import complete.
