> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pictory.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Text to Video n8n Workflow Integration

> Automate text-to-video creation with Pictory API using n8n workflows — render storyboard videos, poll job status, and integrate with Google Sheets for batch processing

This guide walks you through building an n8n workflow that integrates with the Pictory API to automate video creation. You'll learn how to render storyboard videos, poll for job completion with retry logic, and optionally read input from and write results back to a Google Sheets spreadsheet.

## Workflow Overview

The n8n workflow automates the full Pictory video rendering pipeline:

1. Trigger the workflow (manually or from a spreadsheet)
2. Call the Pictory Render Storyboard Video API
3. Extract the `jobId` and initialize polling variables
4. Poll the job status every 30 seconds
5. Branch based on whether the job completed, failed, or is still in progress
6. Output the video URL on success, or handle failure/timeout

<Frame caption="Pictory n8n workflow — render storyboard video with polling and retry logic">
  <img src="https://mintcdn.com/pictory/0va4NPTDPtTf1Ubt/images/n8n_workflow.png?fit=max&auto=format&n=0va4NPTDPtTf1Ubt&q=85&s=e5d8ea2d8bf62ed01f2ce719c1219fe4" alt="n8n workflow for Pictory API integration" width="2894" height="1368" data-path="images/n8n_workflow.png" />
</Frame>

## Before You Begin

Make sure you have:

