Skip to content
x.md
Esc
↑↓navigate↵open⌘Jpreview
On this page

Build an interaction circle

A server-side integration using public x.md routes, issued keys, and explicit coverage checks.

A circle needs both directions of interaction. Reading someone’s own posts alone misses people replying to or mentioning them. This integration uses only the documented public API at https://x.pcstyle.dev.

Run the server-side reference and benchmark with Bun and your server environment. It reports stage coverage, missing profiles, retries and wall time; --fresh forces a fresh own-post import and bypasses x.md’s search, reply and profile caches. Upstream caches are outside the client’s control. Set X_MD_UNTIL to the same ISO datetime for fresh and repeat benchmark runs. It is not browser code.

Keep the key on your server

Store the issued key as server-only X_MD_API_KEY. Send Authorization: Bearer … and Accept: application/json on each request. Do not use a public framework environment prefix, put keys in URLs, or embed them in JavaScript delivered to the browser. Your browser should call your own authenticated backend; validate handles there and queue work per user. CORS permits the API headers, but CORS does not protect a key embedded in a bundle.

Check X-Api-Key-Status: valid. An invalid key returns 401; retrying it does not help. Customers need no upstream account, private endpoint, SSH access or service-owner environment variables.

Data contract

Need Public request Read
Identity and avatar /api/v1/profiles/{handle}?include_posts=false&format=json profile.id, screen_name, name, avatar_url, protected
Own posts, replies, reposts /api/v1/profiles/{handle}/posts?since={ISO}&max_posts=1000&format=json posts, meta; replies and reposts default to true
Timeline-position pagination /api/v1/profiles/{handle}?with_replies=true&with_reposts=true&limit=100&format=json posts, opaque nextCursor
Incoming mentions /api/v1/search?q=(%40{handle}%20OR%20to%3A{handle})&feed=latest&since={ISO}&limit=100&require_live=true&format=json posts, nextCursor; the live fallback can return at most 20 per request
Direct reply fallback /api/v1/posts/{id}/replies?limit=100&format=json posts; a bounded recent sample, without pagination or exhaustive coverage
Parent/thread context /api/v1/posts?url={encodedStatusURL}&context=full&replies=recent&format=json posts with context of parent, post, thread or reply
Missing member avatars identity-only route above for each missing handle profile; use at most four concurrent lookups

For a 120-day circle, freeze until once and compute since = until - 120 days. Run own posts and incoming search concurrently. Reuse profile from a fresh import for identity; call the identity-only endpoint if it is absent. Follow every returned search cursor, keeping the same query, feed and dates. Stop at your explicit page budget, repeated cursor, or absent cursor; a short page alone is not completion. If warnings reports an empty or repeated upstream page, stop, retain the current cursor for a later retry, and label search coverage incomplete. Deduplicate post IDs within each direction. A capped search is a sample, not a full 120-day mention history.

Build interactions

Use author.screen_name to distinguish outgoing from incoming posts, case-insensitively. Outgoing reposts have reposted_by.screen_name matching the owner and their original author is the recipient. Their timestamp is the original publication time; exact repost event times are unavailable.

For replies, replying_to is normally an object with screen_name and status; older data may instead contain an array of handles and replying_to_status. Preserve that distinction. For quotes use quote.author.screen_name. Visible mentions are raw_text.facets with type: "mention", original: "@handle", and an index at or after raw_text.display_text_range[0]. This avoids counting implicit reply prefixes as explicit mentions. If an entity field is absent, report unknown coverage rather than inventing it from arbitrary text.

Exclude self-interactions and deduplicate (post ID, other handle, direction, kind). One compatible scoring rule weights reply 3, quote 2.5, mention 1.5 and repost 1, halves weight every 30 days, and combines outgoing score a and incoming score b as a + b + 2 * sqrt(a * b). Keep source and coverage alongside scores: a larger score from a larger sample is not evidence of a closer relationship.

Search indexing can omit replies even on a successful search. Supplement search using the direct-replies route for a bounded selection of the owner’s posts with replies. Deduplicate those post IDs before fetching. These samples omit unmentioned quotes and other incoming activity; label the result “search + reply sample” (or “reply sample” after total search failure), never “mentions complete”. Do not score degraded web search snippets.

Recovery and capacity

For retryable 429/502/503 responses, parse the problem document and Retry-After. A queued retry must wait at least that long plus jitter, and retry at most twice. The reference circle client defers a failed search immediately and returns its available timeline and reply samples; it reports search_resume and search_retry_at for background recovery. It does not hold the entire circle open for a search quota window or issue another search before that deadline during the same run. Other stages retry inline only for waits up to 60 seconds; longer waits return a failure for queued recovery without shortening the server’s deadline. search_unavailable without an explicit wait can use a 30-second delay. Keep successfully collected pages when a later page fails and label the circle partial; retry the failed cursor later. For imports with meta.warnings, retry the same range. For count-limited imports use meta.next_until, deduplicating boundary posts. See bulk import.

The issued key’s response headers are authoritative. A key advertising 50 imports per 900 seconds is a valid override of the default 60. Search has separate burst and window allowances; ten mention pages can spend ten account-backed searches. These quotas are budgets, not reserved throughput. The service admits two fresh import walks at once and rejects excess work with 503 import_busy; queue those requests and honor the delay.

Expose useful completion data to your user: own posts read, incoming interactions read, date bounds, search/reply source, missing profiles, and whether any stage was capped or failed. Do not label a partial circle complete because one HTTP request returned 200.

Last updated on September 28, 2026

Was this page helpful?