This guide sets up a Google Sheet that fills itself with tweets: every hour an Apps Script asks for new tweets matching a search (mentions of your brand, a hashtag, one account's posts) and appends them as rows, oldest first, without duplicates. It needs no server and no add-on, only a sheet, about 80 lines of script and an API key.
The script calls TwittrAPI, a third-party API for public X data, so you need no X developer account. If you would rather run the import from your own machine or a server, the Python and Node.js versions are at the end.
Step 1: Get an API key
Create a key on /start. New accounts get $0.10 of test credit for 14 days. An hourly import of one search uses 24 calls a day when nothing new turns up, a little more on busy days, so the test credit lasts the whole trial.
Step 2: Open the script editor and store the key
- Create a new Google Sheet (or open an existing one).
- Go to Extensions > Apps Script. A new project opens, bound to this sheet.
- In the editor, open Project Settings (the gear icon), scroll to Script properties and add a property named
TWITTRAPI_API_KEYwith your key as the value.
Script properties keep the key out of the code, so you can share the sheet or copy the script without handing out the key. Anyone with edit access to the script can still read it, so do not give script edit access to people who should not use your balance.
Step 3: Paste the import script
Replace the contents of Code.gs with this and change QUERY to what you want to collect:
// Imports tweets matching QUERY into the "Tweets" sheet. Run importTweets once by
// hand, then add an hourly trigger (Step 5).
const API = "https://api.twittrapi.com";
const QUERY = "from:nasa -filter:replies"; // any X search: "#rustlang", "@acme OR url:acme.com" ...
const SHEET_NAME = "Tweets";
const MAX_PAGES = 5; // safety limit per run; each page is one billed call
const HEADER = ["id", "createdAt", "author", "text", "likes", "retweets", "replies", "views", "url"];
function importTweets() {
const props = PropertiesService.getScriptProperties();
const key = props.getProperty("TWITTRAPI_API_KEY");
if (!key) throw new Error("Add TWITTRAPI_API_KEY under Project Settings > Script properties");
const sheet = getSheet_();
const newest = props.getProperty("NEWEST_ID"); // highest tweet ID imported so far
const query = newest ? QUERY + " since_id:" + newest : QUERY;
const tweets = [];
let cursor = null;
for (let page = 0; page < MAX_PAGES; page++) {
const data = fetchPage_(key, query, cursor);
tweets.push(...data.data.filter((t) => !t.unavailable));
if (!data.hasNextPage || !newest) break; // first run: one page is enough
cursor = data.nextCursor;
}
if (tweets.length === 0) return;
tweets.sort((a, b) => compareIds_(a.id, b.id)); // oldest first
const rows = tweets.map((t) => [
t.id,
t.createdAt,
t.author ? "@" + t.author.username : "",
safeText_(t.text),
t.counts.likes,
t.counts.retweets,
t.counts.replies,
t.counts.views,
t.url,
]);
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, HEADER.length).setValues(rows);
props.setProperty("NEWEST_ID", tweets[tweets.length - 1].id);
}
function fetchPage_(key, query, cursor) {
const params = { searchItem: query, category: "Latest", count: "100", format: "simple" };
if (cursor) params.cursor = cursor;
const qs = Object.keys(params)
.map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(params[k]))
.join("&");
for (let attempt = 1; attempt <= 3; attempt++) {
const res = UrlFetchApp.fetch(API + "/search?" + qs, {
headers: { "X-API-KEY": key },
muteHttpExceptions: true,
});
const code = res.getResponseCode();
if (code === 200) return JSON.parse(res.getContentText());
if (code !== 429 && code < 500) throw new Error("HTTP " + code + ": " + res.getContentText());
Utilities.sleep(2000 * attempt); // 429 and 5xx: wait and retry
}
throw new Error("API still failing after 3 attempts");
}
function getSheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);
if (sheet.getLastRow() === 0) {
sheet.appendRow(HEADER);
sheet.getRange("A:A").setNumberFormat("@"); // keep 19-digit IDs as text
sheet.setFrozenRows(1);
}
return sheet;
}
// Tweet IDs are too large for JavaScript numbers; compare them as digit strings.
function compareIds_(a, b) {
return a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
}
// A tweet that starts with = + - or @ would be read as a formula.
function safeText_(text) {
return /^[=+\-@]/.test(text) ? "'" + text : text;
}What it does:
- It calls
GET /searchwithcategory=Latestand the simple format, so every tweet is a flat object withid,createdAt,author,textandcounts. - After the first run it adds
since_id:with the highest ID already in the sheet, so X only returns newer tweets and nothing is imported twice. - The first run takes one page (about 20 to 100 tweets). Later runs follow
nextCursorfor up toMAX_PAGESpages if more than one page of new tweets arrived. - If more new tweets arrive in an hour than
MAX_PAGESpages hold, the oldest of them are skipped. RaiseMAX_PAGESor run the trigger more often for busy searches. - 429 and 5xx answers are retried with a short wait. 429 is not charged and 5xx is refunded (billing).
For one account's posts, from:handle in the query is the simplest choice. The search syntax is covered in Search Twitter/X without a developer account.
Step 4: Run it once and allow access
Select importTweets in the function drop-down and click Run. Google asks you to authorize the script the first time: it needs access to this spreadsheet and permission to connect to an external service (api.twittrapi.com). After that, a "Tweets" sheet appears with a header row and the first page of results.
If you see Add TWITTRAPI_API_KEY..., the script property is missing or misspelled. HTTP 401 means the key is wrong, HTTP 402 that the balance is used up, and HTTP 422 that X rejected the query (an unsupported operator, for example).
Step 5: Schedule it with a trigger
Hourly import: trigger, one search call, new rows
Time trigger
every hour
importTweets()
UrlFetchApp
GET /search
?category=Latest
X-API-KEY from script properties
Sheet
skip known IDs
appendRow() per new tweet
In the editor, open Triggers (the clock icon), click Add Trigger and choose:
- function:
importTweets - event source: Time-driven
- type: Hour timer, every hour
Or create it from code by running this function once:
function createTrigger() {
ScriptApp.newTrigger("importTweets").timeBased().everyHours(1).create();
}Time-driven triggers can also run every 1, 5, 10, 15 or 30 minutes. Each run costs at least one call, so every 5 minutes is 288 calls a day. Google emails you when a triggered run fails.
Apps Script limits that matter here
| Limit | Free Google account | Google Workspace |
|---|---|---|
| URL Fetch calls per day | 20,000 | 100,000 |
| Run time per execution | 6 minutes | 6 minutes |
| Total trigger run time per day | 90 minutes | 6 hours |
One import run takes a few seconds, so an hourly trigger is far below every limit. A Google Sheet holds up to 10 million cells; at 9 columns that is about a million tweets, but sheets get slow long before that. For long-running collections, start a new sheet each month or write to BigQuery instead.
Python and Node.js versions
If you prefer to run the import elsewhere, use the Google Sheets API with a service account and share the sheet with the service account's email address.
Python with gspread:
import os
import gspread
from twittrapi import TwittrAPI
client = TwittrAPI(response_format="simple") # key from TWITTRAPI_API_KEY
ws = gspread.service_account(filename="service-account.json").open_by_key(os.environ["SHEET_ID"]).worksheet("Tweets")
page = client.search.tweets("from:nasa -filter:replies", category="Latest")
tweets = sorted((t for t in page["data"] if not t.get("unavailable")), key=lambda t: int(t["id"]))
rows = [[t["id"], t["createdAt"], t["author"]["username"] if t["author"] else "", t["text"],
t["counts"]["likes"], t["counts"]["retweets"], t["url"]] for t in tweets]
ws.append_rows(rows, value_input_option="RAW") # RAW: text is never read as a formulaNode.js with googleapis:
import { google } from "googleapis";
import { TwittrAPI } from "twittrapi";
const client = new TwittrAPI({ format: "simple" }); // key from TWITTRAPI_API_KEY
const auth = new google.auth.GoogleAuth({
keyFile: "service-account.json",
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
});
const sheets = google.sheets({ version: "v4", auth });
const page = await client.search.tweets({ query: "from:nasa -filter:replies", category: "Latest" });
const rows = page.data
.filter((t) => !t.unavailable)
.map((t) => [t.id, t.createdAt, t.author?.username ?? "", t.text, t.counts.likes, t.counts.retweets, t.url]);
await sheets.spreadsheets.values.append({
spreadsheetId: process.env.SHEET_ID,
range: "Tweets!A1",
valueInputOption: "RAW",
requestBody: { values: rows },
});Add the same since_id: logic as the Apps Script (store the newest ID in a file or in a cell) before you schedule either of them with cron.
No-code alternatives
If you do not want to maintain a script, the no-code integrations cover n8n and Make, which both have Google Sheets modules: a schedule node, a TwittrAPI search node and an "append row" node do the same job.
Frequently asked questions
Can Google Sheets import tweets without an API?
Not reliably. IMPORTXML and IMPORTHTML cannot read x.com, because the pages are rendered with JavaScript and show a login wall to logged-out visitors. You need an API: the official X API with a developer account, or a third-party API such as TwittrAPI with an Apps Script like the one above.
How do I stop duplicate tweets in the sheet?
Store the highest tweet ID you imported and add since_id: with that ID to the search on the next run. X then returns only newer tweets. The script above keeps the ID in a script property.
Why do tweet IDs in my sheet end in zeros?
Sheets stores numbers with 15 significant digits, and tweet IDs have 19. Format the ID column as plain text before writing, as the script does, or the last digits are lost.
How often can the script run?
Time-driven triggers run every 1, 5, 10, 15 or 30 minutes, or every few hours. Each run is at least one API call, and free Google accounts get 90 minutes of trigger run time per day, which is plenty for imports that take a few seconds.
Can I import an account's followers into Sheets?
Yes, with the followers endpoint instead of search. For lists larger than a few thousand rows, export to CSV first and import the file. Our guide on exporting followers to CSV has the script.
Sources
- Apps Script quotas (developers.google.com), checked 2026-10-11
- Installable triggers (developers.google.com)
- Search endpoint reference and simple format
TwittrAPI is not affiliated with X Corp. It reads only public data.



