---
title: Build an interaction circle
description: A server-side integration using public x.md routes, issued keys, and explicit coverage checks.
seo:
  description: Build an X interaction circle with public x.md routes. Use issued keys server-side and check data coverage before rendering.
sidebar:
  order: 5
  icon: users
---

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://fast.mdfromx.com`.

Run the [server-side reference and benchmark](https://github.com/pc-style/x-md/blob/main/scripts/circle-benchmark.ts) 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](/docs/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 three fresh import walks at once, one per caller with one held for API keys, 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.
