xskill skill.md

API Reference

API Reference

Prepaid HTTP endpoints for reading X/Twitter posts, conversations, search results, parsed summaries, and image OCR through a stable agent-facing API.

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 /health
  • POST /v1/signup
  • GET /v1/post
  • GET /v1/thread
  • GET /v1/search
  • GET /v1/list
  • GET /v1/user
  • POST /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/bootstrap hermetic 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.

shell
# 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:openapi

Do 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:

shell
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:

http
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:

shell
curl -sS "$XSKILL_BASE_URL/health"

Create a new account and one-time xsk_ key:

shell
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:

shell
curl -sS \
  -H "Authorization: Bearer $XSKILL_API_KEY" \
  "$XSKILL_BASE_URL/v1/thread?id=$X_POST_ID&mode=conversation&parse=summary"

Read one post:

shell
curl -sS \
  -H "Authorization: Bearer $XSKILL_API_KEY" \
  "$XSKILL_BASE_URL/v1/post?id=$X_POST_ID"

Search X:

shell
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:

shell
curl -sS \
  -H "Authorization: Bearer $XSKILL_API_KEY" \
  "$XSKILL_BASE_URL/v1/list?id=$X_LIST_ID&maxTweets=20"

Read a user timeline:

shell
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:

shell
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.

