{
  "id": "uJVtmxWf6UCJLXQF",
  "name": "Auto-upload Zoom cloud recordings to YouTube and Google Drive",
  "tags": [],
  "nodes": [
    {
      "id": "9b5cbf60-bc19-43fc-8edc-1aedde80c3df",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "width": 500,
        "height": 1204,
        "content": "## Auto-upload Zoom cloud recordings to YouTube and Google Drive\n\n### Who's it for\nCoaches, educators, and teams who record sessions on Zoom and want every recording archived in Google Drive and published to YouTube (unlisted) without manual downloads.\n\n### How it works\n1. Zoom calls this workflow's webhook when a cloud recording completes.\n2. The workflow answers Zoom's URL-validation challenge automatically (HMAC-SHA256) \u2014 the step where most webhook setups fail.\n3. MP4 files are extracted from the event payload and downloaded using the short-lived download token included in the event.\n4. Each file is uploaded to Google Drive and YouTube in parallel.\n\n### How to set up\n1. Open the **Setup** node and paste your Zoom app's Secret Token.\n2. In the Zoom Marketplace, create a *Webhook Only* (or Server-to-Server OAuth) app, subscribe to the **recording.completed** event, and paste this workflow's production webhook URL.\n3. Activate the workflow, then press **Validate** in Zoom.\n4. Select your Google Drive destination folder and connect your YouTube OAuth2 credentials.\n\n### Requirements\n- Zoom paid plan (cloud recording)\n- YouTube Data API v3 \u2014 uploads cost 1,600 quota units (\u22486 videos/day on the default quota)\n\n### How to customize\nChange the YouTube privacy status (unlisted \u2192 private/public), adjust the file-naming pattern in the *Extract MP4 files* node, or add a Slack/email notification after upload."
      },
      "typeVersion": 1
    },
    {
      "id": "ab4ca747-0d19-4388-8e60-c0f2c4cb4078",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 682,
        "height": 362,
        "content": "### 1. Receive Zoom events\nPaste your Zoom app's **Secret Token** into the Setup node."
      },
      "typeVersion": 1
    },
    {
      "id": "34e7f355-61b9-44f8-89b9-aaa61fc42477",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        16
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 330,
        "content": "### 2. URL validation\nAnswers Zoom's challenge automatically. Click **Validate** in Zoom after activating this workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "9d03d2ae-ee43-4eb9-9784-8fb10ac804ac",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        384
      ],
      "parameters": {
        "color": 7,
        "width": 1200,
        "height": 506,
        "content": "### 3. Download & upload\nExtract completed MP4s, download with the event's token, then upload to Google Drive and YouTube (unlisted) in parallel."
      },
      "typeVersion": 1
    },
    {
      "id": "f02505ab-8aeb-463c-9299-efec74ac817b",
      "name": "Receive Zoom event",
      "type": "n8n-nodes-base.webhook",
      "position": [
        624,
        384
      ],
      "parameters": {
        "path": "zoom-recording",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "8ef8867f-5bde-4545-9883-0effecd04f20",
      "name": "Setup: Edit this node",
      "type": "n8n-nodes-base.set",
      "position": [
        848,
        384
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "secret-token",
              "name": "zoomSecretToken",
              "type": "string",
              "value": "YOUR_ZOOM_SECRET_TOKEN"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "e327b5b9-9dc9-4888-9b06-dbe7edb3805a",
      "name": "Is URL validation?",
      "type": "n8n-nodes-base.if",
      "position": [
        1072,
        384
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.body.event }}",
              "rightValue": "endpoint.url_validation"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "b2b49bce-f8c1-4ede-ac86-123ef6594a7c",
      "name": "Generate HMAC response",
      "type": "n8n-nodes-base.code",
      "position": [
        1328,
        176
      ],
      "parameters": {
        "jsCode": "// Answer Zoom's URL-validation challenge:\n// HMAC-SHA256 of plainToken, keyed with your app's Secret Token.\nconst crypto = require('crypto');\n\nconst secretToken = $json.zoomSecretToken;\nconst plainToken = $json.body.payload.plainToken;\n\nconst encryptedToken = crypto\n  .createHmac('sha256', secretToken)\n  .update(plainToken)\n  .digest('hex');\n\nreturn [{ json: { plainToken, encryptedToken } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "df0e0ede-0b84-46fd-94bf-62b524bd55e6",
      "name": "Respond to validation",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1552,
        176
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ { plainToken: $json.plainToken, encryptedToken: $json.encryptedToken } }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "be903419-a52c-4fe9-84ad-eb817862f6f9",
      "name": "Respond 200 OK",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1328,
        592
      ],
      "parameters": {
        "options": {},
        "respondWith": "text",
        "responseBody": "OK"
      },
      "typeVersion": 1.1
    },
    {
      "id": "e02a6e21-209c-4113-a413-f7e636fe16d5",
      "name": "Extract MP4 files",
      "type": "n8n-nodes-base.code",
      "position": [
        1552,
        592
      ],
      "parameters": {
        "jsCode": "// Extract completed MP4 files from the recording.completed payload.\nconst body = $input.first().json.body;\nconst obj = body.payload.object;\nconst downloadToken = body.download_token;\n\nconst topic = obj.topic || 'Zoom recording';\nconst startTime = obj.start_time || '';\n// e.g. 2026-07-18_1400 (UTC)\nconst dt = startTime ? startTime.replace(/[:]/g, '').replace('T', '_').slice(0, 15) : 'unknown';\n\nconst files = (obj.recording_files || [])\n  .filter(f => f.file_type === 'MP4' && f.status === 'completed')\n  .map(f => ({\n    json: {\n      fileName: `${dt}_${topic}.mp4`.replace(/[\\\\/:*?\"<>|]/g, '_'),\n      videoTitle: `${topic} (${startTime.slice(0, 10)})`,\n      downloadUrl: f.download_url,\n      downloadToken: downloadToken,\n      fileSize: f.file_size,\n      topic: topic,\n      startTime: startTime\n    }\n  }));\n\nif (files.length === 0) {\n  return [{ json: { skip: true, reason: 'No MP4 files in this event', topic } }];\n}\n\nreturn files;"
      },
      "typeVersion": 2
    },
    {
      "id": "24a4ddbd-3497-4a65-aae6-caa12d91d089",
      "name": "Has MP4?",
      "type": "n8n-nodes-base.if",
      "position": [
        1776,
        592
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.skip }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "41f5a4f3-5d27-487f-9b59-3e2d83d0de63",
      "name": "Download recording",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2000,
        592
      ],
      "parameters": {
        "url": "={{ $json.downloadUrl }}",
        "options": {
          "timeout": 300000,
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.downloadToken }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "a7624fa8-01fb-4eff-b91a-06c71aa82c9b",
      "name": "Upload to Google Drive",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        2240,
        528
      ],
      "parameters": {
        "name": "={{ $('Extract MP4 files').item.json.fileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": "root",
          "cachedResultName": "Select your destination folder"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "1b27281e-160c-478e-abb5-3e454449bb49",
      "name": "Upload to YouTube",
      "type": "n8n-nodes-base.youTube",
      "position": [
        2240,
        736
      ],
      "parameters": {
        "title": "={{ $('Extract MP4 files').item.json.videoTitle }}",
        "options": {
          "description": "=Zoom session recording\nMeeting: {{ $('Extract MP4 files').item.json.topic }}\nDate: {{ $('Extract MP4 files').item.json.startTime }}",
          "privacyStatus": "unlisted"
        },
        "resource": "video",
        "operation": "upload",
        "categoryId": "27",
        "regionCode": "US"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "e27af28b-9bf8-4c19-a329-c29f15d96379",
  "nodeGroups": [],
  "connections": {
    "Has MP4?": {
      "main": [
        [
          {
            "node": "Download recording",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond 200 OK": {
      "main": [
        [
          {
            "node": "Extract MP4 files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract MP4 files": {
      "main": [
        [
          {
            "node": "Has MP4?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download recording": {
      "main": [
        [
          {
            "node": "Upload to Google Drive",
            "type": "main",
            "index": 0
          },
          {
            "node": "Upload to YouTube",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is URL validation?": {
      "main": [
        [
          {
            "node": "Generate HMAC response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 200 OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Receive Zoom event": {
      "main": [
        [
          {
            "node": "Setup: Edit this node",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Setup: Edit this node": {
      "main": [
        [
          {
            "node": "Is URL validation?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate HMAC response": {
      "main": [
        [
          {
            "node": "Respond to validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}