Slack media bot with n8n and Ittybit
Someone drops a media URL in Slack with /process, n8n picks it up, sends it to Ittybit for transcoding, waits for the result, and replies in-thread with the output URL. No code to deploy, no servers to manage.
How it works
- A Slack slash command (
/process) sends the media URL and type to n8n - n8n routes the request by media type (video, audio, or image)
- An HTTP Request node POSTs to Ittybit
/taskswith a webhook callback URL - n8n pauses the workflow and waits for the Ittybit webhook
- When processing completes, n8n resumes and replies in the original Slack thread
Prerequisites
- An n8n instance (cloud or self-hosted)
- A Slack workspace with permission to create a slash command
- An Ittybit API key
Create the Slack slash command
In the Slack API dashboard, create a new app and add a slash command:
- Command:
/process - Request URL: Your n8n webhook trigger URL (created in the next step)
- Description:
Process a media file with Ittybit - Usage Hint:
[video|audio|image] [url]
Install the app to your workspace and note the Bot User OAuth Token for the Slack reply node.
Build the n8n workflow
1. Webhook Trigger node
Create a new workflow. Add a Webhook node as the trigger. Set the HTTP method to POST. Copy the webhook URL and paste it into the Slack slash command Request URL.
The incoming payload from Slack will include:
{
"text": "video https://example.com/uploads/clip.mov",
"channel_id": "C01ABC123",
"user_id": "U01XYZ789",
"response_url": "https://hooks.slack.com/commands/..."
}
2. Function node: parse the command
Add a Code node to split the slash command text into media type and URL:
const text = $input.first().json.text || '';
const parts = text.trim().split(/\s+/);
const kind = ['video', 'audio', 'image'].includes(parts[0]) ? parts[0] : 'video';
const url = parts.find((p) => p.startsWith('http')) || '';
return [
{
json: {
kind,
url,
channel_id: $input.first().json.channel_id,
user_id: $input.first().json.user_id,
},
},
];
3. IF node: validate input
Add an IF node to check that a URL was provided. Condition: {{ $json.url }} is not empty. Route the “false” branch to a Respond to Webhook node that returns an error message to Slack.
4. HTTP Request node: create the Ittybit task
This is the core of the workflow. Add an HTTP Request node with this config:
{
"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": "={{ $json.url }}" },
{ "name": "kind", "value": "={{ $json.kind }}" },
{
"name": "webhook_url",
"value": "={{ $node['Wait'].webhookUrl }}"
},
{
"name": "metadata",
"value": "={{ JSON.stringify({ channel_id: $json.channel_id, user_id: $json.user_id }) }}"
}
]
}
}curl -X POST https://api.ittybit.com/jobs \
-H "Authorization: Bearer $ITTYBIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": "https://example.com/uploads/clip.mov",
"kind": "video",
"webhook_url": "https://your-n8n.app/webhook-waiting/abc123",
"metadata": {
"channel_id": "C01ABC123",
"user_id": "U01XYZ789"
}
}' The webhook_url points to the n8n Wait node so Ittybit can resume the workflow on completion. The metadata field passes through unchanged, carrying the Slack context needed for the reply.
5. Respond to Webhook node
Immediately after dispatching the task, send an acknowledgement back to Slack so the user knows the job is in progress. Add a Respond to Webhook node:
{
"respondWith": "text",
"responseBody": "Processing your {{ $json.kind }} file. I'll reply here when it's done."
}
6. Wait node: pause for webhook callback
Add a Wait node set to Resume on webhook call. This pauses the workflow until Ittybit POSTs back. The Wait node generates a unique webhook URL that you passed as webhook_url in step 4.
When Ittybit completes the task, it POSTs to this URL with:
{
"id": "task_abc123",
"status": "completed",
"kind": "video",
"output": {
"url": "https://cdn.ittybit.com/o/output/video.mp4"
},
"metadata": {
"channel_id": "C01ABC123",
"user_id": "U01XYZ789"
}
}
7. IF node: check task status
Add an IF node to branch on {{ $json.status }}:
- completed — continue to the Slack reply node
- failed — send an error message instead
8. Slack node: reply in thread
Add a Slack node (using your Bot User OAuth Token):
- Operation: Send Message
- Channel:
{{ $json.metadata.channel_id }} - Text:
<@{{ $json.metadata.user_id }}> Done! Here's your processed {{ $json.kind }}: {{ $json.output.url }}
For the failure branch, send:
- Text:
<@{{ $json.metadata.user_id }}> Processing failed for task {{ $json.id }}. Check the Ittybit dashboard for details.
Full workflow JSON
Import this directly into n8n via Settings > Import from JSON:
{
"name": "Slack Media Bot",
"nodes": [
{
"parameters": { "httpMethod": "POST", "path": "slack-media-bot" },
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"position": [240, 300]
},
{
"parameters": {
"jsCode": "const text = $input.first().json.text || '';\nconst parts = text.trim().split(/\\s+/);\nconst kind = ['video', 'audio', 'image'].includes(parts[0]) ? parts[0] : 'video';\nconst url = parts.find(p => p.startsWith('http')) || '';\nreturn [{ json: { kind, url, channel_id: $input.first().json.channel_id, user_id: $input.first().json.user_id } }];"
},
"name": "Parse Command",
"type": "n8n-nodes-base.code",
"position": [460, 300]
},
{
"parameters": {
"conditions": { "string": [{ "value1": "={{ $json.url }}", "operation": "isNotEmpty" }] }
},
"name": "Has URL?",
"type": "n8n-nodes-base.if",
"position": [680, 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\": \"{{ $json.url }}\", \"kind\": \"{{ $json.kind }}\", \"webhook_url\": \"{{ $node['Wait for Ittybit'].webhookUrl }}\", \"metadata\": { \"channel_id\": \"{{ $json.channel_id }}\", \"user_id\": \"{{ $json.user_id }}\" } }"
},
"name": "Create Ittybit Task",
"type": "n8n-nodes-base.httpRequest",
"position": [900, 260]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "=Processing your {{ $json.kind }} file. I'll reply here when it's done."
},
"name": "Ack to Slack",
"type": "n8n-nodes-base.respondToWebhook",
"position": [1120, 260]
},
{
"parameters": { "resume": "webhook" },
"name": "Wait for Ittybit",
"type": "n8n-nodes-base.wait",
"position": [1340, 260]
},
{
"parameters": {
"conditions": { "string": [{ "value1": "={{ $json.status }}", "value2": "completed" }] }
},
"name": "Task Completed?",
"type": "n8n-nodes-base.if",
"position": [1560, 260]
},
{
"parameters": {
"channel": "={{ $json.metadata.channel_id }}",
"text": "=<@{{ $json.metadata.user_id }}> Done! Here's your processed {{ $json.kind }}: {{ $json.output.url }}"
},
"name": "Slack Reply (Success)",
"type": "n8n-nodes-base.slack",
"position": [1780, 220]
},
{
"parameters": {
"channel": "={{ $json.metadata.channel_id }}",
"text": "=<@{{ $json.metadata.user_id }}> Processing failed for task {{ $json.id }}."
},
"name": "Slack Reply (Failure)",
"type": "n8n-nodes-base.slack",
"position": [1780, 360]
}
],
"connections": {
"Webhook Trigger": { "main": [[{ "node": "Parse Command", "type": "main", "index": 0 }]] },
"Parse Command": { "main": [[{ "node": "Has URL?", "type": "main", "index": 0 }]] },
"Has URL?": { "main": [[{ "node": "Create Ittybit Task", "type": "main", "index": 0 }], []] },
"Create Ittybit Task": { "main": [[{ "node": "Ack to Slack", "type": "main", "index": 0 }]] },
"Ack to Slack": { "main": [[{ "node": "Wait for Ittybit", "type": "main", "index": 0 }]] },
"Wait for Ittybit": { "main": [[{ "node": "Task Completed?", "type": "main", "index": 0 }]] },
"Task Completed?": {
"main": [
[{ "node": "Slack Reply (Success)", "type": "main", "index": 0 }],
[{ "node": "Slack Reply (Failure)", "type": "main", "index": 0 }]
]
}
}
}
Add processing options
Extend the Code node to support extra flags in the slash command. For example, /process video https://example.com/clip.mov 720p webm:
const text = $input.first().json.text || '';
const parts = text.trim().split(/\s+/);
const kind = ['video', 'audio', 'image'].includes(parts[0]) ? parts[0] : 'video';
const url = parts.find((p) => p.startsWith('http')) || '';
const flags = parts.filter((p) => !p.startsWith('http') && p !== kind);
const options = {};
for (const flag of flags) {
if (/^\d+p$/.test(flag)) options.height = parseInt(flag);
if (['mp4', 'webm', 'mov', 'mp3', 'aac', 'webp', 'png', 'jpg'].includes(flag))
options.format = flag;
if (['low', 'medium', 'high'].includes(flag)) options.quality = flag;
}
return [
{
json: {
kind,
url,
options,
channel_id: $input.first().json.channel_id,
user_id: $input.first().json.user_id,
},
},
];
Then pass options through to the Ittybit task body.
See also
- Build a user upload pipeline — multi-task processing for uploads
- Media processing pipeline on Railway — webhook-driven backend
- n8n HTTP Request node docs