Auto-generate thumbnails with n8n and Ittybit

View Markdown

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 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:

{
  "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".

{
  "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 }) }}"
      }
    ]
  }
}
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"
    }
  }'

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:

{
  "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:

// 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:

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:

{
  "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:

OptionExampleEffect
start5Extract frame at 5 seconds
format"webp", "jpg", "avif"Output format
width640Resize to 640px wide (maintains aspect ratio)
height360Resize 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:

[
  { "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