json
{
  "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:

json
{
  "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_000 micro-credits = $1.00
  • $0.02 = 20_000 micro-credits

Default price card:

OperationPrice
Raw list timeline2_000 + 300 * tweetsReturned
Raw post4_000 micro-credits
Raw search2_000 + 300 * tweetsReturned
Raw thread4_000 + 300 * tweetsRead
Raw user timeline2_000 + 300 * tweetsReturned
Parsed thread, Haiku14_000 + 300 * tweetsRead
Parsed thread, Sonnet94_000 + 300 * tweetsRead
Vision image OCR/description20_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:

shell
curl -sS "$XSKILL_BASE_URL/health"

Response:

json
{
  "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:

bash
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 .sourceFingerprint

Equal 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:

NameRequiredValuesDefault
inviteCodeyesactive signup invite codenone
nameno1 to 80 charactersomitted

Example:

shell
curl -sS \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"inviteCode":"replace-with-invite","name":"Research agent"}' \
  "$XSKILL_BASE_URL/v1/signup"

Response:

json
{
  "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:

NameRequiredValuesDefault
idyes1 to 25 digitsnone
parsenosummary, json, tldromitted
parseFallbacknoraw, errorraw
parseTimeoutMsnointeger 1000..120000server default
modelnohaiku, sonnethaiku
visionnotrue, falsefalse

Billing:

  • No parse: raw_post.
  • parse=...&model=haiku: parsed_thread for one tweet, with raw-post fallback reservation.
  • parse=...&model=sonnet: premium_parsed_thread for one tweet. Requires premium models to be enabled.
  • vision=true: analyzes attached photo media and separately bills vision_image.

Example:

shell
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 when parse is requested and completed.
  • data.parseStatus: present only when a requested parse degraded under parseFallback=raw; one of parser_timeout, parser_unavailable, parse_budget_exceeded. Degraded responses bill raw_post only.
  • data.vision: image OCR/description results when vision=true is 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:

NameRequiredValuesDefault
idyes1 to 25 digitsnone
modenoconversation, threadconversation
maxPagesnointeger >= 1provider default
maxTweetsnointeger >= 1provider default
parsenosummary, json, tldromitted
parseFallbacknoraw, errorraw
parseTimeoutMsnointeger 1000..120000server default
modelnohaiku, sonnethaiku
visionnotrue, falsefalse

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 bills vision_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:

shell
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: true when the provider reports additional pages.
  • data.parsed: present when parse is requested and completed.
  • data.parseStatus: present only when a requested parse degraded under parseFallback=raw; one of parser_timeout, parser_unavailable, parse_budget_exceeded. Degraded responses bill raw_thread only.
  • data.vision and data.visionTruncated: present when vision=true is 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.

Runs provider-backed X advanced search.

Query parameters:

NameRequiredValuesDefault
queryyesnon-empty string, max 512 charsnone
typenoLatest, TopLatest
cursornoprovider cursoromitted
maxPagesnointeger 1..51
maxTweetsnointeger 1..10020

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:

shell
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: Latest or Top.
  • 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:

NameRequiredValuesDefault
idyes1 to 25 digitsnone
cursornoprovider cursoromitted
maxPagesnointeger 1..51
maxTweetsnointeger 1..10020

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:

shell
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:

NameRequiredValuesDefault
handleyesX handle without @, 1 to 15 charsnone
cursornoprovider cursoromitted
maxPagesnointeger 1..51
maxTweetsnointeger 1..10020

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:

shell
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:

json
{
  "amountCents": 1000
}

amountCents must be an integer of at least 1000.

Example:

shell
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:

json
{
  "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:

http
Stripe-Signature: ...
Content-Type: application/json

The 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:

shell
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, and data.totals.marginUsd: revenue vs upstream cost vs margin.
  • data.byEndpoint and data.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, or unknown. It is unknown unless the balance was actually queried, which is what data.float.checkedAt records. A balance supplied without a query time is reported but never treated as a measurement, and checkedAt is omitted rather than filled in with the current time.
  • data.parse: parse-outcome counters. See below.
  • data.alerts: includes parse_degrade_rate_high when the parse degrade rate crosses its threshold, and twitterapi_io_float_low when a queried balance is below XSK_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.totals and data.parse.byEndpoint.<endpoint>, each with requested, fulfilled, degraded, failed, and a degradedByStatus breakdown over parser_timeout, parser_unavailable, and parse_budget_exceeded.
  • requested always equals fulfilled + 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, or null when no parse has been requested yet. It is never silently 0 — an unmeasured rate and a healthy rate must not look alike.
  • data.parse.alertThresholdRate and data.parse.alertMinimumRequests: the two values the parse_degrade_rate_high alert 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

HTTPCodeMeaning
400invalid_requestRequest validation failed or JSON body is invalid.
400invalid_stripe_eventStripe webhook payload is not a valid xskill top-up event.
400invalid_stripe_signatureStripe signature is missing or invalid.
401unauthorizedMissing, invalid, or revoked xsk_ API key.
402insufficient_balancePrepaid balance is too low; top up to continue.
403invalid_signup_inviteSignup invite code is invalid.
403premium_model_requiredmodel=sonnet was requested but premium models are disabled.
404post_not_foundThe requested post was not found.
404not_foundRoute does not exist.
409stripe_idempotency_conflictStripe top-up idempotency conflict.
413invalid_requestRequest body is too large.
413cost_ceiling_exceededRequest would exceed the configured per-call cost ceiling.
413parse_budget_exceededParse input/output budget is too large.
415invalid_requestRequest media type is not supported.
429rate_limitedPer-key or public signup rate limit exceeded.
500internal_errorUnexpected server error.
503auth_unavailableAPI-key auth is not configured.
503billing_unavailableCredit ledger is not configured or did not return a usage id.
503ops_unavailableOps metrics are missing or XSK_OPS_TOKEN is not configured.
503parser_provider_unavailableThe configured parser provider rejected or failed the parse request.
503parser_timeoutThe configured parser did not complete before the request timeout.
503parser_unavailableParse was requested but no parser is configured.
503pricing_unavailablePricing engine is not configured.
503provider_unavailableTweet provider is missing or unavailable.
503signup_unavailablePublic API-key issuance is not configured.
503stripe_unavailableStripe Checkout or webhook verification is not configured.
503vision_unavailableVision 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[] with type, url, previewImageUrl, altText, and optional vision OCR/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.