Import a post history
Import accessible account history with explicit limits, coverage, and retry handling.
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).
curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
'https://mdfromx.com/paulg/posts?since=2025-09-01&max_posts=2000'
For a complete interaction graph, combine this route with the circle integration guide. 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.
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 for how keys are issued and revoked.
Quickstart
Pick a range
Three shapes cover an onboarding slider:
curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
"https://mdfromx.com/api/v1/profiles/paulg/posts?since=$(date -u -d '-90 days' +%F)&max_posts=2000"curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
"https://mdfromx.com/api/v1/profiles/paulg/posts?since=$(date -u -d '-1 year' +%F)&max_posts=5000"curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
'https://mdfromx.com/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.
Read the body
{
"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.
Stream it if you render as you go
curl -sN -H "Authorization: Bearer $X_MD_API_KEY" \
'https://mdfromx.com/api/v1/profiles/paulg/posts?since=2026-06-01&format=ndjson'{"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:
curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \
'https://mdfromx.com/api/v1/profiles/paulg/posts?index=true'
{
"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.
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=trueand 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_busywithRetry-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_unavailableorupstream_rate_limited, wait at leastRetry-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, or0; invalid counts and conflicting aliases return 400.only_replies=trueconflicts withwith_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.