* A Pictory API key ([get one here](https://app.pictory.ai/api-access))
* An [n8n](https://n8n.io/) instance (self-hosted or cloud)
* Basic familiarity with n8n workflow editor

## Import the Workflow

You can import the ready-made workflow JSON directly into n8n:

1. Open your n8n instance
2. Click **Add workflow** (or press `Ctrl+N`)
3. Click the **three-dot menu** (top-right) and select **Import from file...**
4. Upload the [n8n-workflow.json](/integrations/n8n/n8n-workflow.json) file
5. Replace `YOUR_PICTORY_API_KEY` with your actual Pictory API key in the **Pictory Render Storyboard Video** and **Get Job** nodes

<Warning>
  Never commit or share workflow files containing your actual API key. Always use placeholder values like `YOUR_PICTORY_API_KEY` in shared workflows and set the real key only in your private n8n instance.
</Warning>

## Node-by-Node Explanation

### 1. When clicking 'Execute workflow' (Manual Trigger)

| Property    | Value                          |
| ----------- | ------------------------------ |
| **Type**    | `n8n-nodes-base.manualTrigger` |
| **Purpose** | Starts the workflow on demand  |

This is the entry point of the workflow. When you click **Execute workflow** in the n8n editor, this node triggers and passes execution to the next node. In production, you can replace this with a **Schedule Trigger**, **Webhook Trigger**, or a **Google Sheets Trigger** to automate execution.

***

### 2. Pictory Render Storyboard Video (HTTP Request)

| Property               | Value                                                                 |
| ---------------------- | --------------------------------------------------------------------- |
| **Type**               | `n8n-nodes-base.httpRequest`                                          |
| **Method**             | `POST`                                                                |
| **URL**                | `https://api.pictory.ai/pictoryapis/v2/video/storyboard/render`       |
| **Always Output Data** | `true`                                                                |
| **Purpose**            | Sends the storyboard payload to Pictory and initiates video rendering |

This node makes a `POST` request to the Pictory [Render Storyboard Video](/api-reference/videos/render-storyboard-video) API. It sends:

* **Headers**: `Content-Type: application/json` and `Authorization: YOUR_PICTORY_API_KEY`
* **Body**: A JSON payload containing:
  * `videoName` — the name for the generated video
  * `smartLayoutName` — the visual layout theme (e.g., `"Wanderlust"`)
  * `voiceOver` — AI voice configuration with speaker name
  * `backgroundMusic` — auto-selected background music at 10% volume
  * `scenes` — the text story content, with `createSceneOnNewLine` and `createSceneOnEndOfSentence` both enabled to automatically split the story into multiple scenes

**Response**: Returns a `jobId` in `data.jobId` that you use to track the rendering progress.

```json theme={null}
{
  "data": {
    "jobId": "abc123-def456-..."
  }
}
```

***

### 3. Set JobId (Set Node)

| Property               | Value                                                |
| ---------------------- | ---------------------------------------------------- |
| **Type**               | `n8n-nodes-base.set`                                 |
| **Always Output Data** | `true`                                               |
| **Purpose**            | Extracts the job ID and initializes polling counters |

This node extracts three variables from the API response and stores them for use throughout the workflow:

| Variable     | Value                    | Description                                       |
| ------------ | ------------------------ | ------------------------------------------------- |
| `jobId`      | `{{ $json.data.jobId }}` | The rendering job identifier from Pictory         |
| `retryCount` | `0`                      | Current poll attempt counter (starts at 0)        |
| `maxRetries` | `30`                     | Maximum number of polling attempts before timeout |

With a 30-second wait between polls and 30 max retries, the workflow will wait up to **15 minutes** for the video to render before timing out.

***

### 4. Wait for Job to Complete (Wait Node)

| Property               | Value                                          |
| ---------------------- | ---------------------------------------------- |
| **Type**               | `n8n-nodes-base.wait`                          |
| **Wait Time**          | `30` seconds                                   |
| **Always Output Data** | `true`                                         |
| **Purpose**            | Pauses execution before polling the job status |

This node introduces a 30-second delay before checking the job status. Video rendering takes time, so polling immediately after submission would return an `in-progress` status. This wait ensures the API has time to process.

<Note>
  The Wait node pauses the entire workflow execution. In n8n, this means the execution is suspended and resumed after the wait period, so it does not consume resources while waiting.
</Note>

***

### 5. Get Job (HTTP Request)

| Property               | Value                                                                             |
| ---------------------- | --------------------------------------------------------------------------------- |
| **Type**               | `n8n-nodes-base.httpRequest`                                                      |
| **Method**             | `GET`                                                                             |
| **URL**                | `https://api.pictory.ai/pictoryapis/v1/jobs/{{ $('Set JobId').item.json.jobId }}` |
| **Always Output Data** | `true`                                                                            |
| **Purpose**            | Polls the Pictory Jobs API to check rendering status                              |

This node calls the [Get Job](/api-reference/jobs/get-video-render-job-by-id) API using the `jobId` extracted earlier. The URL dynamically references the `jobId` from the **Set JobId** node using n8n's expression syntax `{{ $('Set JobId').item.json.jobId }}`.

**Response**: Returns the job status along with video URLs when completed:

```json theme={null}
{
  "data": {
    "status": "completed",
    "videoURL": "https://...",
    "videoShareURL": "https://..."
  }
}
```

Possible status values: `in-progress`, `completed`, `failed`.

***

### 6. If Job is Completed or Failed (IF Node)

| Property               | Value                                                                  |
| ---------------------- | ---------------------------------------------------------------------- |
| **Type**               | `n8n-nodes-base.if`                                                    |
| **Condition**          | `data.status == "completed"` OR `data.status == "failed"`              |
| **Always Output Data** | `true`                                                                 |
| **Purpose**            | Routes execution based on whether the job has reached a terminal state |

This is the primary branching node:

* **True branch** (status is `completed` or `failed`): Proceeds to the **If Job Completed** node for further evaluation
* **False branch** (status is still `in-progress`): Proceeds to the **Increment Retry** node to poll again

***

### 7. If Job Completed (IF Node)

| Property               | Value                                                  |
| ---------------------- | ------------------------------------------------------ |
| **Type**               | `n8n-nodes-base.if`                                    |
| **Condition**          | `data.status == "completed"`                           |
| **Always Output Data** | `true`                                                 |
| **Purpose**            | Distinguishes between a completed job and a failed job |

This second-level branch runs only when the job has reached a terminal state:

* **True branch** (completed): Routes to **Set Job Output** to extract video URLs
* **False branch** (failed): Routes to **Set Failed Job Status** to record the failure

***

### 8. Set Job Output (Set Node)

| Property               | Value                                                 |
| ---------------------- | ----------------------------------------------------- |
| **Type**               | `n8n-nodes-base.set`                                  |
| **Always Output Data** | `true`                                                |
| **Purpose**            | Extracts video URLs from a successfully completed job |

When the job completes successfully, this node extracts:

| Variable        | Value                                  | Description                                |
| --------------- | -------------------------------------- | ------------------------------------------ |
| `videoUrl`      | `{{ $json.data.videoURL }}`            | Direct download URL for the rendered video |
| `videoShareUrl` | `{{ $json.data.videoShareURL }}`       | Shareable video URL                        |
| `jobId`         | `{{ $('Set JobId').item.json.jobId }}` | The original job ID for reference          |

This is the **success endpoint** of the workflow. You can connect additional nodes here to send notifications, upload the video, or write results back to a spreadsheet.

***

### 9. Set Failed Job Status (Set Node)

| Property    | Value                               |
| ----------- | ----------------------------------- |
| **Type**    | `n8n-nodes-base.set`                |
| **Purpose** | Records a job failure with a reason |

When the job status is `failed`, this node sets:

| Variable           | Value                    |
| ------------------ | ------------------------ |
| `status`           | `"failed"`               |
| `reasonForFailure` | `"job execution failed"` |

This is a **failure endpoint**. You can connect notification nodes here (e.g., Slack, email) to alert you when a video render fails.

***

### 10. Increment Retry (Set Node)

| Property               | Value                                             |
| ---------------------- | ------------------------------------------------- |
| **Type**               | `n8n-nodes-base.set`                              |
| **Always Output Data** | `true`                                            |
| **Purpose**            | Increments the retry counter for the polling loop |

When the job is still in progress, this node:

* Increments `retryCount` by 1: `{{ $('Set JobId').item.json.retryCount + 1 }}`
* Preserves `jobId` and `maxRetries` from the original **Set JobId** node

This creates the polling loop by updating the counter before checking whether to continue polling.

***

### 11. Can Poll Job (IF Node)

| Property               | Value                                                              |
| ---------------------- | ------------------------------------------------------------------ |
| **Type**               | `n8n-nodes-base.if`                                                |
| **Condition**          | `retryCount == maxRetries`                                         |
| **Always Output Data** | `true`                                                             |
| **Purpose**            | Guards against infinite polling by enforcing a maximum retry limit |

This node checks if the retry limit has been reached:

* **True branch** (retryCount equals maxRetries): The job has timed out. Routes to **Set Timeout Job Status**
* **False branch** (retryCount is less than maxRetries): Loops back to **Wait for Job to Complete** to poll again

***

### 12. Set Timeout Job Status (Set Node)

| Property               | Value                                               |
| ---------------------- | --------------------------------------------------- |
| **Type**               | `n8n-nodes-base.set`                                |
| **Always Output Data** | `true`                                              |
| **Purpose**            | Records a timeout when polling exhausts all retries |

When max retries are reached without the job completing, this node sets:

| Variable           | Value               |
| ------------------ | ------------------- |
| `renderStatus`     | `"failed"`          |
| `reasonForFailure` | `"job timed out"`   |
| `jobId`            | The original job ID |

This is the **timeout endpoint**. Consider increasing `maxRetries` in the **Set JobId** node if your videos consistently need more time to render.

## Understanding `alwaysOutputData`

Most nodes in this workflow have `alwaysOutputData` set to `true`. This is a critical n8n node setting that controls what happens when a node produces no output items.

**What it does:** When `alwaysOutputData` is enabled, the node will always output at least one empty item (`[{}]`), even if the node itself produces no data. Without this setting, a node that returns no items would cause downstream nodes to be skipped entirely.

**Why it matters in this workflow:**

* **Polling loop continuity**: The polling loop (Wait -> Get Job -> IF -> Increment Retry -> Can Poll Job -> back to Wait) must never break due to an empty output. If the **Get Job** node returned no data (e.g., due to a network issue), `alwaysOutputData` ensures the workflow still routes through the IF nodes and continues polling rather than silently stopping.
* **IF node branching**: IF nodes with `alwaysOutputData` ensure that both the true and false branches always emit an item. This prevents the workflow from stalling when a condition does not match any input items.
* **Error visibility**: Without `alwaysOutputData`, a node that fails silently (no output) would make the workflow appear to hang. With it enabled, the empty item propagates through the chain, making it easier to debug where things went wrong.

<Tip>
  As a best practice, enable `alwaysOutputData` on all nodes in polling/retry workflows. This ensures the loop never breaks silently and all branches always execute, giving you predictable behavior even when API responses are unexpected.
</Tip>

## Workflow Flow Diagram

```mermaid theme={null}
flowchart TD
    A[Manual Trigger] --> B[Pictory Render Storyboard Video]
    B --> C[Set JobId + retryCount=0 + maxRetries=30]
    C --> D[Wait 30 seconds]
    D --> E[Get Job Status]
    E --> F{Job Completed or Failed?}
    F -->|Yes| G{Job Completed?}
    F -->|No - Still in progress| H[Increment Retry]
    G -->|Yes| I[Set Job Output: videoUrl, videoShareUrl]
    G -->|No - Failed| J[Set Failed Job Status]
    H --> K{retryCount == maxRetries?}
    K -->|Yes - Timed out| L[Set Timeout Job Status]
    K -->|No - Keep polling| D
```

## Google Sheets Integration

A common use case is reading video content from a Google Sheets spreadsheet, rendering videos for each row, and writing the results (job ID and video URL) back to the spreadsheet.

### Spreadsheet Structure

Set up your Google Sheet with the following columns:

| Column A                   | Column B       | Column C               | Column D               | Column E               |
| -------------------------- | -------------- | ---------------------- | ---------------------- | ---------------------- |
| **Story Text**             | **Video Name** | **Job ID**             | **Status**             | **Video URL**          |
| Your story content here... | my\_video\_1   | *(filled by workflow)* | *(filled by workflow)* | *(filled by workflow)* |

### Modified Workflow for Spreadsheet Integration

To integrate with Google Sheets, modify the workflow with these additional nodes:

#### Step 1: Read from Google Sheets (Replace Manual Trigger)

Replace the **Manual Trigger** node with a **Google Sheets** node:

* **Operation**: Read Rows
* **Document**: Select your spreadsheet
* **Sheet**: Select the sheet name
* **Options**: Filter rows where **Job ID** column is empty (to only process new rows)

Each row becomes an item that flows through the workflow. For example, if you have 20 rows, the workflow processes 20 videos.

#### Step 2: Use Spreadsheet Data in the Render Request

In the **Pictory Render Storyboard Video** node, replace the hardcoded body with dynamic expressions referencing the spreadsheet columns:

```json theme={null}
{
  "videoName": "={{ $json['Video Name'] }}",
  "smartLayoutName": "Wanderlust",
  "voiceOver": {
    "enabled": true,
    "aiVoices": [{ "speaker": "Brian" }]
  },
  "backgroundMusic": {
    "enabled": true,
    "volume": 0.1,
    "autoMusic": true
  },
  "scenes": [
    {
      "story": "={{ $json['Story Text'] }}",
      "createSceneOnNewLine": true,
      "createSceneOnEndOfSentence": true
    }
  ]
}
```

#### Step 3: Write Job ID to Spreadsheet

After the **Set JobId** node, add a **Google Sheets** node to write the job ID back:

* **Operation**: Update Row
* **Document**: Select your spreadsheet
* **Sheet**: Select the sheet name
* **Mapping Column**: Use the row number or a unique identifier
* **Values to Update**:
  * **Job ID** column: `{{ $json.jobId }}`
  * **Status** column: `in-progress`

This immediately records the job ID so you can track which rows have been submitted for rendering.

#### Step 4: Write Video URL to Spreadsheet on Completion

After the **Set Job Output** node (success endpoint), add another **Google Sheets** node:

* **Operation**: Update Row
* **Document**: Select your spreadsheet
* **Sheet**: Select the sheet name
* **Matching Column**: Match on the **Job ID** column with value `{{ $json.jobId }}`
* **Values to Update**:
  * **Status** column: `completed`
  * **Video URL** column: `{{ $json.videoUrl }}`

<Tip>
  Use the **Job ID** column as the matching key to ensure the video URL is written to the correct row. The Google Sheets node's **Update Row** operation can match on any column value. It finds the row where the Job ID matches and updates the corresponding Video URL column.
</Tip>

#### Step 5: Write Failure Status to Spreadsheet

Similarly, after the **Set Failed Job Status** and **Set Timeout Job Status** nodes, add Google Sheets nodes to update the status:

* **Matching Column**: Match on the **Job ID** column
* **Values to Update**:
  * **Status** column: `failed` or `timed out`
  * **Video URL** column: *(leave empty)*

### Complete Spreadsheet Workflow Diagram

```mermaid theme={null}
flowchart TD
    A[Google Sheets: Read Rows] --> B[Pictory Render Storyboard Video]
    B --> C[Set JobId]
    C --> W[Google Sheets: Write Job ID + Status=in-progress]
    W --> D[Wait 30 seconds]
    D --> E[Get Job Status]
    E --> F{Completed or Failed?}
    F -->|Yes| G{Completed?}
    F -->|No| H[Increment Retry]
    G -->|Yes| I[Set Job Output]
    I --> S1[Google Sheets: Write Video URL + Status=completed]
    G -->|No| J[Set Failed Status]
    J --> S2[Google Sheets: Write Status=failed]
    H --> K{Max retries?}
    K -->|Yes| L[Set Timeout Status]
    L --> S3[Google Sheets: Write Status=timed out]
    K -->|No| D
```

## Best Practices

<AccordionGroup>
  <Accordion title="Secure Your API Key">
    Use n8n's **Credentials** feature or **Environment Variables** to store your Pictory API key instead of hardcoding it in HTTP Request nodes. This prevents accidental exposure when sharing or exporting workflows.
  </Accordion>

  <Accordion title="Adjust Polling Interval and Retries">
    The default configuration polls every 30 seconds with a maximum of 30 retries (15 minutes total). For longer videos, increase `maxRetries` in the **Set JobId** node. The recommended polling interval is 10–30 seconds.
  </Accordion>

  <Accordion title="Handle Rate Limits">
    If processing many videos in batch (e.g., from a large spreadsheet), add a short delay between render requests to avoid hitting Pictory API rate limits. You can use a **Wait** node with a 2-5 second delay before the Render node.
  </Accordion>

  <Accordion title="Error Notifications">
    Connect the failure endpoints (**Set Failed Job Status** and **Set Timeout Job Status**) to notification nodes such as Slack, email, or Discord to get alerted when a render fails or times out.
  </Accordion>

  <Accordion title="Idempotent Spreadsheet Processing">
    Filter spreadsheet rows by checking if the **Job ID** column is empty before processing. This ensures rows that have already been submitted are not processed again if the workflow is re-run.
  </Accordion>
</AccordionGroup>

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Unauthorized error from Pictory API">
    Verify your API key is correct and includes the full key string. The Authorization header value should be your complete API key (e.g., `pictai_xxxx...`). Do not add `Bearer` prefix.
  </Accordion>

  <Accordion title="Job always times out">
    Increase `maxRetries` in the **Set JobId** node. Longer stories or higher-quality renders may take more than 15 minutes. Also verify that the initial render request returned a valid `jobId`.
  </Accordion>

  <Accordion title="Google Sheets row not updating">
    Ensure the **Matching Column** is set correctly in the Google Sheets Update Row operation. The Job ID value must exactly match what was written earlier. Check that your Google Sheets credentials have write permissions.
  </Accordion>

  <Accordion title="Workflow stops at Wait node">
    In n8n, the Wait node suspends workflow execution. If you are using n8n in queue mode, ensure your workers are running. For the default main mode, the workflow resumes automatically after the wait period.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Render Storyboard Video API" icon="film" href="/api-reference/videos/render-storyboard-video">
    Full API reference for the render endpoint used in this workflow
  </Card>

  <Card title="Get Job Status API" icon="clock" href="/api-reference/jobs/get-video-render-job-by-id">
    API reference for polling job status
  </Card>

  <Card title="Text to Video Guide" icon="text" href="/guides/text-to-video/text-to-video-basic">
    Learn about storyboard payload options and scene configuration
  </Card>

  <Card title="AI Voiceover Guide" icon="microphone" href="/guides/text-to-video/ai-voiceover">
    Configure AI voices for your automated video pipeline
  </Card>
</CardGroup>
