# Auto-generate thumbnails with n8n and Ittybit

Automatically extract thumbnails from S3 video uploads using n8n workflows and Ittybit

A new video lands in S3. Within seconds, n8n detects the upload, sends the file to Ittybit for thumbnail extraction, waits for the result via webhook, copies the thumbnail back to S3, and updates your Postgres database. No custom servers, no cron jobs.

## How it works

```
S3 upload -> n8n S3 Trigger -> HTTP Request (Ittybit) -> Wait (webhook)
                                                            |
Postgres <- S3 node (copy thumbnail) <- IF node <- Ittybit webhook
```

1. An S3 Trigger node detects a new video in your uploads bucket
2. An HTTP Request node POSTs to Ittybit `/tasks` with `kind: "image"` and a webhook callback URL
3. n8n pauses the workflow at a Wait node until Ittybit calls back
4. An IF node checks whether the task completed successfully
5. An S3 node copies the thumbnail to your assets bucket
6. A Postgres node writes the thumbnail URL and metadata to your database

## Prerequisites

- An [n8n](https://n8n.io) instance (cloud or self-hosted)
- An S3 bucket with video uploads (and credentials configured in n8n)
- An Ittybit API key
- A Postgres database for storing thumbnail metadata

## Build the n8n workflow

### 1. S3 Trigger node

Add an **S3 Trigger** node to poll your uploads bucket for new objects. Set the prefix to your video upload path and filter by extension:

- **Bucket:** `my-media-bucket`
- **Prefix:** `uploads/`
- **Poll interval:** Every 1 minute (adjust to your volume)

The trigger emits events with this shape:

```json
{
  "Key": "uploads/product-demo.mp4",
  "Bucket": "my-media-bucket",
  "Size": 52428800,
  "LastModified": "2026-04-03T12:00:00Z"
}
```

### 2. IF node: filter video files

Add an **IF** node to ensure only video files proceed. Condition: `{{ $json.Key }}` matches regex `\.(mp4|mov|mkv|webm|avi)$`. Route non-video files to a **No Operation** node.

### 3. HTTP Request node: create the Ittybit task

This is the core node. It sends the video to Ittybit for thumbnail extraction using `kind: "image"`.

<CodeGroup labels={["n8n node config", "curl"]}>
```json
{
  "method": "POST",
  "url": "https://api.ittybit.com/jobs",
  "authentication": "genericCredentialType",
  "genericAuthType": "httpHeaderAuth",
  "sendHeaders": true,
  "headerParameters": {
    "parameters": [
      { "name": "Authorization", "value": "Bearer {{ $credentials.ittybitApiKey }}" },
      { "name": "Content-Type", "value": "application/json" }
    ]
  },
  "sendBody": true,
  "bodyParameters": {
    "parameters": [
      { "name": "input", "value": "=s3://{{ $json.Bucket }}/{{ $json.Key }}" },
      { "name": "kind", "value": "image" },
      {
        "name": "options",
        "value": "={ \"format\": \"webp\", \"width\": 640, \"start\": 2, \"connection_id\": \"{{ $credentials.ittybitConnectionId }}\" }"
      },
      {
        "name": "webhook_url",
        "value": "={{ $node['Wait for Thumbnail'].webhookUrl }}"
      },
      {
        "name": "metadata",
        "value": "={{ JSON.stringify({ source_bucket: $json.Bucket, source_key: $json.Key }) }}"
      }
    ]
  }
}
```

```bash
curl -X POST https://api.ittybit.com/jobs \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "s3://my-media-bucket/uploads/product-demo.mp4",
    "kind": "image",
    "options": {
      "format": "webp",
      "width": 640,
      "start": 2,
      "connection_id": "conn_abc123"
    },
    "webhook_url": "https://your-n8n.app/webhook-waiting/abc123",
    "metadata": {
      "source_bucket": "my-media-bucket",
      "source_key": "uploads/product-demo.mp4"
    }
  }'
```

</CodeGroup>

The `kind: "image"` tells Ittybit to extract a still frame from the video. The `start` option sets the timestamp in seconds. The `webhook_url` points to the Wait node so Ittybit resumes the workflow when the thumbnail is ready.

### 4. Wait node: pause for webhook callback

Add a **Wait** node set to **Resume on webhook call**. This pauses the workflow until Ittybit POSTs back with the completed job.

When Ittybit finishes, it sends:

```json
{
  "event": "job.succeeded",
  "data": {
    "id": "task_abc123",
    "status": "completed",
    "kind": "image",
    "input": "s3://my-media-bucket/uploads/product-demo.mp4",
    "output": {
      "url": "https://cdn.ittybit.com/o/abc/product-demo.webp"
    },
    "options": {
      "format": "webp",
      "width": 640,
      "start": 2
    },
    "metadata": {
      "source_bucket": "my-media-bucket",
      "source_key": "uploads/product-demo.mp4"
    },
    "created_at": 1775217601000,
    "completed_at": 1775217603000
  }
}
```

### 5. IF node: check task status

Add an **IF** node to branch on `{{ $json.data.status }}`:

- **completed** -- continue to the S3 copy and database update
- **failed** -- route to an error handler (e.g., a Slack notification or email alert)

### 6. S3 node: copy thumbnail to assets bucket

Add an **HTTP Request** node (or **AWS S3** node) to download the thumbnail from the Ittybit CDN URL and upload it to your assets bucket:

```javascript
// Code node to build the S3 destination key
const sourceKey = $json.data.metadata.source_key;
const filename = sourceKey
  .split('/')
  .pop()
  .replace(/\.[^.]+$/, '.webp');

return [
  {
    json: {
      thumbnail_url: $json.data.output.url,
      destination_key: `thumbnails/${filename}`,
      source_key: sourceKey,
      task_id: $json.data.id,
    },
  },
];
```

Then configure an **AWS S3** node:

- **Operation:** Upload
- **Bucket:** `my-media-bucket`
- **Key:** `{{ $json.destination_key }}`
- **Binary data:** from the HTTP Request node that fetched the thumbnail

### 7. Postgres node: update metadata

Add a **Postgres** node to write the thumbnail reference to your database:

```sql
UPDATE videos
SET
  thumbnail_url = $1,
  thumbnail_task_id = $2,
  updated_at = NOW()
WHERE source_key = $3;
```

Parameters:

- `$1`: `{{ $json.destination_key }}` (or the full S3 URL)
- `$2`: `{{ $json.task_id }}`
- `$3`: `{{ $json.source_key }}`

## Full workflow JSON

Import this directly into n8n via **Settings > Import from JSON**:

```json
{
  "name": "Auto-Generate Thumbnails",
  "nodes": [
    {
      "parameters": {
        "bucket": "my-media-bucket",
        "prefix": "uploads/",
        "pollTimes": { "item": [{ "mode": "everyMinute" }] }
      },
      "name": "S3 Trigger",
      "type": "n8n-nodes-base.s3Trigger",
      "position": [240, 300]
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.Key }}",
              "operation": "regex",
              "value2": "\\.(mp4|mov|mkv|webm|avi)$"
            }
          ]
        }
      },
      "name": "Is Video?",
      "type": "n8n-nodes-base.if",
      "position": [460, 300]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ittybit.com/jobs",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Authorization", "value": "Bearer {{ $credentials.ittybitApiKey }}" },
            { "name": "Content-Type", "value": "application/json" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={ \"input\": \"s3://{{ $json.Bucket }}/{{ $json.Key }}\", \"kind\": \"image\", \"options\": { \"format\": \"webp\", \"width\": 640, \"start\": 2 }, \"webhook_url\": \"{{ $node['Wait for Thumbnail'].webhookUrl }}\", \"metadata\": { \"source_bucket\": \"{{ $json.Bucket }}\", \"source_key\": \"{{ $json.Key }}\" } }"
      },
      "name": "Create Thumbnail Task",
      "type": "n8n-nodes-base.httpRequest",
      "position": [680, 260]
    },
    {
      "parameters": { "resume": "webhook" },
      "name": "Wait for Thumbnail",
      "type": "n8n-nodes-base.wait",
      "position": [900, 260]
    },
    {
      "parameters": {
        "conditions": {
          "string": [{ "value1": "={{ $json.data.status }}", "value2": "completed" }]
        }
      },
      "name": "Task Completed?",
      "type": "n8n-nodes-base.if",
      "position": [1120, 260]
    },
    {
      "parameters": {
        "jsCode": "const sourceKey = $json.data.metadata.source_key;\nconst filename = sourceKey.split('/').pop().replace(/\\.[^.]+$/, '.webp');\nreturn [{ json: { thumbnail_url: $json.data.output.url, destination_key: `thumbnails/${filename}`, source_key: sourceKey, task_id: $json.data.id } }];"
      },
      "name": "Build S3 Path",
      "type": "n8n-nodes-base.code",
      "position": [1340, 220]
    },
    {
      "parameters": {
        "operation": "upload",
        "bucket": "my-media-bucket",
        "key": "={{ $json.destination_key }}",
        "binaryPropertyName": "data"
      },
      "name": "Upload to S3",
      "type": "n8n-nodes-base.s3",
      "position": [1560, 220]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE videos SET thumbnail_url = $1, thumbnail_task_id = $2, updated_at = NOW() WHERE source_key = $3",
        "additionalFields": {
          "queryParams": "={{ $json.destination_key }},={{ $json.task_id }},={{ $json.source_key }}"
        }
      },
      "name": "Update Database",
      "type": "n8n-nodes-base.postgres",
      "position": [1780, 220]
    }
  ],
  "connections": {
    "S3 Trigger": { "main": [[{ "node": "Is Video?", "type": "main", "index": 0 }]] },
    "Is Video?": {
      "main": [[{ "node": "Create Thumbnail Task", "type": "main", "index": 0 }], []]
    },
    "Create Thumbnail Task": {
      "main": [[{ "node": "Wait for Thumbnail", "type": "main", "index": 0 }]]
    },
    "Wait for Thumbnail": { "main": [[{ "node": "Task Completed?", "type": "main", "index": 0 }]] },
    "Task Completed?": {
      "main": [[{ "node": "Build S3 Path", "type": "main", "index": 0 }], []]
    },
    "Build S3 Path": { "main": [[{ "node": "Upload to S3", "type": "main", "index": 0 }]] },
    "Upload to S3": { "main": [[{ "node": "Update Database", "type": "main", "index": 0 }]] }
  }
}
```

## Thumbnail options

Adjust the `options` object in the HTTP Request body to control the output:

| Option    | Example                     | Effect                                        |
| --------- | --------------------------- | --------------------------------------------- |
| `start`   | `5`                         | Extract frame at 5 seconds                    |
| `format`  | `"webp"`, `"jpg"`, `"avif"` | Output format                                 |
| `width`   | `640`                       | Resize to 640px wide (maintains aspect ratio) |
| `height`  | `360`                       | Resize to 360px tall                          |
| `quality` | `"high"`                    | Output quality level                          |

## Multiple thumbnail sizes

Extend the workflow to generate several sizes per video. After the S3 Trigger, fan out into parallel HTTP Request nodes:

```json
[
  { "kind": "image", "options": { "format": "webp", "width": 1280, "start": 2 } },
  { "kind": "image", "options": { "format": "webp", "width": 640, "start": 2 } },
  { "kind": "image", "options": { "format": "webp", "width": 320, "start": 2 } }
]
```

Each task fires its own webhook, so use a **Merge** node (set to wait for all inputs) before the database update if you want to write all sizes in one row.

## See also

- [Extract thumbnails from video](/guides/extract-thumbnails-from-video) -- thumbnail options and format guide
- [Process files from S3](/guides/process-files-from-s3) -- setting up S3 connections
- [Write output to S3](/guides/write-output-to-s3) -- writing processed files back to your bucket
- [Slack media bot with n8n](/guides/slack-media-bot-with-n8n) -- another n8n integration pattern
- [n8n HTTP Request node docs](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/)