Node.js Tutorial

Post to Twitter from Node.js
with one fetch call

No OAuth 1.0a. No request signing. No X developer account. Just fetch, a Bearer key, and a JSON body.

7-day free trial • No credit card required

No X OAuth 1.0a to build

To post to Twitter from Node.js, send a POST request to OpenTweet's REST API athttps://opentweet.io/api/v1/postswith your API key as a Bearer token and the tweet text as JSON. Node 18 and newer ship with a global fetch, so there is nothing to install.

Going straight to the X API from Node means an approved developer account, OAuth 1.0a consumer keys, access tokens, and manual HMAC-SHA1 request signing on every call. That signature code is where most tutorials go wrong, and it is a lot of ceremony just to post one tweet.

OpenTweet replaces all of that with a single ot_ key, JSON payloads, and built-in scheduling. No developer portal application, no OAuth token exchange, no callback URLs, and no signing.

x_api_oauth.js
// The old way (X API + OAuth 1.0a)
import crypto from "crypto";
import OAuth from "oauth-1.0a";

// Step 1: approved X developer account
// Step 2: consumer + access token pairs
const oauth = new OAuth({
  consumer: { key: "...", secret: "..." },
  signature_method: "HMAC-SHA1",
  hash_function(base, key) {
    return crypto
      .createHmac("sha1", key)
      .update(base)
      .digest("base64");
  },
});

// Step 3: sign every request by hand,
// then still build your own scheduler.
opentweet.js
// The new way (OpenTweet API)
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: "Bearer ot_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "Hello from Node.js!",
    scheduled_date: "2026-07-10T09:00:00Z",
  }),
});
// Done. Scheduled. No OAuth. No signing.

Step-by-Step: Node.js to Twitter

1

Get an ot_ API key

Sign up for OpenTweet, connect your X account, and open the developer page at opentweet.io/developer. Generate an API key. It starts with ot_ and is the only credential you need. Store it in an environment variable, never in your source.

bash
# Store your key as an environment variable
export OPENTWEET_API_KEY="ot_your_key"

# Sanity check the key with GET /me
curl https://opentweet.io/api/v1/me \
  -H "Authorization: Bearer $OPENTWEET_API_KEY"
2

Post your first tweet

Send a POST to /api/v1/posts with a JSON body. Set publish_now to true to send it live right away. This uses the global fetch on Node 18+.

post-tweet.mjs
const res = await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "Hello from Node.js!",
    publish_now: true,
  }),
});

console.log(res.status); // 201 = created
console.log(await res.json());
3

Schedule a tweet for later

Swap publish_now for a scheduled_date in ISO 8601 format. The two fields are mutually exclusive. Omit both and the post is saved as a draft instead.

schedule-tweet.mjs
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "Scheduled from Node.js!",
    scheduled_date: "2026-07-10T09:00:00Z",
  }),
});

publish_now and scheduled_date are exclusive

Send one or the other, never both in the same request. To save a draft you can edit later, leave both out and OpenTweet stores the post unscheduled.
4

Post a thread

Set is_thread to true and pass thread_tweets as an array of strings. OpenTweet chains them into a native X thread. Add publish_now or scheduled_date the same way.

post-thread.mjs
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    is_thread: true,
    thread_tweets: [
      "Thread: shipping to X from Node in one call.",
      "No OAuth 1.0a, no request signing, no dev account.",
      "Just fetch, a Bearer key, and JSON. That's it.",
    ],
    thread_media: [[], [], []],
    publish_now: true,
  }),
});
5

Handle 401, 403, and 429

fetch does not throw on HTTP errors, so always check response.ok and the status. 401 means a missing or bad key, 403 means your plan or account cannot perform the action, and 429 means you hit a rate limit. The JSON body carries the reason.

handle-errors.mjs
const res = await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ text: "Hi", publish_now: true }),
});

if (!res.ok) {
  const err = await res.json().catch(() => ({}));
  if (res.status === 401) throw new Error("Bad or missing ot_ key");
  if (res.status === 403) throw new Error("Plan or account cannot post: " + (err.error || ""));
  if (res.status === 429) throw new Error("Rate limited, retry after a pause");
  throw new Error(`OpenTweet ${res.status}: ${err.error || res.statusText}`);
}
6

Wrap it in a reusable postTweet() helper

