# Pull tweets into Google Sheets with Apps Script

> An Apps Script that imports tweets from an X search or account into Google Sheets every hour, without duplicates and without an X developer account. Python and Node.js versions included.

HTML page: https://www.twittrapi.com/blog/twitter-to-google-sheets
Published 2026-10-11.

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](https://www.twittrapi.com/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

1. Create a new Google Sheet (or open an existing one).
2. Go to **Extensions > Apps Script**. A new project opens, bound to this sheet.
3. In the editor, open **Project Settings** (the gear icon), scroll to **Script properties** and add a property named `TWITTRAPI_API_KEY` with 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:

```js
// 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 /search`](https://www.twittrapi.com/docs/reference/search) with `category=Latest` and the [simple format](https://www.twittrapi.com/docs/simple-format), so every tweet is a flat object with `id`, `createdAt`, `author`, `text` and `counts`.
- 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 `nextCursor` for up to `MAX_PAGES` pages if more than one page of new tweets arrived.
- If more new tweets arrive in an hour than `MAX_PAGES` pages hold, the oldest of them are skipped. Raise `MAX_PAGES` or 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](https://www.twittrapi.com/docs/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](https://www.twittrapi.com/blog/search-twitter-without-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

Figure: an hourly Apps Script trigger calls /search once and appends new tweets to the sheet: 720 calls a month, $0.065 at the Standard price.

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:

```js
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](https://docs.gspread.org/):

```python
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 formula
```

Node.js with [googleapis](https://www.npmjs.com/package/googleapis):

```js
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](https://www.twittrapi.com/docs/no-code) 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](https://www.twittrapi.com/blog/export-twitter-followers-to-csv) has the script.

## Sources

- [Apps Script quotas (developers.google.com)](https://developers.google.com/apps-script/guides/services/quotas), checked 2026-10-11
- [Installable triggers (developers.google.com)](https://developers.google.com/apps-script/guides/triggers/installable)
- [Search endpoint reference](https://www.twittrapi.com/docs/reference/search) and [simple format](https://www.twittrapi.com/docs/simple-format)

TwittrAPI is not affiliated with X Corp. It reads only public data.
