Slack media bot with n8n and Ittybit

View Markdown

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

  1. A Slack slash command (/process) sends the media URL and type to n8n
  2. n8n routes the request by media type (video, audio, or image)
  3. An HTTP Request node POSTs to Ittybit /tasks with a webhook callback URL
  4. n8n pauses the workflow and waits for the Ittybit webhook
  5. 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