# xskill API Reference

A product by Achronon.

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.

```sh
# 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:

```sh
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:

```sh
curl -sS "$XSKILL_BASE_URL/health"
```

Create a new account and one-time `xsk_` key:

```sh
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:

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

Read one post:

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

Search X:

```sh
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:

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

Read a user timeline:

```sh
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:

```sh
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:

| 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](./terms.md) and
[Pricing and Refund Policy](./refund-policy.md) before creating a Checkout
Session.

See [pricing.md](./pricing.md) for price-card overrides and reservation
behavior.

## Endpoints

### `GET /health`

Returns service health. No auth.

Example:

```sh
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:

| Name | Required | Values | Default |
| --- | --- | --- | --- |
| `inviteCode` | yes | active signup invite code | none |
| `name` | no | 1 to 80 characters | omitted |

Example:

```sh
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:

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

```sh
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:

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

```sh
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.

### `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:

```sh
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:

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

```sh
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:

```sh
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](./terms.md) and
[Pricing and Refund Policy](./refund-policy.md) before creating a Checkout
Session.

Request body:

```json
{
  "amountCents": 1000
}
```

`amountCents` must be an integer of at least `1000`.

Example:

```sh
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:

```sh
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

| 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[]` 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.
