Auto-posting to X from Apps Script, and why I turned it off
I have over a thousand posts sitting in an archive. Auto-tweeting a couple of them a day seemed like an obvious thing to automate, so in 2025 I built it: a Google Sheet full of post text, a Google Apps Script that signs an OAuth 1.0a request by hand, and a timer trigger that fires twice a day.
It worked. Then it stopped: by May 2026 the account was on pay-per-use pricing and the request was failing with CreditsDepleted, and I decided not to top the balance up. What I run now posts nothing automatically — an agent picks candidates and drafts the text, I approve them, and I post the approved ones myself from X’s own interface.
So this is two things: a working reference implementation of OAuth 1.0a signing in Apps Script, which is the part that is genuinely hard to find, and an honest account of why I turned the automation off.
Originally published in Japanese on October 11, 2025. This English version was written in September 2026.
The tier this was built on no longer exists
This matters more than a normal “check the docs” caveat, so it goes before the code rather than after it.
On February 6, 2026, X launched pay-per-use pricing for the self-serve API. That announcement did not abolish the old tiers; the changelog entry says Basic and Pro “remain available, and existing subscribers can opt in to Pay-Per-Use”, that Public Utility Apps keep free scaled access, and that recently active legacy Free tier users get a one-time $10 voucher — wording that treats the Free tier as legacy. The pricing page today opens with “The X API uses pay-per-usage pricing with no subscriptions”. The tiers went afterwards: existing Basic subscribers were migrated from June 1, 2026, and Pro subscribers from September 1, 2026 — so the tier structure the Japanese original was written against is gone in both directions.
What replaced it, from the official pricing page as of September 2026:
Operation
Price
Post: Create
$0.015 per request
Post: Create (with URL)
$0.200 per request
Posts: Read
$0.005 per resource
Owned reads (your own data)
$0.001 per resource
Read that second row carefully if you are considering this project, because it is the whole economics of it. A post containing a link costs thirteen times a post without one — and a blog auto-poster is, by definition, nothing but posts containing links. Twice a day at $0.200 is roughly $12 a month to publish links nobody asked for. That is not a large sum. It is a very different proposition from “free”, which is what this was built as.
The signing logic below is unaffected. OAuth 1.0a is a frozen spec, POST /2/tweets is still the current endpoint, and OAuth 1.0a user-context tokens are still supported with no deprecation notice — though X now steers new work towards OAuth 2.0. The code still runs; the business case it was built on does not.
Both pages were reachable on September 7, 2026. Two other changes worth knowing if you are automating anything else here: programmatic replies were restricted to threads you were summoned into (February 23, 2026), and the follow, like and quote-post endpoints were removed from self-serve access altogether (announced April 16, 2026, effective April 20). The developer console has also moved to console.x.com, so the 2025 portal screenshots in the Japanese original no longer match what you will see.
The shape of it
Four moving parts, all free apart from the API itself:
A Google Sheet holds one post per row in column A.
Apps Script picks a random non-empty row.
It signs a request with OAuth 1.0a user-context credentials and POSTs to the tweet endpoint.
Two time-driven triggers run it at 10:00 and 19:00.
The reason OAuth 1.0a is in there at all: an OAuth 2.0 app-only bearer token cannot create posts. Posting is a user-context action. OAuth 1.0a with an access token and secret is the shortest path to that from a scriptable environment with no redirect handling, because there is no refresh cycle to manage — you generate the credentials once in the portal and they keep working.
The modern alternative is OAuth 2.0 with PKCE and user scopes, which is arguably the better long-term choice but means storing and refreshing tokens inside Apps Script. For a script that posts twice a day from one account, 1.0a was the pragmatic pick.
Getting credentials out of the developer portal
Sign in to the X Developer Portal with the account that will do the posting. This trips people up: the credentials post as whoever authorized them.
1. Create an app. In the Developer Console at console.x.com, a default project already exists, so you are usually only adding an app to it.
2. Configure user authentication. Open the app’s user authentication settings and set:
App permissions: Read and Write
Type of App: Web App, Automated App or Bot
Callback URI: any valid URL — nothing redirects here, since you are not running the browser flow
Website URL: your site
3. Generate keys and tokens. From the Keys and Tokens tab, generate the API key and secret, and the access token and secret. Four values in total.
The gotcha that costs an hour: if you change app permissions after issuing an access token — the classic Read Only to Read and Write upgrade — the existing access token keeps the old permission level. It will authenticate fine and then fail to post, which reads like a signing bug. Regenerate the access token and secret after any permission change.
The sheet
Name a sheet tweets and put one complete post per row in the first column:
No template logic in the script — whatever is in the cell is what gets posted. Keeping the composition in the sheet means you can edit copy without touching code.
The Apps Script
Extensions → Apps Script from the sheet, then:
// Picks a random row from the sheet and posts it to X.
// Intended to run on a time-driven trigger.
const consumerKey = "YOUR_API_KEY";
const consumerSecret = "YOUR_API_SECRET";
const accessToken = "YOUR_ACCESS_TOKEN";
const accessSecret = "YOUR_ACCESS_SECRET";
const SPREADSHEET_ID = "YOUR_SPREADSHEET_ID";
const SHEET_NAME = "tweets";
// RFC 5849 3.6 percent-encoding. encodeURIComponent leaves ! * ' ( ) alone;
// OAuth requires them encoded, and a signature built without this is simply wrong.
function pctEncode(value) {
return encodeURIComponent(String(value))
.replace(/[!*'()]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase());
}
function postRandomTweet() {
const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
const last = sheet.getLastRow();
const data = last < 1 ? [] :
sheet.getRange(1, 1, last, 1).getValues().flat()
.map(v => String(v).trim()).filter(v => v !== "");
if (data.length === 0) {
Logger.log("No rows available to post.");
return;
}
const randomIndex = Math.floor(Math.random() * data.length);
postToTwitter(data[randomIndex]);
}
function postToTwitter(tweetText) {
const apiUrl = "https://api.x.com/2/tweets";
const method = "POST";
const oauthParams = {
oauth_consumer_key: consumerKey,
oauth_nonce: Utilities.getUuid().replace(/-/g, ""),
oauth_signature_method: "HMAC-SHA1",
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
oauth_token: accessToken,
oauth_version: "1.0",
};
const baseParams = Object.keys(oauthParams)
.sort()
.map(key => `${pctEncode(key)}=${pctEncode(oauthParams[key])}`)
.join("&");
const baseString = [
method,
pctEncode(apiUrl),
pctEncode(baseParams)
].join("&");
const signingKey = `${pctEncode(consumerSecret)}&${pctEncode(accessSecret)}`;
// Apps Script signs this itself. No third-party library, and the secrets
// never leave the script.
oauthParams.oauth_signature = Utilities.base64Encode(
Utilities.computeHmacSignature(Utilities.MacAlgorithm.HMAC_SHA_1, baseString, signingKey)
);
const authHeader = "OAuth " + Object.keys(oauthParams)
.sort()
.map(key => `${pctEncode(key)}="${pctEncode(oauthParams[key])}"`)
.join(", ");
const res = UrlFetchApp.fetch(apiUrl, {
method: "POST",
contentType: "application/json",
payload: JSON.stringify({ text: tweetText }),
muteHttpExceptions: true,
headers: { Authorization: authHeader },
});
const code = res.getResponseCode();
if (code === 200 || code === 201) {
Logger.log("Posted: " + tweetText);
} else {
Logger.log("Failed (" + code + "): " + res.getContentText());
}
}
function testTweet() {
postToTwitter("Test post from Apps Script.");
}
SPREADSHEET_ID is the segment of the sheet URL between /d/ and /edit.
The signature, since that is the part that breaks
Almost every failure here is a signature failure returning a 401, so it is worth understanding what the code is doing rather than pasting it blind.
The six oauth_* parameters are sorted by key, percent-encoded, and joined with &.
The base string is the HTTP method, the encoded URL, and that encoded parameter block, joined with &.
The signing key is the consumer secret and the access token secret, each encoded, joined with &.
HMAC-SHA1 over the base string with that key, Base64 encoded, becomes oauth_signature. All seven parameters then go into the Authorization header.
Two details that matter and are easy to miss. The JSON body is not part of the signature. For a v1.1 form-encoded request the body parameters would be signed; for this v2 endpoint the payload is JSON and stays out of the base string. If you adapt this from a v1.1 example, that is the difference.
And the URL in the base string must match the request URL exactly — same scheme, same host, no trailing slash, no query string.
One detail that is easy to get wrong: encodeURIComponent is not the percent-encoding OAuth specifies. RFC 5849 §3.6 requires everything outside ALPHA / DIGIT / - . _ ~ to be encoded, and encodeURIComponent leaves ! * ' ( ) alone. I checked this against X’s own worked example: with pctEncode the signature base string matches their published value byte for byte and the signature comes out as Ls93hJiZbQ3akF3HF3x1Bz8/zU4=, exactly as documented. With plain encodeURIComponent the ! in their sample status text stays literal and the signature is wrong. That is why pctEncode is used everywhere in the code above rather than offered as an aside.
The six oauth_* parameters this script signs happen not to contain those characters, so the old version worked. Adapt it to v1.1, or to any endpoint with query parameters or user-supplied values in the signature, and it stops working.
The version I originally ran pulled CryptoJS from a CDN and eval()-ed it at every execution. Do not do that. It hands a third-party host arbitrary code execution in the same scope as your API secrets, and because the call sits at the top level it also spends a UrlFetch on every run, so a CDN blip takes the whole script down before any function starts. Apps Script has Utilities.computeHmacSignature built in, which is what the code above uses — no library, and the secrets never leave the script. Vendoring the HMAC implementation into the project, or moving the secrets into Script Properties instead of source constants, would both be improvements.
Triggers
In the Apps Script editor, add two time-driven triggers on postRandomTweet, at 10:00 and 19:00. Apps Script fires within an hour window rather than at the exact minute, which is fine for this and worth knowing if you were expecting precision.
Note that random selection with no memory means repeats. With a large enough sheet it does not matter much; if it bothers you, write the chosen row index to a second sheet and skip recent ones.
Why I stopped running it
The script did not break. The credit balance the account had been migrated onto ran out — a billing-model change rather than a quota — and I had to decide whether the output was worth paying for.
It was not, and the reason had nothing to do with cost. Fully automated posting produces a feed that is technically active and editorially dead. A random row from a thousand-row sheet does not know what happened this week, which posts I have since rewritten, or which ones I would now be embarrassed to point at. It just posts.
What I moved to keeps the automation on the expensive parts and puts a human at the end. Codex shortlists candidate posts and drafts the accompanying text; I look at the list and approve or reject; I then post the approved ones myself from X’s own interface. No API tier, no credits, no signing.
That last detail is a correction, not a stylistic preference. I originally had an agent drive a logged-in browser session to do the posting, and having approved each post myself seemed to make that fine. It does not. X’s Developer Guidelines ask separately whether you are “only using the official API (not scraping/browser automation)”, and their prohibited-practices table lists non-API automation — browser scripting, scraping, any automation outside the official API — with violations able to result in app suspension, API access revocation, or permanent account bans. Who approved the text and how the text reaches X are two different rules. I wrote that up separately in “Human approval did not make my X automation compliant”; if you are here for the workflow rather than the signing code, read that one before copying anything.
The work that was actually tedious — deciding what is worth resurfacing and writing something to say about it — is still automated. The one step that was cheap and that I was wrong to delegate, the final yes, is not.
If you are building this today
The signing code above still stands, and Apps Script is still a good place to run a small scheduled job for free — 20,000 UrlFetch calls a day and 90 minutes of trigger runtime on a consumer account is far more headroom than this needs.
What changed is the other end. There is no free tier to prototype against any more, so the first decision is now a billing decision: you top up credit, and every link post costs $0.200. Price the thing before you build it, which is the step I got to skip in 2025.
And ask the question I skipped: if this posts twice a day forever with no one reading the output first, is that a feed you would want to follow? At $0.015 a post that question is easy to avoid. At $0.200 a post, with a card attached, you end up answering it.