AutomationFlowsSocial Media › Twitter Stream to Google Sheets Monitor

Twitter Stream to Google Sheets Monitor

Original n8n title: Twitter Stream Monitor

Twitter Stream Monitor. Uses googleDrive, googleSheets. Webhook trigger; 19 nodes.

Webhook trigger★★★★☆ complexity19 nodesGoogle DriveGoogle Sheets
Social Media Trigger: Webhook Nodes: 19 Complexity: ★★★★☆ Added:

This workflow follows the Google Drive → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Twitter Stream Monitor",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "twitter-stream-monitor",
        "options": {
          "responseCode": {
            "values": {}
          }
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -944,
        144
      ],
      "id": "80671ad3-078a-4bf8-be69-05ce8a61f8b9",
      "name": "Twitter Stream Webhook"
    },
    {
      "parameters": {
        "jsCode": "// Get input data - headers are inside json!\nconst data = $input.item.json;\nconst body = data.body || data;\nconst headers = data.headers || {};\n\n// Your TwitterAPI.io API key\nconst EXPECTED_API_KEY: \"YOUR_API_KEY\";\n\nconsole.log('\ud83d\udccb Checking API key...');\n\n// Get API key from headers\nconst receivedApiKey = headers['x-api-key'];\n\nif (!receivedApiKey) {\n  console.log('\u274c No API key found in headers');\n  throw new Error('No API key found in request');\n}\n\nif (receivedApiKey !== EXPECTED_API_KEY) {\n  console.log(`\u274c Invalid API key: got \"${receivedApiKey}\"`);\n  throw new Error('Invalid API key');\n}\n\nconsole.log('\u2705 Valid webhook request from TwitterAPI.io');\nconsole.log(`\ud83d\udce5 Event: ${body.eventtype || body.event_type}`);\nconsole.log(`\ud83d\udce5 Rule: ${body.ruletag || body.rule_tag}`);\nconsole.log(`\ud83d\udce5 Tweets: ${body.tweets?.length || 0}`);\n\n// Pass through the BODY only (not the headers wrapper)\nreturn [{\n  json: body\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -736,
        144
      ],
      "id": "aa8bdf8a-d18e-4b63-b97d-efab427ae2f2",
      "name": "Validate API Key"
    },
    {
      "parameters": {
        "jsCode": "const body = $input.item.json;\n\n// Check event type\nif (body.eventtype !== 'tweet' && body.event_type !== 'tweet') {\n  console.log('\u26a0\ufe0f Non-tweet event, skipping');\n  return [];\n}\n\nconst tweets = body.tweets || [];\nconst ruleTag = body.ruletag || body.rule_tag || 'unknown';\n\nconsole.log(`\ud83d\udce5 Processing ${tweets.length} tweets from rule \"${ruleTag}\"`);\n\nif (tweets.length === 0) {\n  console.log('No tweets in webhook payload');\n  return [];\n}\n\n// Normalize each tweet to match your existing workflow format\nreturn tweets.map(tweet => ({\n  json: {\n    // Keep all original TwitterAPI.io fields\n    ...tweet,\n    \n    // Normalize date field (ensure consistency)\n    createdAt: tweet.created_at || tweet.createdAt,\n    \n    // Normalize reply fields\n    isReply: tweet.is_reply || tweet.isReply || !!tweet.inReplyToId || !!tweet.in_reply_to_status_id,\n    inReplyToId: tweet.inReplyToId || tweet.in_reply_to_status_id || null,\n    inReplyToUsername: tweet.inReplyToUsername || tweet.in_reply_to_screen_name || null,\n    \n    // Add webhook metadata\n    webhook_source: ruleTag,\n    webhook_timestamp: body.timestamp\n  }\n}));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -528,
        144
      ],
      "id": "7aa1de1b-4031-405b-b38c-058da2194a30",
      "name": "Normalize Webhook Tweets"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\nconst apiKey: \"YOUR_API_KEY\";\n\nconsole.log(`Processing ${tweets.length} tweets for reply enrichment`);\n\n// Collect all reply IDs first\nconst replyIds = [];\nconst tweetMap = {};\n\ntweets.forEach(item => {\n  const tweet = item.json;\n  tweetMap[tweet.id] = tweet;\n  \n  if (tweet.isReply && tweet.inReplyToId) {\n    replyIds.push(tweet.inReplyToId);\n  }\n});\n\nconsole.log(`Found ${replyIds.length} replies to fetch`);\n\n// BATCH FETCH: Get up to 100 tweets per API call\nconst BATCH_SIZE = 100;\nconst originalTweets = {};\n\nfor (let i = 0; i < replyIds.length; i += BATCH_SIZE) {\n  const batch = replyIds.slice(i, i + BATCH_SIZE);\n  const batchIds = batch.join(',');\n  \n  console.log(`Fetching batch ${Math.floor(i/BATCH_SIZE) + 1}: ${batch.length} tweets`);\n  \n  try {\n    const response = await this.helpers.httpRequest({\n      method: 'GET',\n      url: `https://api.twitterapi.io/twitter/tweets?tweet_ids=${batchIds}`,\n      headers: {\n        'X-API-Key': apiKey,\n        'Accept': 'application/json'\n      },\n      json: true\n    });\n    \n    // Store fetched tweets by ID\n    if (response.tweets) {\n      response.tweets.forEach(t => {\n        originalTweets[t.id] = t;\n      });\n    }\n    \n    console.log(`\u2705 Fetched ${response.tweets?.length || 0} original tweets`);\n    \n    // Rate limit: wait 1 second between batches\n    if (i + BATCH_SIZE < replyIds.length) {\n      await new Promise(resolve => setTimeout(resolve, 1000));\n    }\n    \n  } catch (error) {\n    console.log(`\u26a0\ufe0f Batch fetch failed: ${error.message}`);\n  }\n}\n\n// Now enrich all tweets\nconst enrichedTweets = tweets.map(item => {\n  const tweet = item.json;\n  \n  if (tweet.isReply && tweet.inReplyToId) {\n    const originalTweet = originalTweets[tweet.inReplyToId];\n    \n    if (originalTweet) {\n      return {\n        json: {\n          ...tweet,\n          original_tweet_id: originalTweet.id,\n          original_author_username: originalTweet.author?.userName || '',\n          original_tweet_url: originalTweet.url || originalTweet.twitterUrl,\n          original_full_text: originalTweet.text || ''\n        }\n      };\n    } else {\n      return {\n        json: {\n          ...tweet,\n          original_tweet_id: tweet.inReplyToId,\n          original_author_username: tweet.inReplyToUsername,\n          original_tweet_url: `https://twitter.com/${tweet.inReplyToUsername}/status/${tweet.inReplyToId}`,\n          original_full_text: '(Original tweet not found)'\n        }\n      };\n    }\n  }\n  \n  // Not a reply\n  return {\n    json: {\n      ...tweet,\n      original_tweet_id: '',\n      original_author_username: '',\n      original_tweet_url: '',\n      original_full_text: ''\n    }\n  };\n});\n\nconsole.log(`\u2705 Enriched ${enrichedTweets.length} tweets`);\nreturn enrichedTweets;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -320,
        144
      ],
      "id": "18365328-601f-410e-aaa2-5f9c797e7435",
      "name": "Fetch Original Tweets for Replies"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\n\nconsole.log(`Formatting ${tweets.length} tweets for Google Sheets`);\n\nreturn tweets.map(item => {\n  const tweet = item.json;\n  \n  // Extract media URLs\n  const mediaUrls = [];\n  if (tweet.extendedEntities?.media) {\n    tweet.extendedEntities.media.forEach(m => {\n      if (m.mediaurlhttps || m.media_url_https) {\n        mediaUrls.push(m.mediaurlhttps || m.media_url_https);\n      }\n    });\n  }\n  \n  // Safe extraction\n  const authorUsername = tweet.author?.userName || tweet.userName || 'unknown';\n  \n  // Construct Drive folder URL (if media exists)\n  const driveFolderUrl = mediaUrls.length > 0 \n    ? 'https://drive.google.com/drive/folders/YOUR_FOLDER_ID' \n    : '';\n  \n  return {\n    json: {\n      tweet_id: tweet.id,\n      created_at: tweet.createdAt,\n      author_username: authorUsername,\n      tweet_url: tweet.url || tweet.twitterUrl,\n      full_text: tweet.text,\n      is_reply: tweet.isReply ? 'TRUE' : 'FALSE',\n      reply_to_tweet_id: tweet.inReplyToId || '',\n      reply_to_username: tweet.inReplyToUsername || '',\n      original_tweet_id: tweet.original_tweet_id || '',\n      original_author_username: tweet.original_author_username || '',\n      original_tweet_url: tweet.original_tweet_url || '',\n      original_full_text: tweet.original_full_text || '',\n      // NEW: Quote tweet fields\n      is_quote: tweet.quoted_tweet_id ? 'TRUE' : 'FALSE',\n      quoted_tweet_id: tweet.quoted_tweet_id || '',\n      quoted_author_username: tweet.quoted_author_username || '',\n      quoted_tweet_url: tweet.quoted_tweet_url || '',\n      quoted_full_text: tweet.quoted_full_text || '',\n      media_urls: mediaUrls.join(', '),\n      drive_folder_url: driveFolderUrl\n    }\n  };\n});"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        144,
        144
      ],
      "id": "bda3fe19-7e0c-46c1-ac69-74b5dc3cac34",
      "name": "Format Data for Sheets"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "06a27676-e542-4a6b-adaf-ce1fb07a9796",
              "leftValue": "={{ $json.media_urls }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        352,
        144
      ],
      "id": "6d9de146-9ab1-4bc9-84b7-b2028f8cb6be",
      "name": "Check if Media Exists"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\n\nconsole.log(`Processing ${tweets.length} tweets with media`);\n\n// Process each tweet\nconst allMediaItems = [];\n\ntweets.forEach(item => {\n  const tweetData = item.json;\n  const mediaUrls = tweetData.media_urls;\n\n  // If no media, skip\n  if (!mediaUrls || mediaUrls.trim() === '') {\n    console.log(`\u26a0\ufe0f No media URLs for tweet ${tweetData.tweet_id}`);\n    return;\n  }\n\n  // Split comma-separated URLs into array\n  const urlArray = mediaUrls.split(',').map(url => url.trim()).filter(url => url);\n\n  console.log(`\ud83d\udcf8 Found ${urlArray.length} media files for tweet ${tweetData.tweet_id}`);\n\n  // Create one item per media URL\n  urlArray.forEach((url, index) => {\n    // Determine file extension from URL\n    let extension = '.jpg';\n    if (url.includes('.mp4')) extension = '.mp4';\n    if (url.includes('.png')) extension = '.png';\n    if (url.includes('.gif')) extension = '.gif';\n\n    allMediaItems.push({\n      json: {\n        ...tweetData,\n        media_url: url,\n        media_index: index + 1,\n        filename: `${tweetData.author_username}_${tweetData.tweet_id}_${index + 1}${extension}`\n      }\n    });\n  });\n});\n\nconsole.log(`\u2705 Total media items to download: ${allMediaItems.length}`);\n\nreturn allMediaItems;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        48
      ],
      "id": "b0c996d2-7f0a-496c-8d85-4ff126875932",
      "name": "Split Media URLs into Array"
    },
    {
      "parameters": {
        "jsCode": "// Get data from both nodes\nconst uploadedFiles = $input.all(); // From Google Drive Upload\nconst downloadedFiles = $('Download Media File').all(); // From Download Media File node\n\nconsole.log(`Processing ${uploadedFiles.length} uploaded files`);\n\n// Create a map of index -> tweet data from Download Media File\nconst tweetDataMap = {};\ndownloadedFiles.forEach((item, index) => {\n  tweetDataMap[index] = item.json;\n});\n\n// Group by tweet_id\nconst grouped = {};\n\nuploadedFiles.forEach((item, index) => {\n  const driveFileId = item.json.id; // Google Drive file ID\n  const driveUrl = `https://drive.google.com/file/d/${driveFileId}/view`;\n  \n  // Get the original tweet data from Download Media File node\n  const tweetData = tweetDataMap[index];\n  \n  if (!tweetData) {\n    console.log(`\u26a0\ufe0f No tweet data found for index ${index}`);\n    return;\n  }\n  \n  const tweetId = tweetData.tweet_id;\n  \n  if (!grouped[tweetId]) {\n    grouped[tweetId] = {\n      tweet_id: tweetData.tweet_id,\n      created_at: tweetData.created_at,\n      author_username: tweetData.author_username,\n      tweet_url: tweetData.tweet_url,\n      full_text: tweetData.full_text,\n      is_reply: tweetData.is_reply,\n      reply_to_tweet_id: tweetData.reply_to_tweet_id || '',\n      reply_to_username: tweetData.reply_to_username || '',\n      original_tweet_id: tweetData.original_tweet_id || '',\n      original_author_username: tweetData.original_author_username || '',\n      original_tweet_url: tweetData.original_tweet_url || '',\n      original_full_text: tweetData.original_full_text || '',\n      // NEW: Quote tweet fields\n      is_quote: tweetData.is_quote || 'FALSE',\n      quoted_tweet_id: tweetData.quoted_tweet_id || '',\n      quoted_author_username: tweetData.quoted_author_username || '',\n      quoted_tweet_url: tweetData.quoted_tweet_url || '',\n      quoted_full_text: tweetData.quoted_full_text || '',\n      media_urls: tweetData.media_urls,\n      drive_file_paths: []\n    };\n  }\n  \n  grouped[tweetId].drive_file_paths.push(driveUrl);\n});\n\n// Return one item per tweet with all drive paths joined\nconst result = Object.values(grouped).map(tweet => ({\n  json: {\n    ...tweet,\n    drive_file_paths: tweet.drive_file_paths.join(', ')\n  }\n}));\n\nconsole.log(`\u2705 Grouped into ${result.length} tweets with Drive paths`);\nconsole.log(`Sample:`, JSON.stringify(result[0], null, 2));\n\nreturn result;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1232,
        48
      ],
      "id": "579a3ef5-3b8d-4379-baae-7aaa34082c5a",
      "name": "Collect Drive Paths per Tweet"
    },
    {
      "parameters": {
        "name": "={{ $json.filename }}",
        "driveId": {
          "__rl": true,
          "value": "My Drive",
          "mode": "list",
          "cachedResultName": "My Drive",
          "cachedResultUrl": "https://drive.google.com/drive/my-drive"
        },
        "folderId": {
          "__rl": true,
          "value": "1dMkz2p41j9PclOmZNW85zSXpGx7rJo51",
          "mode": "list",
          "cachedResultName": "Twitter Archive",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1dMkz2p41j9PclOmZNW85zSXpGx7rJo51"
        },
        "options": {
          "simplifyOutput": false
        }
      },
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        1008,
        48
      ],
      "id": "faebc305-6941-4f1f-8438-eeea71ad63ac",
      "name": "Upload to Google Drive",
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\n\nconst results = [];\n\nfor (const item of items) {\n  const url = item.json.media_url;\n  \n  if (!url) {\n    console.log('\u26a0\ufe0f No media_url found, skipping item');\n    continue;\n  }\n  \n  try {\n    console.log(`Downloading: ${url}`);\n    \n    // Download the file\n    const response = await this.helpers.httpRequest({\n      method: 'GET',\n      url: url,\n      returnFullResponse: true,\n      encoding: 'arraybuffer'\n    });\n    \n    // Determine mime type from response or URL\n    let mimeType = response.headers['content-type'] || 'image/jpeg';\n    \n    // response.body is already a Buffer when encoding is 'arraybuffer'\n    const binaryData = Buffer.isBuffer(response.body) \n      ? response.body \n      : Buffer.from(response.body);\n    \n    results.push({\n      json: {\n        // Keep ALL original tweet data (with underscores)\n        ...item.json\n      },\n      binary: {\n        data: {\n          data: binaryData.toString('base64'),\n          mimeType: mimeType,\n          fileName: item.json.filename,\n          fileExtension: item.json.filename.split('.').pop()\n        }\n      }\n    });\n    \n    console.log(`\u2705 Downloaded: ${item.json.filename} (${mimeType})`);\n  } catch (error) {\n    console.log(`\u274c Failed to download ${url}: ${error.message}`);\n    // Still add the item so workflow can continue\n    results.push({\n      json: {\n        ...item.json,\n        download_error: error.message\n      }\n    });\n  }\n}\n\nconsole.log(`Total files processed: ${results.length}`);\n\nreturn results;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        784,
        48
      ],
      "id": "94de1787-cce7-4a26-ba76-73e976d107c1",
      "name": "Download Media File"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1472,
        144
      ],
      "id": "69a3da90-c35a-45bb-9330-a88a0f4821ab",
      "name": "Merge Tweets (With & Without Media)"
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "1wCO_3eg9FPRHsEbFkuO786Ux-XvP7jew6ZCssO-U74A",
          "mode": "list",
          "cachedResultName": "Twitter Monitor - Data Archive",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_GOOGLE_SHEET_ID/edit"
        },
        "sheetName": {
          "__rl": true,
          "value": "gid=0",
          "mode": "list",
          "cachedResultName": "Tweets",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_GOOGLE_SHEET_ID/edit"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "tweet_id": "={{ $json.tweet_id }}",
            "created_at": "={{ $json.created_at }}",
            "author_username": "={{ $json.author_username }}",
            "tweet_url": "={{ $json.tweet_url }}",
            "full_text": "={{ $json.full_text }}",
            "is_reply": "={{ $json.is_reply }}",
            "reply_to_tweet_id": "={{ $json.reply_to_tweet_id }}",
            "reply_to_username": "={{ $json.reply_to_username }}",
            "original_tweet_id": "={{ $json.original_tweet_id }}",
            "original_author_username": "={{ $json.original_author_username }}",
            "original_tweet_url": "={{ $json.original_tweet_url }}",
            "original_full_text": "={{ $json.original_full_text }}",
            "media_urls": "={{ $json.media_urls }}",
            "drive_file_paths": "={{ $json.drive_file_paths }}",
            "quoted_tweet_id": "={{ $json.quoted_tweet_id }}",
            "quoted_author_username": "={{ $json.quoted_author_username }}",
            "quoted_tweet_url": "={{ $json.quoted_tweet_url }}",
            "quoted_full_text": "={{ $json.quoted_full_text }}"
          },
          "matchingColumns": [
            "tweet_id"
          ],
          "schema": [
            {
              "id": "tweet_id",
              "displayName": "tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "created_at",
              "displayName": "created_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "author_username",
              "displayName": "author_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "tweet_url",
              "displayName": "tweet_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "full_text",
              "displayName": "full_text",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "is_reply",
              "displayName": "is_reply",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reply_to_tweet_id",
              "displayName": "reply_to_tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reply_to_username",
              "displayName": "reply_to_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_tweet_id",
              "displayName": "original_tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_author_username",
              "displayName": "original_author_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_tweet_url",
              "displayName": "original_tweet_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "original_full_text",
              "displayName": "original_full_text",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "quoted_tweet_id",
              "displayName": "quoted_tweet_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "quoted_author_username",
              "displayName": "quoted_author_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "quoted_tweet_url",
              "displayName": "quoted_tweet_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "quoted_full_text",
              "displayName": "quoted_full_text",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "media_urls",
              "displayName": "media_urls",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "drive_file_paths",
              "displayName": "drive_file_paths",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1680,
        144
      ],
      "id": "59d44f2e-710b-48b5-ba7a-30de48e0e25f",
      "name": "Write to Google Sheets",
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "content": "## WEBHOOK INTAKE & VALIDATION\nReceives tweets from TwitterAPI.io webhook\n- Validates API key for security\n- Normalizes tweet data format\n",
        "height": 416,
        "width": 608,
        "color": 4
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1008,
        -64
      ],
      "id": "50406a24-5ae8-4608-87a3-560cd81b95ac",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## REPLY ENRICHMENT\nFetches original tweets for replies\n- Batch fetches up to 100 tweets at a time\n- Adds context to reply tweets",
        "height": 416,
        "width": 224,
        "color": 2
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -384,
        -64
      ],
      "id": "bb5d6264-ba22-45bd-b385-8c2a09e1db1e",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## DATA FORMATTING\nFormats tweet data for Google Sheets\n- Extracts media URLs\n- Structures all 14 fields\n",
        "height": 416,
        "width": 192,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        96,
        -80
      ],
      "id": "e5207a43-b63a-4212-8732-711108f1accc",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## MERGE & WRITE\nFinal output stage\n- Merges tweets with/without media\n- Writes all data to Google Sheets\n- Updates existing tweets (no duplicates)",
        "height": 384,
        "width": 416,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1456,
        -64
      ],
      "id": "e1d6f610-9f5c-412f-81ae-db2ef94feac8",
      "name": "Sticky Note5"
    },
    {
      "parameters": {
        "content": "## MEDIA PROCESSING PIPELINE\nHandles tweets with media attachments\n- Downloads images/videos from Twitter\n- Uploads to Google Drive folder\n- Collects Drive links per tweet\n",
        "height": 400,
        "width": 1088,
        "color": 6
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        304,
        -112
      ],
      "id": "e8837c6f-42c9-4134-9cd9-f99c40f79195",
      "name": "Sticky Note6"
    },
    {
      "parameters": {
        "jsCode": "const tweets = $input.all();\n\nconsole.log(`Processing ${tweets.length} tweets for quote enrichment`);\n\n// Enrich all tweets - check if quoted_tweet data already exists\nconst enrichedTweets = tweets.map(item => {\n  const tweet = item.json;\n  \n  // Check if this tweet has a quoted tweet\n  if (tweet.quoted_tweet && tweet.quoted_tweet.id) {\n    const quotedTweet = tweet.quoted_tweet;\n    \n    return {\n      json: {\n        ...tweet,\n        quoted_tweet_id: quotedTweet.id,\n        quoted_author_username: quotedTweet.author?.userName || '',\n        quoted_tweet_url: quotedTweet.url || quotedTweet.twitterUrl || `https://x.com/${quotedTweet.author?.userName}/status/${quotedTweet.id}`,\n        quoted_full_text: quotedTweet.text || ''\n      }\n    };\n  }\n  \n  // Not a quote tweet\n  return {\n    json: {\n      ...tweet,\n      quoted_tweet_id: '',\n      quoted_author_username: '',\n      quoted_tweet_url: '',\n      quoted_full_text: ''\n    }\n  };\n});\n\nconsole.log(`\u2705 Enriched ${enrichedTweets.length} tweets with quote data`);\nreturn enrichedTweets;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        144
      ],
      "id": "b7c89670-5bff-4d6c-988b-3d8bda88500a",
      "name": "Fetch Quoted Tweets"
    },
    {
      "parameters": {
        "content": "## QUOTE TWEET ENRICHMENT\n- Adds: quoted tweet ID, author, URL, full text",
        "height": 416,
        "width": 192
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -144,
        -64
      ],
      "id": "bf416917-7da5-4c94-917d-2c6c0d90544b",
      "name": "Sticky Note3"
    }
  ],
  "connections": {
    "Twitter Stream Webhook": {
      "main": [
        [
          {
            "node": "Validate API Key",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate API Key": {
      "main": [
        [
          {
            "node": "Normalize Webhook Tweets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Webhook Tweets": {
      "main": [
        [
          {
            "node": "Fetch Original Tweets for Replies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Original Tweets for Replies": {
      "main": [
        [
          {
            "node": "Fetch Quoted Tweets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Data for Sheets": {
      "main": [
        [
          {
            "node": "Check if Media Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check if Media Exists": {
      "main": [
        [
          {
            "node": "Split Media URLs into Array",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge Tweets (With & Without Media)",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Split Media URLs into Array": {
      "main": [
        [
          {
            "node": "Download Media File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Google Drive": {
      "main": [
        [
          {
            "node": "Collect Drive Paths per Tweet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Media File": {
      "main": [
        [
          {
            "node": "Upload to Google Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Drive Paths per Tweet": {
      "main": [
        [
          {
            "node": "Merge Tweets (With & Without Media)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Tweets (With & Without Media)": {
      "main": [
        [
          {
            "node": "Write to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Quoted Tweets": {
      "main": [
        [
          {
            "node": "Format Data for Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  },
  "versionId": "a3745d91-7128-4d49-a037-8e67a3317bb6",
  "id": "M7dtj8yS3-IyGv4L0QWnH",
  "tags": []
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Twitter Stream Monitor. Uses googleDrive, googleSheets. Webhook trigger; 19 nodes.

Source: https://github.com/tabii-dev/n8n-Portfolio/blob/main/twitter-stream-monitor/workflow.json — original creator credit. Request a take-down →

More Social Media workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Social Media

More workflow: https://aitool.wiki/

Google Sheets, Google Drive, Read Write File +3
Social Media

Automate LinkedIn organization page posting with precise time scheduling and Google Drive media management. Runs hourly during business hours, processes approved posts scheduled for today, waits until

Google Sheets, Google Drive, LinkedIn
Social Media

&gt; Set up n8n self-hosted instance using https://tino.vn/vps-n8n?affid=388 &gt; Use the code ==VPSN8N== for up to 39% off.

Google Drive, HTTP Request, Google Sheets
Social Media

&gt; Recommended: Self-hosted via tino.vn/vps-n8n?affid=388 — use code VPSN8N for up to 39% off.

Google Drive, Google Sheets, HTTP Request
Social Media

This n8n workflow automatically converts LinkedIn video URLs into downloadable MP4 files using the LinkedIn Video Downloader API, uploads them to Google Drive with public access, and logs both the ori

Form Trigger, HTTP Request, Google Drive +1