xskill is a prepaid API for agents that need to read and understand X/Twitter posts, threads, conversations, search results, lists, and user timelines. Text parsing and image OCR/description are live through the shared ClaudeVPS claudex sandbox.
Base URLs
- Production API:
https://api.xskill.md - Local development default:
http://127.0.0.1:3000
Executable OpenAPI Contract
GET /openapi.json serves the same deterministic OpenAPI 3.1 JSON committed at openapi/openapi.json. The public consumer contract contains exactly:
GET /healthPOST /v1/signupGET /v1/postGET /v1/threadGET /v1/searchGET /v1/listGET /v1/userPOST /v1/topups/checkout
The complete mounted-route inventory keeps the following surfaces explicit but outside that public consumer document:
- Website, policy, skill, image, and rendered/raw documentation routes:
GET /,/logo.svg,/api,/api.md,/pricing,/pricing.md,/refund-policy,/refund-policy.md,/skill.md,/terms,/terms.md,/topups/success, and/topups/cancel. - Provider callback: Stripe-signed
POST /v1/stripe/webhook. - Owner-only operations:
GET /v1/ops/metrics. - Contract delivery:
GET /openapi.json, which serves the artifact without recursively describing itself. - The catch-all not-found handler and standalone test-only
POST /__load/bootstraphermetic load-harness route.
Fastify automatic HEAD routes are disabled. Authentication, request schemas, success responses, and stable error codes are checked against the running route surface so an unclassified public handler or stale artifact fails closed.
# Read-only contract and tracked-byte freshness check.
corepack npm run check:openapi
# Explicit local writer; review openapi/openapi.json after generation.
corepack npm run generate:openapiDo not edit openapi/openapi.json directly. Neither command contacts a live xskill deployment, an X provider, ClaudeVPS, Stripe, or a credentialed service.
Set these shell variables before using the examples:
export XSKILL_BASE_URL="${XSKILL_BASE_URL:-https://api.xskill.md}"
export XSKILL_API_KEY="xsk_replace_me"
export X_POST_ID="1234567890123456789"
export X_LIST_ID="1234567890123456789"
export X_USER_HANDLE="OpenAI"Authentication
Customer endpoints require an xsk_ API key. Prefer the bearer form:
Authorization: Bearer xsk_...x-api-key: xsk_... is also accepted. Never send provider or parser secrets such as TWITTERAPI_IO_API_KEY, GETXAPI_API_KEY, or CVPS_API_TOKEN to xskill endpoints.
GET /health, POST /v1/signup, and POST /v1/stripe/webhook do not use customer API-key auth. GET /v1/ops/metrics is an internal endpoint protected by XSK_OPS_TOKEN; never use a customer xsk_ key for ops access.
Operator admin keys can be configured server-side by hash. They use the same Authorization: Bearer xsk_... request shape, but authenticate to the internal admin account and record usage at zero customer price. Upstream provider/parser costs are still spent and tracked in ops metrics.
Copy-Paste Quickstart
Check service health:
curl -sS "$XSKILL_BASE_URL/health"Create a new account and one-time xsk_ key:
curl -sS \
-X POST \
-H "Content-Type: application/json" \
-d '{"inviteCode":"replace-with-invite","name":"Research agent"}' \
"$XSKILL_BASE_URL/v1/signup"Read and summarize a conversation:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/thread?id=$X_POST_ID&mode=conversation&parse=summary"Read one post:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/post?id=$X_POST_ID"Search X:
curl -sS -G \
-H "Authorization: Bearer $XSKILL_API_KEY" \
--data-urlencode "query=from:OpenAI agent" \
--data-urlencode "type=Latest" \
"$XSKILL_BASE_URL/v1/search"Read a list timeline:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/list?id=$X_LIST_ID&maxTweets=20"Read a user timeline:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/user?handle=$X_USER_HANDLE&maxTweets=20"Create a $10.00 top-up Checkout Session:
curl -sS \
-X POST \
-H "Authorization: Bearer $XSKILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amountCents":1000}' \
"$XSKILL_BASE_URL/v1/topups/checkout"Response Shape
Successful API responses return a data object. Read endpoints also return usage; admin-bypass requests keep this metadata but return a zero-priced debit.
{
"data": {},
"usage": {
"cost": {
"currency": "USD",
"estimatedUsd": 0.003,
"itemsRead": 20,
"unitCostUsd": 0.00015,
"upstreamRequests": 1
},
"pricing": {
"currency": "USD",
"operation": "parsed_thread",
"priceCardVersion": "2026-06-28.timeline-raw",
"priceMicroCredits": 20000,
"priceUsd": 0.02,
"units": {
"tweets": 20
}
},
"provider": "twitterapi.io",
"tweetsRead": 20
}
}usage.provider is the active backend adapter for the call. It is normally twitterapi.io, but may be getxapi during an operator failover. Customer routes and price-card billing stay the same across that provider flip.
Normalized tweets include a links array when the upstream tweet carries URL entities. Each entry has url (the t.co short link) and, when available, expandedUrl and displayUrl. Link-only posts (for example shared X Articles, x.com/i/article/...) have empty media but expose their real target through links.
Errors use one envelope:
{
"error": {
"code": "invalid_request",
"message": "Request validation failed",
"statusCode": 400
},
"requestId": "req-2f"
}Every response includes the same server-generated request identifier in the x-request-id header. Error responses repeat it as requestId so operators can correlate the response with request telemetry. Caller-supplied x-request-id values are ignored and are never echoed or used in logs.
Rate-limited authenticated requests include x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, and, for rejected requests, retry-after.
Pricing
xskill stores balances as integer micro-credits:
1_000_000micro-credits =$1.00$0.02=20_000micro-credits
Default price card:
| Operation | Price |
|---|---|
| Raw list timeline | 2_000 + 300 * tweetsReturned |
| Raw post | 4_000 micro-credits |
| Raw search | 2_000 + 300 * tweetsReturned |
| Raw thread | 4_000 + 300 * tweetsRead |
| Raw user timeline | 2_000 + 300 * tweetsReturned |
| Parsed thread, Haiku | 14_000 + 300 * tweetsRead |
| Parsed thread, Sonnet | 94_000 + 300 * tweetsRead |
| Vision image OCR/description | 20_000 * images |
The default free tier tops each account balance up to 2_000_000 micro-credits once per UTC month. Free credits are account-scoped, not API-key-scoped, and unused free credits do not stack above the monthly cap.
Top-ups use Stripe Checkout. The minimum top-up is $10.00, which credits 10_000_000 micro-credits after Stripe reports a paid Checkout Session. Signup and checkout surfaces must link the Terms of Service and Pricing and Refund Policy before creating a Checkout Session.
See pricing.md for price-card overrides and reservation behavior.
Endpoints
GET/health
Returns service health. No auth.
Example:
curl -sS "$XSKILL_BASE_URL/health"Response:
{
"builtAt": "2026-08-04T14:31:02.000Z",
"environment": "production",
"ok": true,
"release": "958a45d",
"service": "xskill-api",
"sourceFingerprint": "3f0c1d…"
}sourceFingerprint identifies the live build. It is a SHA-256 over the build inputs, computed inside the image at build time, so no operator can set it.
Do not use release to determine which commit is live. It echoes XSK_RELEASE, falling back to RAILWAY_GIT_COMMIT_SHA, and is null when neither is set. Because the operator-set variable is preferred, it can be, and has been, wrong: on 2026-08-04 production reported 7855f98 while serving 758dff7, 25 commits later. It is retained only as an operator annotation.
builtAt is when the image was built, or null if unknown.
To check what is live, recompute the fingerprint over a clean checkout and compare. This needs no credential and no provider API:
git clone https://github.com/Achronon/xskill.git && cd xskill
node scripts/generate-build-info.mjs # prints sourceFingerprint
curl -s https://api.xskill.md/health | jq -r .sourceFingerprintEqual values mean production is running that checkout. Different values mean real drift. unknown means the image carries no identity artifact at all, and must never be read as agreement.
Recompute against a clean checkout: the digest covers the committed tree, so uncommitted local edits legitimately move it.
POST/v1/signup
Creates a new account and returns a one-time xsk_ API key. Public signup is invite-gated with XSK_SIGNUP_INVITE_CODE, invite reuse tracking, and IP throttling via XSK_SIGNUP_RATE_LIMIT_MAX_REQUESTS / XSK_SIGNUP_RATE_LIMIT_WINDOW_MS. Set XSK_DATA_FILE to persist account metadata, API-key hashes, invite-use hashes, balances, credit-grant idempotency, and usage records across single-replica restarts. Without XSK_DATA_FILE, those stores are process-local. Direct deployments do not trust proxy headers by default; hosted deployments behind a proxy that strips inbound forwarding headers should set XSK_TRUST_PROXY=true so throttling keys on the forwarded client IP instead of the load balancer. The key is only returned in this response; store it before leaving the page or terminal.
Request body:
| Name | Required | Values | Default |
|---|---|---|---|
inviteCode | yes | active signup invite code | none |
name | no | 1 to 80 characters | omitted |
Example:
curl -sS \
-X POST \
-H "Content-Type: application/json" \
-d '{"inviteCode":"replace-with-invite","name":"Research agent"}' \
"$XSKILL_BASE_URL/v1/signup"Response:
{
"data": {
"account": {
"createdAt": "2026-06-26T00:00:00.000Z",
"id": "acct_..."
},
"apiKey": {
"accountId": "acct_...",
"createdAt": "2026-06-26T00:00:00.000Z",
"id": "key_...",
"name": "Research agent",
"prefix": "xsk_..."
},
"docs": {
"api": "/api",
"pricing": "/pricing",
"refundPolicy": "/refund-policy",
"skill": "/skill.md",
"terms": "/terms"
},
"key": "xsk_...",
"topUp": {
"amountCents": 1000,
"amountMicroCredits": 10000000,
"endpoint": "/v1/topups/checkout"
}
}
}The hosted landing page uses this endpoint, then calls POST /v1/topups/checkout with the new key after showing the Terms of Service and Pricing and Refund Policy links.
GET/v1/post
Fetches one post by numeric X post ID.
Query parameters:
| Name | Required | Values | Default |
|---|---|---|---|
id | yes | 1 to 25 digits | none |
parse | no | summary, json, tldr | omitted |
parseFallback | no | raw, error | raw |
parseTimeoutMs | no | integer 1000..120000 | server default |
model | no | haiku, sonnet | haiku |
vision | no | true, false | false |
Billing:
- No
parse:raw_post. parse=...&model=haiku:parsed_threadfor one tweet, with raw-post fallback reservation.parse=...&model=sonnet:premium_parsed_threadfor one tweet. Requires premium models to be enabled.vision=true: analyzes attached photo media and separately billsvision_image.
Example:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/post?id=$X_POST_ID&parse=tldr"Response fields:
data.post: normalized post.data.parsed: present whenparseis requested and completed.data.parseStatus: present only when a requested parse degraded underparseFallback=raw; one ofparser_timeout,parser_unavailable,parse_budget_exceeded. Degraded responses billraw_postonly.data.vision: image OCR/description results whenvision=trueis requested.usage.tweetsRead: upstream post reads.usage.pricing: customer price metadata.usage.vision: separate image billing metadata when applicable.
Parse latency contract: LLM parsing is synchronous and can take tens of seconds. parseTimeoutMs bounds the parse step for this request. On breach (or parser outage) with parseFallback=raw (the default), the endpoint returns 200 with the raw payload plus data.parseStatus instead of failing. Use parseFallback=error to receive 503 parser_timeout / 503 parser_provider_unavailable errors instead.
GET/v1/thread
Fetches tweets in a conversation through the provider's conversation_id search path. Use the root conversation ID when possible.
Query parameters:
| Name | Required | Values | Default |
|---|---|---|---|
id | yes | 1 to 25 digits | none |
mode | no | conversation, thread | conversation |
maxPages | no | integer >= 1 | provider default |
maxTweets | no | integer >= 1 | provider default |
parse | no | summary, json, tldr | omitted |
parseFallback | no | raw, error | raw |
parseTimeoutMs | no | integer 1000..120000 | server default |
model | no | haiku, sonnet | haiku |
vision | no | true, false | false |
mode=conversation returns all fetched conversation tweets. mode=thread returns the root author's self-reply chain when root-author and reply metadata are available; otherwise it falls back to the full conversation to avoid guessing.
Billing:
- No
parse:raw_thread. parse=...&model=haiku:parsed_thread.parse=...&model=sonnet:premium_parsed_thread. Requires premium models.vision=true: analyzes attached photo media and separately billsvision_image.
The twitterapi.io adapter caps thread fetches at 5 pages / 100 tweets.
When the conversation pages do not include the root tweet (common for old or very active conversations, where Latest-ordered pagination returns the newest replies first), the service recovers the root with one extra post read and prepends it. The extra read is included in usage.cost.
Example:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/thread?id=$X_POST_ID&mode=thread&parse=summary&maxPages=2"Response fields:
data.id: conversation ID.data.mode: resolved mode.data.tweets: normalized tweets.data.truncated:truewhen the provider reports additional pages.data.parsed: present whenparseis requested and completed.data.parseStatus: present only when a requested parse degraded underparseFallback=raw; one ofparser_timeout,parser_unavailable,parse_budget_exceeded. Degraded responses billraw_threadonly.data.visionanddata.visionTruncated: present whenvision=trueis available.usage.tweetsRead: upstream tweets read.usage.tweetsReturned: tweets returned after mode filtering.
The parse latency contract matches GET /v1/post: parseTimeoutMs bounds the parse step, and parseFallback=raw (default) degrades to the raw thread with data.parseStatus instead of an error.
GET/v1/search
Runs provider-backed X advanced search.
Query parameters:
| Name | Required | Values | Default |
|---|---|---|---|
query | yes | non-empty string, max 512 chars | none |
type | no | Latest, Top | Latest |
cursor | no | provider cursor | omitted |
maxPages | no | integer 1..5 | 1 |
maxTweets | no | integer 1..100 | 20 |
Billing: raw_search, settled to 2_000 + 300 * tweetsReturned. Successful empty searches still charge the 2_000 micro-credit search base; provider failures before a completed fetch settle back to zero. The route reserves the provider-estimated reachable result window before provider spend.
Example:
curl -sS -G \
-H "Authorization: Bearer $XSKILL_API_KEY" \
--data-urlencode "query=conversation_id:$X_POST_ID" \
--data-urlencode "type=Latest" \
--data-urlencode "maxTweets=20" \
"$XSKILL_BASE_URL/v1/search"Response fields:
data.query: query sent to the provider.data.type:LatestorTop.data.tweets: normalized tweets.data.pageInfo.nextCursor: cursor for the next page when present.usage.tweetsRead: upstream tweets read.usage.tweetsReturned: tweets returned to the caller.
GET/v1/list
Reads tweets from an X list timeline by numeric list ID.
Query parameters:
| Name | Required | Values | Default |
|---|---|---|---|
id | yes | 1 to 25 digits | none |
cursor | no | provider cursor | omitted |
maxPages | no | integer 1..5 | 1 |
maxTweets | no | integer 1..100 | 20 |
Billing: raw_list, settled to 2_000 + 300 * tweetsReturned. Successful empty list reads still charge the 2_000 micro-credit base; provider failures before a completed fetch settle back to zero. The route reserves the provider-estimated reachable result window before provider spend.
Example:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/list?id=$X_LIST_ID&maxTweets=20"Response fields:
data.id: list ID.data.tweets: normalized tweets.data.pageInfo.nextCursor: cursor for the next page when present.usage.tweetsRead: upstream tweets read.usage.tweetsReturned: tweets returned to the caller.
GET/v1/user
Reads tweets from a user's timeline by handle.
Query parameters:
| Name | Required | Values | Default |
|---|---|---|---|
handle | yes | X handle without @, 1 to 15 chars | none |
cursor | no | provider cursor | omitted |
maxPages | no | integer 1..5 | 1 |
maxTweets | no | integer 1..100 | 20 |
Billing: raw_user_timeline, settled to 2_000 + 300 * tweetsReturned. Successful empty timeline reads still charge the 2_000 micro-credit base; provider failures before a completed fetch settle back to zero. The route reserves the provider-estimated reachable result window before provider spend.
Example:
curl -sS \
-H "Authorization: Bearer $XSKILL_API_KEY" \
"$XSKILL_BASE_URL/v1/user?handle=$X_USER_HANDLE&maxTweets=20"Response fields:
data.handle: user reference used by the provider.data.tweets: normalized tweets.data.pageInfo.nextCursor: cursor for the next page when present.usage.tweetsRead: upstream tweets read.usage.tweetsReturned: tweets returned to the caller.
POST/v1/topups/checkout
Creates a Stripe Checkout Session for prepaid credits. Show or link the Terms of Service and Pricing and Refund Policy before creating a Checkout Session.
Request body:
{
"amountCents": 1000
}amountCents must be an integer of at least 1000.
Example:
curl -sS \
-X POST \
-H "Authorization: Bearer $XSKILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amountCents":1000}' \
"$XSKILL_BASE_URL/v1/topups/checkout"Response:
{
"data": {
"amountCents": 1000,
"amountMicroCredits": 10000000,
"currency": "usd",
"id": "cs_test_...",
"url": "https://checkout.stripe.com/..."
}
}Credits are added only after Stripe sends a paid Checkout webhook.
POST/v1/stripe/webhook
Stripe-only webhook endpoint. No customer API-key auth.
Headers:
Stripe-Signature: ...
Content-Type: application/jsonThe route verifies the raw request body with STRIPE_WEBHOOK_SECRET. Paid checkout.session.completed and checkout.session.async_payment_succeeded events credit the account once using the Checkout Session ID as the idempotency key.
GET/v1/ops/metrics
Internal observability endpoint. Requires the configured ops token:
curl -sS \
-H "Authorization: Bearer $XSK_OPS_TOKEN" \
"$XSKILL_BASE_URL/v1/ops/metrics"The response is a JSON dashboard with real-time in-process usage, cost, revenue, margin, credit grants, recent per-call billing logs, and twitterapi.io prepaid float alert status.
Response fields:
data.totals.revenueUsd,data.totals.costUsd, anddata.totals.marginUsd: revenue vs upstream cost vs margin.data.byEndpointanddata.byOperation: request, unit, revenue, cost, and margin breakdowns.data.byProvider: provider/parser cost breakdown. Parsed calls split twitterapi.io fetch cost from the configured parser backend.data.recentUsage: recent billable reservations/settlements.data.float.status:ok,low, orunknown. It isunknownunless the balance was actually queried, which is whatdata.float.checkedAtrecords. A balance supplied without a query time is reported but never treated as a measurement, andcheckedAtis omitted rather than filled in with the current time.data.parse: parse-outcome counters. See below.data.alerts: includesparse_degrade_rate_highwhen the parse degrade rate crosses its threshold, andtwitterapi_io_float_lowwhen a queried balance is belowXSK_TWITTERAPI_IO_FLOAT_ALERT_USD.
twitterapi_io_float_low cannot fire in production today. The only wiring that supplies a balance is XSK_TWITTERAPI_IO_FLOAT_BALANCE_USD, a hand-entered constant that carries no query time, so the status stays unknown. Setting that variable does not enable the alert: a constant can never cross a threshold downward. Enabling it needs the live balance read tracked on XSK-30.
Parse outcomes
data.byOperation records the operation a call settled as. A parse=summary request that degrades to raw settles at rawPricing, so it lands in the same bucket as a caller who legitimately asked for raw. data.parse records what the caller asked for, which is the only way to tell those two apart:
data.parse.totalsanddata.parse.byEndpoint.<endpoint>, each withrequested,fulfilled,degraded,failed, and adegradedByStatusbreakdown overparser_timeout,parser_unavailable, andparse_budget_exceeded.requestedalways equalsfulfilled + degraded + failed, so the fulfilment rate can be read directly rather than inferred.data.parse.degradeRate: the fraction of requested parses that did not return a parse, ornullwhen no parse has been requested yet. It is never silently0— an unmeasured rate and a healthy rate must not look alike.data.parse.alertThresholdRateanddata.parse.alertMinimumRequests: the two values theparse_degrade_rate_highalert is evaluated against. The sample floor keeps one degrade in a handful of calls from raising an alert.
Counters are in-process and reset when the service restarts, exactly like the usage aggregates alongside them.
This alert is not delivered. Like twitterapi_io_float_low, it appears in a pull-only endpoint that nothing polls, so no human is notified. Delivery is tracked separately and is an owner decision; see XSK-30, which has the same gap for float.
The endpoint returns a recursively sanitized copy of the in-memory dashboard. Account, grant, and usage identifiers never enter emitted billing events or the owner dashboard contract. API-key and secret/PII shapes are removed by the recursive egress sanitizer. Endpoint, operation, provider, source, status, route, method, and other categorical values come from the finite policy in src/observability/attributes.ts; unapproved inputs collapse into one fixed unknown bucket before storage or aggregation. Price-card version is validated non-dimension metadata and invalid values also become unknown. Aggregate counts, money values, approved dimensions, and timestamps remain available. These are source/CI controls and do not establish live deployment.
Error Codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Request validation failed or JSON body is invalid. |
| 400 | invalid_stripe_event | Stripe webhook payload is not a valid xskill top-up event. |
| 400 | invalid_stripe_signature | Stripe signature is missing or invalid. |
| 401 | unauthorized | Missing, invalid, or revoked xsk_ API key. |
| 402 | insufficient_balance | Prepaid balance is too low; top up to continue. |
| 403 | invalid_signup_invite | Signup invite code is invalid. |
| 403 | premium_model_required | model=sonnet was requested but premium models are disabled. |
| 404 | post_not_found | The requested post was not found. |
| 404 | not_found | Route does not exist. |
| 409 | stripe_idempotency_conflict | Stripe top-up idempotency conflict. |
| 413 | invalid_request | Request body is too large. |
| 413 | cost_ceiling_exceeded | Request would exceed the configured per-call cost ceiling. |
| 413 | parse_budget_exceeded | Parse input/output budget is too large. |
| 415 | invalid_request | Request media type is not supported. |
| 429 | rate_limited | Per-key or public signup rate limit exceeded. |
| 500 | internal_error | Unexpected server error. |
| 503 | auth_unavailable | API-key auth is not configured. |
| 503 | billing_unavailable | Credit ledger is not configured or did not return a usage id. |
| 503 | ops_unavailable | Ops metrics are missing or XSK_OPS_TOKEN is not configured. |
| 503 | parser_provider_unavailable | The configured parser provider rejected or failed the parse request. |
| 503 | parser_timeout | The configured parser did not complete before the request timeout. |
| 503 | parser_unavailable | Parse was requested but no parser is configured. |
| 503 | pricing_unavailable | Pricing engine is not configured. |
| 503 | provider_unavailable | Tweet provider is missing or unavailable. |
| 503 | signup_unavailable | Public API-key issuance is not configured. |
| 503 | stripe_unavailable | Stripe Checkout or webhook verification is not configured. |
| 503 | vision_unavailable | Vision was requested but no analyzer is configured or available. |
Normalized Data Notes
Tweet objects can include:
id,text,url,createdAt,conversationId,inReplyToTweetId- author fields such as
id,username,name,verified,profileImageUrl - count fields such as
replyCount,retweetCount,likeCount,quoteCount,bookmarkCount,viewCount media[]withtype,url,previewImageUrl,altText, and optionalvisionOCR/description data
Provider and parser failures are sanitized; responses do not expose upstream API keys, CVPS/Claude auth tokens, raw prompt internals, or customer API-key material.