Auto-generate thumbnails with n8n 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
- An S3 Trigger node detects a new video in your uploads bucket
- An HTTP Request node POSTs to Ittybit
/taskswithkind: "image"and a webhook callback URL - n8n pauses the workflow at a Wait node until Ittybit calls back
- An IF node checks whether the task completed successfully
- An S3 node copies the thumbnail to your assets bucket
- 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:
| 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:
[
{ "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 — thumbnail options and format guide
- Process files from S3 — setting up S3 connections
- Write output to S3 — writing processed files back to your bucket
- Slack media bot with n8n — another n8n integration pattern
- n8n HTTP Request node docs