Post to Twitter from Google Sheets
one row, one tweet
A sheet with text and a datetime, one Apps Script, and one UrlFetchApp call to OpenTweet. No OAuth 1.0a signing, no token refresh, no X developer account.
7-day free trial • No credit card required
Why Apps Script plus the raw X API is a bad time
Calling POST /2/tweets on the X API from Apps Script means a developer account first, then user-context auth. The OAuth 1.0a route makes you build the signature yourself: percent-encode every parameter, assemble a signature base string, generate a nonce and timestamp, and sign it withUtilities.computeHmacSignature. Get one byte of the encoding wrong and X returns 401 with no hint which byte.
The OAuth 2.0 route trades signing for token lifecycle: user access tokens expire after 2 hours and refresh tokens are single-use, so your script has to persist the newest refresh token in Script Properties on every run. If a trigger fires while another run holds the fresh token, the chain breaks and posting stops until a human re-authorizes. We wrote up that exact failure in fixing expired X OAuth refresh tokens. And every write bills against X's pay-per-use API pricing on top of the engineering.
OpenTweet replaces all of it with a single static ot_ key and JSON. OpenTweet holds the X OAuth tokens and refreshes them server-side, so your script is one UrlFetchApp.fetch call that never breaks at 3am.
| Tweet text | Post at | Status | Post ID |
|-----------------------|-------------------|--------|---------|
| Shipping v2 today... | 2026-07-10 09:00 | | |
| Behind the scenes... | 2026-07-10 15:30 | | |
| Just hit 1k users... | | | |// One row becomes one POST. That is the whole trick.
UrlFetchApp.fetch('https://opentweet.io/api/v1/posts', {
method: 'post',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + key,
},
payload: JSON.stringify({
text: 'Shipping v2 today...',
scheduled_date: '2026-07-10T09:00:00Z',
}),
});Step-by-Step: Google Sheets to Twitter
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 script needs. You will store it in Script Properties, never in the code.
# Sanity check the key with GET /me before touching the sheet
curl https://opentweet.io/api/v1/me \
-H "Authorization: Bearer ot_your_key"Set up the sheet
Create a tab named Tweets with a header row and four columns: Tweet text (A), Post at (B, a datetime cell, leave empty to post immediately), Status (C, the script fills this), and Post ID (D, the script fills this too). Each row is one tweet.
Set the spreadsheet timezone first
Apps Script reads datetime cells against the spreadsheet timezone (File > Settings). Set it to your timezone before entering times, and the script'stoISOString() conversion to UTC will be exactly right.Paste the Apps Script
Open Extensions > Apps Script, delete the placeholder, and paste this complete Code.gs. It reads every row without a status, sends rows with a future datetime as scheduled_date and rows without one as publish_now, then writes the result back so no row is ever sent twice.
var API_URL = 'https://opentweet.io/api/v1/posts';
var SHEET_NAME = 'Tweets';
function syncSheetToOpenTweet() {
var key = PropertiesService.getScriptProperties().getProperty('OPENTWEET_API_KEY');
if (!key) throw new Error('Add OPENTWEET_API_KEY in Project Settings > Script Properties.');
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
var rows = sheet.getDataRange().getValues();
// Skip the header row. Columns: A text, B datetime, C status, D post id.
for (var i = 1; i < rows.length; i++) {
var text = rows[i][0];
var postAt = rows[i][1];
var status = rows[i][2];
if (!text || status) continue;
var payload = { text: String(text) };
if (postAt instanceof Date && postAt.getTime() > Date.now()) {
payload.scheduled_date = postAt.toISOString();
} else {
payload.publish_now = true;
}
var res = UrlFetchApp.fetch(API_URL, {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + key },
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
var code = res.getResponseCode();
if (code === 201) {
var data = JSON.parse(res.getContentText());
sheet.getRange(i + 1, 3).setValue(payload.publish_now ? 'posted' : 'scheduled');
sheet.getRange(i + 1, 4).setValue(data.posts[0].id);
} else if (code === 429) {
// Daily or per-minute limit. Stop here; the next trigger run picks these rows up.
sheet.getRange(i + 1, 3).setValue('rate limited, will retry');
return;
} else {
// 400/401/403/409 are permanent for this row. Record it and move on.
sheet.getRange(i + 1, 3).setValue('error ' + code + ': ' + res.getContentText().slice(0, 140));
}
}
}muteHttpExceptions matters
Without it, UrlFetchApp throws on any non-2xx response and the whole run dies on the first bad row. With it, you read the status code yourself and handle each row independently.Store the key and add a time-driven trigger
In the Apps Script editor open Project Settings > Script Properties and add OPENTWEET_API_KEY with your ot_ key as the value. Then open the Triggers page (clock icon in the left sidebar), click Add Trigger, choose syncSheetToOpenTweet, event source Time-driven, and an Hour timer. Or create it in code:
// Run this once from the editor to install the trigger.
function createTrigger() {
ScriptApp.newTrigger('syncSheetToOpenTweet')
.timeBased()
.everyHours(1)
.create();
}Approve the authorization prompt
The first manual run asks for permission to read the spreadsheet and connect to an external service. That is Google authorizing your own script, not an X OAuth flow. Approve it once and triggers run unattended after that.Verify posts land
Add one test row with a datetime a few minutes out, select syncSheetToOpenTweet in the editor toolbar, and hit Run. Column C should flip to scheduled and column D should get an id. Confirm from the API side too:
# Everything the sheet has queued
curl "https://opentweet.io/api/v1/posts?status=scheduled" \
-H "Authorization: Bearer ot_your_key"
# Published rows, with the live X URL for each
curl "https://opentweet.io/api/v1/posts?status=posted" \
-H "Authorization: Bearer ot_your_key"Batch variant: schedule 50 rows in one request
If you plan content in bulk, one row-per-request loop is wasteful. The same endpoint accepts a posts array of up to 50 items, each with its own scheduled_date. A whole content-calendar sheet goes up in a single UrlFetchApp call.
function batchScheduleFromSheet() {
var key = PropertiesService.getScriptProperties().getProperty('OPENTWEET_API_KEY');
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
var rows = sheet.getDataRange().getValues();
var posts = [];
var rowNumbers = [];
for (var i = 1; i < rows.length; i++) {
if (!rows[i][0] || rows[i][2] || !(rows[i][1] instanceof Date)) continue;
posts.push({
text: String(rows[i][0]),
scheduled_date: rows[i][1].toISOString(),
});
rowNumbers.push(i + 1);
if (posts.length === 50) break; // API maximum per request
}
if (posts.length === 0) return;
var res = UrlFetchApp.fetch(API_URL, {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + key },
payload: JSON.stringify({ posts: posts }),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 201) {
throw new Error('Batch failed: ' + res.getContentText());
}
var created = JSON.parse(res.getContentText()).posts;
for (var j = 0; j < created.length; j++) {
sheet.getRange(rowNumbers[j], 3).setValue('scheduled');
sheet.getRange(rowNumbers[j], 4).setValue(created[j].id);
}
}Batch validation is all-or-nothing
If any item in the array has empty text, a past date, or a bad date format, the API returns 400 with a per-itemdetails list and creates nothing. Fix the flagged rows and rerun. Every scheduled_date must be in the future.There is also a dedicated /api/v1/posts/batch-schedule endpoint for assigning dates to drafts that already exist in OpenTweet. For a sheet-driven flow the posts array above is simpler: create and schedule in one call. More patterns in bulk schedule tweets.
Pro Tips
Let OpenTweet handle the timing
Apps Script time-driven triggers fire within a window, not at an exact minute. Send scheduled_date and let OpenTweet publish at the precise time. The trigger only needs to sync new rows, so hourly is plenty.
Idempotency-Key against double-sends
If a run crashes after the POST but before the status write, the next run resends that row. Add an Idempotency-Key header (spreadsheet id + row number works) and the API replays the original response instead of posting twice.
Threads and images fit in extra columns
Add a column with follow-up tweets and send is_thread:true with a thread_tweets array. Add another with public image URLs and send media_urls. Same endpoint, same request.
Read the 409 before overriding it
A 409 means the duplicate-content guard matched text you already posted or queued. Usually that is the sheet saving you from yourself. If the repeat is intentional, resend that row with allow_duplicate:true.
Common Mistakes to Avoid
Hardcoding the ot_ key in Code.gs
Anyone with edit access to the sheet can open the script and read it. Keep the key in Script Properties, reference it with PropertiesService, and rotate it on the developer page if it ever leaks.
Skipping the Status write-back
Without marking sent rows, every trigger run resends the whole sheet. The duplicate guard will 409 identical text, but each attempt still burns rate limit. Write the status back and skip non-empty rows.
Formatting dates by hand
A string like "7/10/2026 9:00" is not ISO 8601 and gets rejected with a 400. Keep column B as a real datetime cell and let toISOString() produce the UTC timestamp. Set the spreadsheet timezone so it converts from the right zone.
Frequently Asked Questions
How do I post to Twitter from Google Sheets?
Add an Apps Script (Extensions > Apps Script) that reads rows from your sheet and sends a POST to https://opentweet.io/api/v1/posts with UrlFetchApp. Authenticate with an OpenTweet ot_ key as a Bearer token and a JSON body like {"text":"Hello","publish_now":true}. A time-driven trigger runs the script on a schedule. No OAuth 1.0a signing and no X developer account are needed.
Can Google Apps Script post to Twitter through the X API directly?
It can, but it is painful. Posting to the X API needs a developer account plus OAuth 1.0a request signing (nonce, timestamp, HMAC-SHA1 signature base string) or OAuth 2.0 user tokens that expire every 2 hours with single-use refresh tokens you must persist and rotate in Script Properties. X also bills API writes pay per use. OpenTweet replaces all of that with one static Bearer key and plain JSON.
How do I schedule tweets from Google Sheets for a specific time?
Put a datetime in the Post at column. The script converts it with toISOString() and sends it as scheduled_date in the POST body. OpenTweet publishes at that exact time, so your Apps Script trigger only needs to sync new rows, not fire at the posting moment. publish_now and scheduled_date are mutually exclusive: send one or the other.
How often should the time-driven trigger run?
Hourly is plenty. Because OpenTweet handles the exact publish time via scheduled_date, the trigger only pushes new rows into the queue. Apps Script time-driven triggers also fire within a window rather than at an exact minute, which is another reason not to rely on the trigger itself for timing.
What happens if the same row gets sent twice?
The script writes a status back to column C and skips any row that already has one, so normal runs never resend. As a second net, OpenTweet's duplicate-content guard returns HTTP 409 when a single create matches something you already posted or queued. Resend with allow_duplicate:true only if the repeat is intentional.
Can I post threads or images from a Google Sheet?
Yes. The same POST /api/v1/posts endpoint accepts is_thread:true with a thread_tweets array for threads, and media_urls with publicly reachable image URLs for media. Add extra columns for those fields and extend the payload object in the script.
Keep exploring
OpenTweet vs the X API
Post, schedule, and automate X without a developer account or the $200/mo minimum.
Post to X without an API
The clean, account-safe way to post to X from your code or an AI agent.
Twitter MCP Server
Give Claude, Cursor, and OpenClaw the ability to post to X. 36 tools included.
Developer docs
Quickstart, API keys, MCP setup, and the REST reference for posting to X from code or an agent.
XMCP vs OpenTweet
X's official MCP server bills per API call and cannot schedule. Compare it with the flat-fee hosted MCP.
MCP for AI agents
Connect your AI client to X in under two minutes, no X developer account.
Developer API and keys
REST endpoints, one bearer key, and usage tracking. Build on OpenTweet.
OpenTweet for AI agents
The posting layer for autonomous agents and automations that live on X.
Build an AI Twitter persona
Give your AI agent its own X account. Setup, cadence, and the rules that keep it safe.
Turn your sheet into a tweet queue today
Get your ot_ key in 2 minutes. Paste one script, add a trigger, and the sheet runs itself.