Cloudflare Workers Tutorial

Post to Twitter from Cloudflare Workers
on a cron trigger

A scheduled() handler, a wrangler.toml cron trigger, and one fetch to OpenTweet. No twitter-api-v2 runtime errors, no OAuth 1.0a signing, no X developer account.

7-day free trial • No credit card required

Why twitter-api-v2 breaks in the Workers runtime

If you npm install twitter-api-v2 and import it into a Worker, the deploy or the first call fails. The library signs every write with OAuth 1.0a, which means HMAC-SHA1 through Node's crypto module plus other Node built-ins. The Workers runtime is workerd, not Node, so those APIs are simply not there.

To be fair, workarounds exist. The nodejs_compat flag polyfills part of node:crypto, and some developers drop the library entirely and rebuild the OAuth 1.0a signature by hand with oauth-1.0a plus crypto-js, or with raw Web Crypto. It can be made to work. But it is fragile: one wrong percent-encode in the signature base string and X answers 401 "Could not authenticate you" with no further detail. You also still need an approved X developer account, four separate credentials in your Worker, and pay-per-use billing on every call.

The path that just works in Workers is a plain fetch, and OpenTweet's REST API is built for exactly that. One ot_ Bearer key, JSON in, JSON out. OpenTweet holds the X OAuth tokens and handles refresh, so your Worker keeps a single secret and zero signing code.

what-fails.js
// The Node path: breaks in workerd
import { TwitterApi } from "twitter-api-v2";
// Error: No such module "node:crypto"
// (or a signing failure after polyfills)

const client = new TwitterApi({
  appKey: "...", appSecret: "...",
  accessToken: "...", accessSecret: "...",
});
await client.v2.tweet("Hello");
what-works.js
// The Workers path: one plain fetch
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "Hello from a Cloudflare Worker!",
    publish_now: true,
  }),
});

Step-by-Step: Cloudflare Workers 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 your Worker needs. You will store it as a Worker secret in step 4, never in wrangler.toml or your source.

bash
# Sanity check the key with GET /me before wiring the Worker
curl https://opentweet.io/api/v1/me \
  -H "Authorization: Bearer ot_your_key"
2

Write the scheduled() handler

Create src/index.js. Cron triggers invoke the scheduled() handler, not fetch(). This is the complete Worker: await one POST to /api/v1/posts and log the result.

src/index.js
export default {
  async scheduled(event, env, ctx) {
    const res = await fetch("https://opentweet.io/api/v1/posts", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.OPENTWEET_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        text: "Good morning from a Cloudflare Worker!",
        publish_now: true,
      }),
    });

    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      // Do not retry permanent 4xx errors. Only 429 and 5xx are worth a retry.
      console.error(`OpenTweet ${res.status}: ${err.error || res.statusText}`);
      return;
    }

    const data = await res.json();
    console.log(`Posted: ${data.posts[0].url}`);
  },
};

Always await the fetch

A fetch you fire without await can be cut off when the handler returns, so the request never leaves the Worker. Await it directly, or hand the promise to ctx.waitUntil().
3

Add a cron trigger to wrangler.toml

Add a [triggers] section with a crons array. Cloudflare calls scheduled() on every expression you list. No polyfills or compatibility flags are needed, the Worker below is pure fetch.

wrangler.toml
name = "daily-tweet"
main = "src/index.js"
compatibility_date = "2026-07-01"

[triggers]
crons = ["0 9 * * *"]

Cron triggers run in UTC

0 9 * * * fires once a day at 09:00 UTC. Convert from your timezone before picking the expression.
4

Set the secret and deploy

Store the key with wrangler secret put. It is encrypted, hidden from the dashboard after saving, and exposed to the handler as env.OPENTWEET_API_KEY. For local dev, put the same variable in a .dev.vars file and keep that file out of git.

bash
# Paste your ot_ key when prompted
npx wrangler secret put OPENTWEET_API_KEY

# Ship it. The cron registers from wrangler.toml on deploy.
npx wrangler deploy
5

Test the cron and verify

You do not have to wait for 09:00 UTC. Wrangler can fire the scheduled() handler on demand in local dev, and wrangler tail streams the production logs live. On the OpenTweet side, list posted or scheduled posts to confirm they land.

