{
  "name": "Shotstack Pending Row Video Automation Pipeline",
  "nodes": [
    {
      "parameters": {
        "content": "## \u2699\ufe0f Setup Required \u2014 Read Before Activating\n\n**How this workflow works** \u2014 This version does not use the Google Sheets Row Added trigger. It runs on a schedule, reads the sheet, selects the first row whose `status` cell is blank, marks it as `processing`, renders the video, uploads it to Drive, then updates the same row.\n\n**1. Sheet columns** \u2014 Your Google Sheet must have these exact header columns:\n- `video_id`\n- `title`\n- `image_url`\n- `script`\n- `status`\n- `video_url`\n\n**2. Pending rows** \u2014 A row is considered pending when `status` is blank. The workflow processes one pending row per scheduled run. To retry a row, clear its `status` cell. Keep `video_id` unique for every row.\n\n**3. Google Sheet URL** \u2014 Replace `YOUR_GOOGLE_SHEET_URL` in:\n- `Get Rows from Sheet`\n- `Mark Row Processing`\n- `Update Sheet (Success)`\n- `Update Sheet (Failed)`\n\n**4. Sheet tab** \u2014 Replace `YOUR_SHEET_NAME` with the visible tab name at the bottom of your Google Sheet, for example `Sheet1`.\n\n**5. Matching column** \u2014 All Google Sheets update nodes use `video_id` as the matching column for **Append or Update Row**. Your sheet header must be exactly `video_id`. If n8n refreshes the sheet schema after you select your own sheet, confirm that **Column to Match On / Matching Columns** is still set to `video_id`.\n\n**6. Shotstack Template ID** \u2014 In `Render Video`, replace `YOUR_SHOTSTACK_TEMPLATE_ID` in the body expression with your real Shotstack template ID.\n\n**7. Google Drive folder** \u2014 In `Upload to Google Drive`, replace `YOUR_GOOGLE_DRIVE_FOLDER_ID` or select the Drive folder where finished videos should be saved. Replace `YOUR_GOOGLE_DRIVE_FOLDER_NAME` with the folder's display name if editing the JSON directly.\n\n**8. Credentials** \u2014 Configure credentials in each node:\n- **Google Sheets OAuth2** \u2192 `Get Rows from Sheet`, `Mark Row Processing`, `Update Sheet (Success)`, and `Update Sheet (Failed)`\n- **Google Drive OAuth2** \u2192 `Upload to Google Drive`\n- **Header Auth (Shotstack)** \u2192 `Render Video`\n  - Name: `x-api-key`\n  - Value: your Shotstack Stage API key\n\n**9. Webhook callback** \u2014 `Wait for Webhook` is configured to resume on a **POST** request and respond immediately with HTTP `200`. Do not edit the callback URL manually; n8n generates it at runtime using `$execution.resumeUrl`, and the `Render Video` node sends it to Shotstack as the `CALLBACK_URL` merge field.\n\n**10. Activate the workflow** \u2014 Manual runs process one pending row immediately. When active, the Schedule Trigger checks the sheet every minute. Adjust the interval if needed.\n\n**11. Go live** \u2014 When ready for production, in `Render Video`, change `/edit/stage/` to `/edit/v1/` and swap in your Production API key.\n",
        "height": 900,
        "width": 620,
        "color": 3
      },
      "id": "766f9681-d2f6-420e-923b-66258e189ad4",
      "name": "Setup Notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1260,
        360
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 1
            }
          ]
        }
      },
      "id": "3ffd353c-7ebf-4ac8-9a17-9b56eacebdc1",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1260,
        80
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_SHEET_URL",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "YOUR_SHEET_NAME",
          "mode": "name",
          "cachedResultName": "YOUR_SHEET_NAME"
        },
        "options": {
          "dataLocationOnSheet": {
            "values": {
              "rangeDefinition": "detectAutomatically"
            }
          }
        }
      },
      "id": "30be788c-a7fe-4c5a-b1bd-a9fa36c9b442",
      "name": "Get Rows from Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        -1040,
        80
      ]
    },
    {
      "parameters": {
        "jsCode": "const rows = $input.all();\n\nconst pending = rows.find((item) => {\n  const status = String(item.json.status ?? '').trim().toLowerCase();\n  return status === '';\n});\n\nif (!pending) {\n  // Nothing to process on this run.\n  return [];\n}\n\nconst row = pending.json;\nconst required = ['video_id', 'title', 'image_url', 'script'];\nfor (const field of required) {\n  if (!String(row[field] ?? '').trim()) {\n    throw new Error(`Pending row is missing required field: ${field}`);\n  }\n}\n\nreturn [\n  {\n    json: {\n      video_id: String(row.video_id).trim(),\n      title: String(row.title).trim(),\n      image_url: String(row.image_url).trim(),\n      script: String(row.script).trim(),\n      status: String(row.status ?? '').trim(),\n      video_url: String(row.video_url ?? '').trim(),\n    },\n  },\n];\n"
      },
      "id": "5b43c17b-8507-40e0-b97c-9bd6586a37d9",
      "name": "Select Next Pending Row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -820,
        80
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_SHEET_URL",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "YOUR_SHEET_NAME",
          "mode": "name",
          "cachedResultName": "YOUR_SHEET_NAME"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "video_id": "={{ $json.video_id }}",
            "status": "processing",
            "video_url": "={{ $json.video_url || '' }}"
          },
          "matchingColumns": [
            "video_id"
          ],
          "schema": [
            {
              "id": "video_id",
              "displayName": "video_id",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "video_url",
              "displayName": "video_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "52a039aa-c829-4175-b217-e0f507256adb",
      "name": "Mark Row Processing",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        -600,
        80
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.shotstack.io/edit/stage/templates/render",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify({'id':'YOUR_SHOTSTACK_TEMPLATE_ID','merge':[{'find':'TITLE','replace':$node['Select Next Pending Row'].json.title},{'find':'IMAGE','replace':$node['Select Next Pending Row'].json.image_url},{'find':'VOICEOVER','replace':$node['Select Next Pending Row'].json.script},{'find':'CALLBACK_URL','replace':$execution.resumeUrl + ($execution.resumeUrl.includes('?') ? '&' : '?') + 'video_id=' + encodeURIComponent($node['Select Next Pending Row'].json.video_id)}]}) }}",
        "options": {}
      },
      "id": "e2fd750d-5d58-4bf3-a1d4-2f470d1aa5e1",
      "name": "Render Video",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -380,
        80
      ]
    },
    {
      "parameters": {
        "resume": "webhook",
        "httpMethod": "POST",
        "responseMode": "onReceived",
        "responseCode": 200,
        "options": {}
      },
      "id": "49b34558-26c5-432c-af5c-e15fd0dd41d4",
      "name": "Wait for Webhook",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        -160,
        80
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "013d4104-77fd-40aa-bd6e-6dc577566d9a",
              "name": "video_id",
              "value": "={{ $json.query?.video_id || $json.body?.query?.video_id || $node['Select Next Pending Row'].json.video_id }}",
              "type": "string"
            },
            {
              "id": "79bce67e-e35b-45da-a795-d49752b77091",
              "name": "status",
              "value": "={{ $json.status || $json.body?.status }}",
              "type": "string"
            },
            {
              "id": "1fc3fc50-3409-4adb-9e84-85fb62d2d873",
              "name": "url",
              "value": "={{ $json.url || $json.body?.url }}",
              "type": "string"
            },
            {
              "id": "4a240545-8451-44bf-8ddb-3d9b16aef937",
              "name": "error",
              "value": "={{ $json.error || $json.body?.error }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "1cc2ff20-3538-45fb-bc08-c199efa5ff40",
      "name": "Normalize Callback",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        60,
        80
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "111f0797-1429-4973-99e1-86fe681b6e82",
              "leftValue": "={{ $json.status }}",
              "rightValue": "done",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1319385b-5135-4f11-b5b4-3c03be77efe1",
      "name": "Check Render Status",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        280,
        80
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "id": "d02ab86e-6f2e-40ec-bda2-4b064a0b6d45",
      "name": "Download Video",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        500,
        -40
      ]
    },
    {
      "parameters": {
        "name": "={{ $('Normalize Callback').item.json.video_id + '.mp4' }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_DRIVE_FOLDER_ID",
          "mode": "id",
          "cachedResultName": "YOUR_GOOGLE_DRIVE_FOLDER_NAME"
        },
        "options": {}
      },
      "id": "ad378b8a-c334-467d-904d-a8521ebf54fc",
      "name": "Upload to Google Drive",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        720,
        -40
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_SHEET_URL",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "YOUR_SHEET_NAME",
          "mode": "name",
          "cachedResultName": "YOUR_SHEET_NAME"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "video_id": "={{ $('Normalize Callback').item.json.video_id }}",
            "status": "done",
            "video_url": "={{ $json.webViewLink || $json.webContentLink || ('https://drive.google.com/file/d/' + $json.id + '/view') }}"
          },
          "matchingColumns": [
            "video_id"
          ],
          "schema": [
            {
              "id": "video_id",
              "displayName": "video_id",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "video_url",
              "displayName": "video_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "ba934ffd-b341-42fe-b03b-391ccf3a4732",
      "name": "Update Sheet (Success)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        940,
        -40
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_SHEET_URL",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "YOUR_SHEET_NAME",
          "mode": "name",
          "cachedResultName": "YOUR_SHEET_NAME"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "video_id": "={{ $json.video_id }}",
            "status": "failed"
          },
          "matchingColumns": [
            "video_id"
          ],
          "schema": [
            {
              "id": "video_id",
              "displayName": "video_id",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "bd8b49e0-d812-4bcb-bde8-17c428e1983b",
      "name": "Update Sheet (Failed)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        500,
        220
      ]
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get Rows from Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Rows from Sheet": {
      "main": [
        [
          {
            "node": "Select Next Pending Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Next Pending Row": {
      "main": [
        [
          {
            "node": "Mark Row Processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Row Processing": {
      "main": [
        [
          {
            "node": "Render Video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Video": {
      "main": [
        [
          {
            "node": "Wait for Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Webhook": {
      "main": [
        [
          {
            "node": "Normalize Callback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Callback": {
      "main": [
        [
          {
            "node": "Check Render Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Render Status": {
      "main": [
        [
          {
            "node": "Download Video",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Update Sheet (Failed)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Video": {
      "main": [
        [
          {
            "node": "Upload to Google Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Google Drive": {
      "main": [
        [
          {
            "node": "Update Sheet (Success)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "nodeGroups": [],
  "tags": []
}