npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@apicity/x

v0.1.0

Published

X (formerly Twitter) social API provider for posting content (api.x.com).

Readme

@apicity/x

npm zero dependencies TypeScript

X (formerly Twitter) social API provider for posting content (api.x.com).

Installation

npm install @apicity/x
# or
pnpm add @apicity/x

Quick Start

import { x as createX } from "@apicity/x";

const x = createX({ accessToken: process.env.X_ACCESS_TOKEN! });

Setup

X requires an OAuth 2.0 user-context access token to post or upload media. App-only Bearer tokens are read-only and rejected by the upload and tweets endpoints.

1. Configure your X app

Open console.x.com and make sure your app lives in a Pay Per Use project — the legacy Free project is deprecated and v2 endpoints reject its tokens with client-not-enrolled. Move the app from the Apps list if needed.

Then open the app and click User authentication settings → Set up:

  • Type of App: Web App, Automated App or Bot (this yields a Client Secret)
  • App permissions: Read and write
  • Callback URI: http://127.0.0.1:8765/callback
  • Website URL: any valid URL

Save and copy the OAuth 2.0 Client ID and Client Secret.

2. Load credits

Pay-per-use bills per write (~$0.015 / post). Open Billing → Credits and load the minimum (typically $5). Without credits, write endpoints return 402 Your enrolled account does not have any credits to fulfill this request — even though authentication itself succeeds.

3. Mint an access token

Save the script below as mint-x-token.mjs and run it:

X_CLIENT_ID=<your-client-id> \
X_CLIENT_SECRET=<your-client-secret> \
  node mint-x-token.mjs

It prints an authorize URL — open it, click Authorize app, and the helper captures the redirect on 127.0.0.1:8765 and prints the access token + refresh token. Access tokens last 2 hours; the refresh token (via offline.access scope) lets you mint a new one without re-authorizing.

import http from "node:http";
import crypto from "node:crypto";

const CLIENT_ID = process.env.X_CLIENT_ID;
const CLIENT_SECRET = process.env.X_CLIENT_SECRET;
const REDIRECT = "http://127.0.0.1:8765/callback";
const SCOPES = [
  "tweet.read",
  "tweet.write",
  "media.write",
  "users.read",
  "offline.access",
].join(" ");

if (!CLIENT_ID || !CLIENT_SECRET) {
  console.error("Set X_CLIENT_ID and X_CLIENT_SECRET");
  process.exit(1);
}

const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
  .createHash("sha256")
  .update(verifier)
  .digest("base64url");
const state = crypto.randomBytes(16).toString("hex");

const authURL = new URL("https://x.com/i/oauth2/authorize");
authURL.searchParams.set("response_type", "code");
authURL.searchParams.set("client_id", CLIENT_ID);
authURL.searchParams.set("redirect_uri", REDIRECT);
authURL.searchParams.set("scope", SCOPES);
authURL.searchParams.set("state", state);
authURL.searchParams.set("code_challenge", challenge);
authURL.searchParams.set("code_challenge_method", "S256");

console.log("Open this URL and click \"Authorize app\":\n" + authURL.toString());

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, REDIRECT);
  if (!url.pathname.startsWith("/callback")) {
    res.writeHead(404).end();
    return;
  }
  const code = url.searchParams.get("code");
  if (!code || url.searchParams.get("state") !== state) {
    res.writeHead(400).end("bad state");
    server.close();
    process.exit(1);
  }
  const basic = Buffer.from(
    `${CLIENT_ID}:${CLIENT_SECRET}`
  ).toString("base64");
  const tokenRes = await fetch("https://api.x.com/2/oauth2/token", {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: REDIRECT,
      code_verifier: verifier,
    }),
  });
  const tok = await tokenRes.json();
  console.log(JSON.stringify(tok, null, 2));
  res.writeHead(200).end("Authorized — check your terminal.");
  server.close();
});

server.listen(8765, "127.0.0.1");

4. Use the token

import { x as createX } from "@apicity/x";

const x = createX({ accessToken: process.env.X_ACCESS_TOKEN });

await x.post.v2.tweets({
  text: "hello from @apicity/x",
});