bash
# Fire the handler locally without waiting for the schedule
npx wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=0+9+*+*+*"

# Watch production runs live
npx wrangler tail

# Confirm posts landed
curl "https://opentweet.io/api/v1/posts?status=posted" \
  -H "Authorization: Bearer ot_your_key"

Queue for later instead of posting now

Swap publish_now for a scheduled_date in ISO 8601 format. The two are mutually exclusive. OpenTweet publishes at that time, so a single nightly cron trigger can fill tomorrow's queue.

src/index.js
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "Scheduled by tonight's Worker cron run.",
    scheduled_date: "2026-07-11T09:00:00Z",
  }),
});

publish_now and scheduled_date are exclusive

Send one or the other, never both in the same request. Omit both and the post is saved as a draft you can edit later.

Pro Tips

No fetch handler, no public URL

A Worker with only a scheduled() handler exposes nothing to the internet, so there is no route to guard with a secret. That is one real advantage over cron endpoints on Vercel or other platforms.

Run several schedules in one Worker

List multiple expressions in the crons array and branch on event.cron inside scheduled() to post different content at different times, all from a single deploy.

Batch a week ahead

A weekly cron can POST to /posts/batch-schedule with up to 50 schedules at once, filling the whole week in a single run instead of one fetch per day.

Generate the text with Workers AI or an LLM API

Call a model inside scheduled() to draft the copy, then POST the text with publish_now true. The Worker becomes a fully hands-off content pipeline at the edge.

Common Mistakes to Avoid

Fighting twitter-api-v2 in workerd

Hours go into nodejs_compat flags and hand-rolled OAuth 1.0a signatures that break on one wrong percent-encode. If your Worker only needs to post, a plain fetch with a Bearer key removes the whole problem class.

Putting the key in [vars]

Plain-text vars in wrangler.toml end up in git and in the dashboard. Use wrangler secret put for the ot_ key in production and a gitignored .dev.vars file locally.

Retrying a permanent 4xx

A 400, 401, or 403 will fail every time until you fix the request. Retrying wastes runs and can hit your daily limit. Only retry 429 and 5xx with backoff.

Frequently Asked Questions

How do I post a tweet from a Cloudflare Worker?

Write a scheduled() handler that sends a POST to https://opentweet.io/api/v1/posts with your ot_ key as a Bearer token and a JSON body like {"text":"Hello","publish_now":true}, then add a cron trigger in wrangler.toml. It is one plain fetch, so it runs in the Workers runtime with no Node.js APIs, no OAuth 1.0a signing, and no X developer account.

Does twitter-api-v2 work in Cloudflare Workers?

Not out of the box. twitter-api-v2 signs requests with OAuth 1.0a using Node's crypto module and other Node built-ins, and the Workers runtime is not Node. The nodejs_compat flag polyfills part of node:crypto, and some developers rebuild the signature with oauth-1.0a plus crypto-js or the Web Crypto API, but the setup is fragile and you still need an approved X developer account, four X credentials, and pay-per-use billing on every call. A plain fetch to OpenTweet avoids all of it.

How do I test a Workers cron trigger locally?

Run npx wrangler dev --test-scheduled, then hit the special endpoint curl "http://localhost:8787/__scheduled?cron=0+9+*+*+*" to invoke the scheduled() handler immediately with that cron expression. In production, npx wrangler tail streams the handler's console output live, and the Cloudflare dashboard shows past cron invocations under the Worker's Triggers tab.

Can a Worker schedule tweets for later instead of posting immediately?

Yes. In the POST body, send a scheduled_date in ISO 8601 format instead of publish_now. The two fields are mutually exclusive. OpenTweet publishes at that time, so one Worker cron run can fill days of queue. To queue many at once, POST to /posts/batch-schedule with up to 50 schedules.

What if my Worker hits a rate limit?

Over the per-minute or daily post limit, the API returns HTTP 429 Too Many Requests with a JSON error body. Do not retry permanent 4xx errors like 400, 401, or 403. Only retry 429 and 5xx responses, and use backoff between attempts. Log the status and body with console.error so npx wrangler tail shows exactly what happened.

Ship your first Worker tweet today

Get your ot_ key in 2 minutes. Wire it into a scheduled() handler in a few lines.