Pull the fetch into one async postTweet() function that sets the Authorization header once and throws on non-2xx responses. Now any part of your app can just await postTweet(...).

opentweet.mjs
const BASE = "https://opentweet.io/api/v1";
const KEY = process.env.OPENTWEET_API_KEY;

export async function postTweet(text, options = {}) {
  const res = await fetch(`${BASE}/posts`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ text, ...options }),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`OpenTweet ${res.status}: ${err.error || res.statusText}`);
  }
  return res.json();
}

// Publish now
await postTweet("Shipped it.", { publish_now: true });

// Schedule for later
await postTweet("Good morning X.", {
  scheduled_date: "2026-07-10T09:00:00Z",
});

Batch schedule up to 50 at once

Create posts as drafts first, collect their ids, then hand them to the batch-schedule endpoint. One request instead of fifty.

batch-schedule.mjs
import { postTweet } from "./opentweet.mjs";

const BASE = "https://opentweet.io/api/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
  "Content-Type": "application/json",
};

const texts = [
  "Monday: ship something today.",
  "Tuesday: write the test first.",
  "Wednesday: simplicity beats cleverness.",
];

// Create drafts (omit publish_now and scheduled_date) and collect ids
const ids = [];
for (const text of texts) {
  const draft = await postTweet(text); // saved as a draft
  ids.push(draft.id);
}

// Schedule them at 9:00 UTC on consecutive days
const start = new Date("2026-07-10T09:00:00Z");
const schedules = ids.map((post_id, i) => ({
  post_id,
  scheduled_date: new Date(start.getTime() + i * 86400000).toISOString(),
}));

const res = await fetch(`${BASE}/posts/batch-schedule`, {
  method: "POST",
  headers: HEADERS,
  body: JSON.stringify({ schedules }),
});
console.log(`Scheduled ${schedules.length} tweets`, res.status);

Pro Tips

Keep the key in the environment

Read process.env.OPENTWEET_API_KEY instead of hardcoding your ot_ key. Add it to a local .env and to your CI or host secrets for production.

Verify the key at startup

Call GET /me once when your service boots. A 200 confirms the key is live before you try to post, so you fail fast on a bad or revoked key.

List what is queued

GET /posts?status=scheduled returns everything waiting to go out. Handy for a dashboard, a health check, or a nightly report of the week ahead.

Pipe an AI model into postTweet

Generate copy with Claude, then await postTweet(text, { publish_now: true }). You have a content pipeline in a dozen lines of Node.

Common Mistakes to Avoid

Forgetting fetch does not throw

A 401 or 429 still resolves the promise. If you skip the response.ok check, failures pass silently and your tweet never goes out. Always inspect the status.

Sending publish_now and scheduled_date together

They are mutually exclusive. Send one or the other. Include both and the request is rejected. Omit both only when you want a draft.

Using api.opentweet.io

That host does not resolve. The REST base is https://opentweet.io/api/v1. Point every request there and keep your ot_ key in the Authorization header.

Frequently Asked Questions

How do I post to Twitter from Node.js?

Send a POST request to https://opentweet.io/api/v1/posts using Node's built-in fetch. Set the Authorization header to Bearer ot_your_key and send a JSON body like {"text":"Hello","publish_now":true}. No OAuth 1.0a, no consumer keys, no Twitter developer account. Works on Node 18+ where fetch is global.

Do I need the Twitter API or OAuth to tweet from Node?

No. The X API requires a developer account and OAuth 1.0a request signing, which is painful to implement by hand in Node. OpenTweet uses a single ot_ Bearer key and plain JSON, so there is no OAuth flow, no signature, and no developer portal application to build.

Can I schedule tweets from Node.js?

Yes. Send a scheduled_date in ISO 8601 format instead of publish_now. The two fields are mutually exclusive. OpenTweet publishes the tweet at that time, so you never run your own cron or background worker. To schedule many at once, POST to /posts/batch-schedule with up to 50 schedules.

Which Node versions work with OpenTweet?

Any Node version with fetch works out of the box, meaning Node 18 and newer. On older versions you can install node-fetch or axios and call the same endpoint. There is no OpenTweet SDK to install because the API is plain REST with JSON.

Start posting from Node.js today

Get your ot_ key in 2 minutes. Post your first tweet in one fetch call.