Real-world example: post a video

Posting a video on X is a four-call dance — initialize a chunked media upload, append the bytes, finalize to kick off transcoding, poll until the media is ready, then attach the resulting media_id to the tweet. The flow below is taken verbatim from tests/integration/x-post-video.test.ts and replays against tests/recordings/x_*/post-video_*/recording.har, so the response shapes match what X actually returns.

import { readFileSync } from "node:fs";
import { x as createX } from "@apicity/x";

const x = createX({ accessToken: process.env.X_ACCESS_TOKEN! });

// 1. Initialize a chunked upload — declare the media type, total
//    byte length, and category up-front. X reserves a media_id we'll
//    thread through every later call.
const bytes = readFileSync("./jump.mp4"); // 1,318,021 bytes in the recording
const init = await x.post.v2.media.upload.initialize({
  media_type: "video/mp4",
  total_bytes: bytes.length,
  media_category: "tweet_video",
});
const mediaId = init.data.id;
// → "2050123807214718976"

// 2. Append the bytes. For files >5MB slice the buffer into
//    segments and call append once per chunk with segment_index 0..n.
await x.post.v2.media.upload.append(mediaId, {
  media: new Blob([bytes], { type: "video/mp4" }),
  segment_index: 0,
});

// 3. Finalize. X queues server-side transcoding and returns
//    processing_info.state = "pending" while the worker is busy.
const fin = await x.post.v2.media.upload.finalize(mediaId);
// fin.data.processing_info → { state: "pending", check_after_secs: 1 }

// 4. Poll status until the media is ready. Honor
//    `check_after_secs` so the loop respects X's pacing hint.
let state = fin.data.processing_info?.state ?? "succeeded";
let wait = fin.data.processing_info?.check_after_secs ?? 1;
while (state === "pending" || state === "in_progress") {
  await new Promise((r) => setTimeout(r, wait * 1000));
  const status = await x.get.v2.media.upload(mediaId);
  state = status.data.processing_info?.state ?? "succeeded";
  wait = status.data.processing_info?.check_after_secs ?? 1;
}
// status.data.processing_info → { state: "succeeded", progress_percent: 100 }

// 5. Post the tweet, attaching the now-ready media id.
const tweet = await x.post.v2.tweets({
  text: "jump",
  media: { media_ids: [mediaId] },
});

console.log(tweet.data.id);
// → "2050123819986378933"
console.log(tweet.data.text);
// → "jump https://t.co/X8cTIpcy3s"
//   X auto-appends the attached media's t.co URL to the returned
//   text — the literal request body just had "jump".

Notes

  • media_category must match the asset: tweet_video, tweet_image, tweet_gif, or amplify_video for long-form. Mismatches are rejected at finalize, not initialize.
  • Uploads expire after data.expires_after_secs (24h). If you finalize but never reference the media_id in a tweet, it is garbage-collected.
  • Errors from any step throw XError with status and the parsed body attached, so try { ... } catch (e) { if (e instanceof XError) ... } gives you the upstream errors[0].message or detail directly.

API Reference

5 endpoints across 2 groups. Each method mirrors an upstream URL path.

media

GET https://api.x.com/2/media/upload{query}

Upstream docs ↗

const res = await x.v2.media.upload({ /* ... */ });

Source: packages/provider/x/src/x.ts

POST https://api.x.com/2/media/upload/{id}/append

Upstream docs ↗

const res = await x.v2.media.upload.append({ /* ... */ });

Source: packages/provider/x/src/x.ts

POST https://api.x.com/2/media/upload/{id}/finalize

Upstream docs ↗

const res = await x.v2.media.upload.finalize({ /* ... */ });

Source: packages/provider/x/src/x.ts

POST https://api.x.com/2/media/upload/initialize

Upstream docs ↗

const res = await x.v2.media.upload.initialize({ /* ... */ });

Source: packages/provider/x/src/x.ts

tweets

POST https://api.x.com/2/tweets

Upstream docs ↗

const res = await x.v2.tweets({ /* ... */ });

Source: packages/provider/x/src/x.ts

Part of the apicity monorepo.

License

MIT — see LICENSE.