Intermediate~15 min setupCommunication & Project ManagementVerified April 2026
Slack logo
Todoist logo

How to Convert Slack Messages to Todoist Tasks with Pipedream

Watches for a specific emoji reaction on Slack messages and instantly creates a Todoist task with the message text, sender, and a link back to the original thread.

Steps and UI details are based on platform versions at time of writing — check each platform for the latest interface.

Best for

Developer-led teams who want custom parsing logic — stripping @mentions, extracting due dates from message text, or routing tasks to different Todoist projects by channel.

Not ideal for

Non-technical teams who just need a simple reaction-to-task trigger — Zapier's native Slack + Todoist integration sets that up in under 10 minutes with no code.

Sync type

real-time

Use case type

routing

Real-World Example

💡

A 12-person product team at a B2B SaaS company uses this workflow so that any message reacted with ✅ in #product-feedback instantly becomes a Todoist task assigned to the PM inbox. Before this, action items buried in Slack threads were manually copy-pasted into Todoist hours later — or not at all. Now, tasks appear in Todoist within 3 seconds of the reaction, with a direct link back to the Slack thread.

What Will This Cost?

Drag the slider to your expected monthly volume.

/mo
505005K50K

Each platform counts differently — Zapier: 1 task per trigger. Make: 1 operation per module per record. n8n: 1 execution per run.

Prices shown for annual billing. Based on published pricing as of April 2026.

Estimated ROI

1000

min saved/mo

$583

labor value/mo

Free

no platform cost

Based on ~2 min manual effort per operation at $35/hr fully loaded labor cost.

Implementation

Skip the setup

Import this workflow directly into Pipedream

Copy the pre-built Pipedream blueprint and paste it straight into Pipedream. All modules, filters, and field mappings are already configured — you just need to connect your accounts.

Before You Start

Make sure you have everything ready.

Slack account with permission to install apps to your workspace (Workspace Admin or App Manager role required)
Slack bot token with scopes: reactions:read, channels:history, groups:history, users:read, chat:write (for error DMs)
Todoist account with a valid API token — free or Pro plan both work, but project routing requires knowing the numeric Project ID
Pipedream account — free tier works for testing, but production use needs a paid plan if you exceed 100 credits/day
The exact emoji name you'll use as the trigger (e.g. 'white_check_mark' for ✅) — confirm the name by opening Slack's emoji picker and hovering over the emoji

Field Mapping

Map these fields between your apps.

FieldAPI Name
Required
Task Content (Title)content
7 optional fields▸ show
Task Descriptiondescription
Project IDproject_id
Due Datedue_date
Prioritypriority
Assignee IDassignee_id
Labelslabels
Slack Message Timestamp

Step-by-Step Setup

1

pipedream.com > Dashboard > New Workflow

Create a new Pipedream Workflow

Go to pipedream.com and log in. Click 'New Workflow' from your dashboard. You'll land on the workflow canvas with an empty trigger slot at the top. Give the workflow a name like 'Slack Reaction → Todoist Task' in the title bar at the top left — this label appears in your logs, so make it descriptive.

  1. 1Click the blue 'New Workflow' button in the top right of your dashboard
  2. 2Click the workflow title field at the top and rename it to 'Slack Reaction → Todoist Task'
  3. 3Click 'Add a trigger' in the empty trigger block
What you should see: You should see a blank workflow canvas with a single trigger block at the top labeled 'Select a trigger'.
2

Workflow Canvas > Add a trigger > Slack > New Reaction Added

Set the Slack trigger to 'New Reaction Added'

In the trigger selector, search for 'Slack' and select it. Scroll through the trigger list until you find 'New Reaction Added' — this fires every time any user adds an emoji reaction to any message in channels your bot can see. This is the most reliable trigger for this workflow because it's specific and doesn't create noise from every message sent.

  1. 1Type 'Slack' in the trigger search box
  2. 2Click 'Slack' in the results
  3. 3Scroll to find 'New Reaction Added' and click it
  4. 4Click 'Connect Slack' and authorize your workspace via OAuth
What you should see: You should see a green 'Connected' badge next to your Slack workspace name and the trigger configuration panel should expand to show channel and reaction filter options.
Common mistake — The Slack bot must be invited to any private channel you want to monitor. Run /invite @Pipedream in the channel first or the trigger will never fire for messages there.
Pipedream
+
click +
search apps
Slack
SL
Slack
Set the Slack trigger to 'Ne…
Slack
SL
module added
3

Trigger Config > Reaction > Channel (optional)

Configure the reaction filter

In the trigger config panel, set the Reaction field to the specific emoji name you want to use — enter it without colons, e.g. 'white_check_mark' for ✅. Leave the Channel field blank if you want this to work across all channels, or enter a specific channel ID to scope it. Setting a specific reaction is critical: without it, every single emoji added by anyone will trigger a Todoist task.

  1. 1Click the 'Reaction' field and type the emoji name without colons, e.g. 'white_check_mark'
  2. 2Optionally click 'Channel' and paste your channel ID (find it in Slack under channel settings > copy channel ID)
  3. 3Click 'Generate Test Event' to pull a real sample reaction event from your workspace
What you should see: After clicking 'Generate Test Event', you should see a JSON payload appear below the trigger config with fields like event.reaction, event.item.ts, and event.user.
Common mistake — Emoji names are case-sensitive in the Slack API. 'White_Check_Mark' will not match. Use all lowercase with underscores.
Slack
SL
trigger
filter
Condition
matches criteria?
yes — passes through
no — skipped
Todoist
TO
notified
4

Workflow Canvas > + > Run Node.js code

Add a code step to fetch the original message text

The reaction event tells you which message was reacted to (via item.ts and item.channel) but does not include the message text itself. You need to call conversations.history to retrieve the actual message content. Click the '+' button below the trigger to add a new step, then select 'Run Node.js code'. This step will use the Slack API with your connected account to fetch the message.

  1. 1Click the '+' icon below the trigger block
  2. 2Select 'Run Node.js code' from the step type list
  3. 3Rename the step to 'fetch_message' by clicking the step name at the top
  4. 4Paste the code from the pro tip section below into the code editor
What you should see: After saving and running the test, you should see a green checkmark on the step and the exports object should contain a 'messageText' field with the actual Slack message content.
Common mistake — Your Slack OAuth token needs the channels:history scope (for public channels) or groups:history scope (for private channels). If the API call returns 'missing_scope', you need to reconnect your Slack account with these scopes enabled.

Paste this into two separate Node.js code steps in Pipedream: the first step (fetch_and_parse) fetches the message text via Slack API and cleans it, the second step creates the Todoist task with full error handling. The deduplication check uses Pipedream's built-in $.data key-value store so you don't need an external database.

JavaScript — Code Step// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
▸ Show code
// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";
export default defineComponent({

... expand to see full code

// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    slack: {
      type: "app",
      app: "slack",
    },
  },
  async run({ steps, $ }) {
    const { channel, ts } = steps.trigger.event.item;
    const reactionUser = steps.trigger.event.user;

    // Deduplication: skip if we've already processed this message+reaction
    const dedupKey = `slack-task-${channel}-${ts}`;
    const alreadySeen = await $.data.get(dedupKey);
    if (alreadySeen) {
      $.flow.exit("Duplicate reaction — task already created for this message.");
    }

    // Fetch the original message text from Slack
    const historyResp = await axios($, {
      url: "https://slack.com/api/conversations.history",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: {
        channel,
        latest: ts,
        inclusive: true,
        limit: 1,
      },
    });

    if (!historyResp.ok) {
      throw new Error(`Slack API error: ${historyResp.error}`);
    }

    const message = historyResp.messages?.[0];
    if (!message) throw new Error("Message not found at timestamp.");

    // Resolve the reacting user's display name
    const userResp = await axios($, {
      url: "https://slack.com/api/users.info",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: { user: message.user || reactionUser },
    });
    const senderName = userResp.user?.profile?.display_name || "Unknown";

    // Clean Slack mention syntax: <@U012AB3CD> → @username
    let cleanText = message.text.replace(/<@([A-Z0-9]+)>/g, (match, uid) => `@${uid}`);
    // Strip channel references like <#C012ABC|general> → #general
    cleanText = cleanText.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
    // Strip URLs wrapped in angle brackets
    cleanText = cleanText.replace(/<(https?:\/\/[^|>]+)(?:\|[^>]+)?>/g, "$1");

    // Extract due date if message contains 'due: <day>'
    const dueDateMatch = cleanText.match(/due:\s*(\w+)/i);
    let dueString = null;
    if (dueDateMatch) {
      // Basic day-name to date — extend this for production
      const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
      const targetDay = days.indexOf(dueDateMatch[1].toLowerCase());
      if (targetDay !== -1) {
        const now = new Date();
        const diff = (targetDay - now.getDay() + 7) % 7 || 7;
        const due = new Date(now);
        due.setDate(now.getDate() + diff);
        dueString = due.toISOString().split("T")[0];
      }
      // Remove the 'due: Thursday' fragment from task title
      cleanText = cleanText.replace(/due:\s*\w+/i, "").trim();
    }

    // Build Slack permalink
    const tsForUrl = ts.replace(".", "");
    const threadUrl = `https://slack.com/archives/${channel}/p${tsForUrl}`;

    // Limit task title to 140 chars
    const taskTitle = cleanText.length > 140 ? cleanText.substring(0, 137) + "..." : cleanText;

    // Mark as processed to prevent duplicate tasks
    await $.data.set(dedupKey, true);

    return { taskTitle, dueString, senderName, threadUrl, channel };
  },
});

// ── STEP 2: create_todoist_task ──────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    todoist: {
      type: "app",
      app: "todoist",
    },
  },
  async run({ steps, $ }) {
    const { taskTitle, dueString, senderName, threadUrl, channel } = steps.fetch_and_parse.$return_value;

    // Route to different Todoist projects based on Slack channel
    const channelProjectMap = {
      "C05XABCDE": 2312345678,   // #product-feedback → Product project
      "C02XBUGFIX": 2398765432,  // #bugs → Engineering project
    };
    const projectId = channelProjectMap[channel] || null; // null = Inbox

    const payload = {
      content: taskTitle,
      description: `Source: ${threadUrl}\nFrom: ${senderName}`,
      priority: 2,
      ...(dueString && { due_date: dueString }),
      ...(projectId && { project_id: projectId }),
      labels: ["slack"],
    };

    let taskResponse;
    try {
      taskResponse = await axios($, {
        method: "POST",
        url: "https://api.todoist.com/rest/v2/tasks",
        headers: {
          Authorization: `Bearer ${this.todoist.$auth.oauth_access_token}`,
          "Content-Type": "application/json",
        },
        data: payload,
      });
    } catch (err) {
      // Log full error for debugging, then rethrow so Pipedream marks the run failed
      console.error("Todoist API error:", err.response?.data || err.message);
      throw new Error(`Failed to create Todoist task: ${err.response?.data?.error || err.message}`);
    }

    console.log(`Task created: ${taskResponse.id} — "${taskTitle}"`);
    return { taskId: taskResponse.id, taskUrl: taskResponse.url };
  },
});
message template
🔔 New Record: {{text}} {{user}}
channel: {{channel}}
ts: {{ts}}
#sales
🔔 New Record: Jane Smith
Company: Acme Corp
5

Workflow Canvas > + > Run Node.js code

Add a second code step to parse the message

This step strips Slack's user mention formatting (e.g. <@U012AB3CD> → @username), removes channel references, and optionally extracts a due date if someone wrote 'due: Friday' in the message. This is where Pipedream earns its place — you can write real logic here that no-code tools cannot handle without multiple workaround steps.

  1. 1Click '+' below the fetch_message step
  2. 2Select 'Run Node.js code'
  3. 3Rename this step to 'parse_message'
  4. 4Add your parsing logic to clean the message text and extract any metadata
What you should see: The step exports should show a cleaned 'taskTitle' string and an optional 'dueDate' string formatted as YYYY-MM-DD, ready to pass to Todoist.
Common mistake — Map fields using the variable picker — don't type field names manually. Hand-typed variable names often have invisible spacing errors that produce blank output.

Paste this into two separate Node.js code steps in Pipedream: the first step (fetch_and_parse) fetches the message text via Slack API and cleans it, the second step creates the Todoist task with full error handling. The deduplication check uses Pipedream's built-in $.data key-value store so you don't need an external database.

JavaScript — Code Step// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
▸ Show code
// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";
export default defineComponent({

... expand to see full code

// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    slack: {
      type: "app",
      app: "slack",
    },
  },
  async run({ steps, $ }) {
    const { channel, ts } = steps.trigger.event.item;
    const reactionUser = steps.trigger.event.user;

    // Deduplication: skip if we've already processed this message+reaction
    const dedupKey = `slack-task-${channel}-${ts}`;
    const alreadySeen = await $.data.get(dedupKey);
    if (alreadySeen) {
      $.flow.exit("Duplicate reaction — task already created for this message.");
    }

    // Fetch the original message text from Slack
    const historyResp = await axios($, {
      url: "https://slack.com/api/conversations.history",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: {
        channel,
        latest: ts,
        inclusive: true,
        limit: 1,
      },
    });

    if (!historyResp.ok) {
      throw new Error(`Slack API error: ${historyResp.error}`);
    }

    const message = historyResp.messages?.[0];
    if (!message) throw new Error("Message not found at timestamp.");

    // Resolve the reacting user's display name
    const userResp = await axios($, {
      url: "https://slack.com/api/users.info",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: { user: message.user || reactionUser },
    });
    const senderName = userResp.user?.profile?.display_name || "Unknown";

    // Clean Slack mention syntax: <@U012AB3CD> → @username
    let cleanText = message.text.replace(/<@([A-Z0-9]+)>/g, (match, uid) => `@${uid}`);
    // Strip channel references like <#C012ABC|general> → #general
    cleanText = cleanText.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
    // Strip URLs wrapped in angle brackets
    cleanText = cleanText.replace(/<(https?:\/\/[^|>]+)(?:\|[^>]+)?>/g, "$1");

    // Extract due date if message contains 'due: <day>'
    const dueDateMatch = cleanText.match(/due:\s*(\w+)/i);
    let dueString = null;
    if (dueDateMatch) {
      // Basic day-name to date — extend this for production
      const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
      const targetDay = days.indexOf(dueDateMatch[1].toLowerCase());
      if (targetDay !== -1) {
        const now = new Date();
        const diff = (targetDay - now.getDay() + 7) % 7 || 7;
        const due = new Date(now);
        due.setDate(now.getDate() + diff);
        dueString = due.toISOString().split("T")[0];
      }
      // Remove the 'due: Thursday' fragment from task title
      cleanText = cleanText.replace(/due:\s*\w+/i, "").trim();
    }

    // Build Slack permalink
    const tsForUrl = ts.replace(".", "");
    const threadUrl = `https://slack.com/archives/${channel}/p${tsForUrl}`;

    // Limit task title to 140 chars
    const taskTitle = cleanText.length > 140 ? cleanText.substring(0, 137) + "..." : cleanText;

    // Mark as processed to prevent duplicate tasks
    await $.data.set(dedupKey, true);

    return { taskTitle, dueString, senderName, threadUrl, channel };
  },
});

// ── STEP 2: create_todoist_task ──────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    todoist: {
      type: "app",
      app: "todoist",
    },
  },
  async run({ steps, $ }) {
    const { taskTitle, dueString, senderName, threadUrl, channel } = steps.fetch_and_parse.$return_value;

    // Route to different Todoist projects based on Slack channel
    const channelProjectMap = {
      "C05XABCDE": 2312345678,   // #product-feedback → Product project
      "C02XBUGFIX": 2398765432,  // #bugs → Engineering project
    };
    const projectId = channelProjectMap[channel] || null; // null = Inbox

    const payload = {
      content: taskTitle,
      description: `Source: ${threadUrl}\nFrom: ${senderName}`,
      priority: 2,
      ...(dueString && { due_date: dueString }),
      ...(projectId && { project_id: projectId }),
      labels: ["slack"],
    };

    let taskResponse;
    try {
      taskResponse = await axios($, {
        method: "POST",
        url: "https://api.todoist.com/rest/v2/tasks",
        headers: {
          Authorization: `Bearer ${this.todoist.$auth.oauth_access_token}`,
          "Content-Type": "application/json",
        },
        data: payload,
      });
    } catch (err) {
      // Log full error for debugging, then rethrow so Pipedream marks the run failed
      console.error("Todoist API error:", err.response?.data || err.message);
      throw new Error(`Failed to create Todoist task: ${err.response?.data?.error || err.message}`);
    }

    console.log(`Task created: ${taskResponse.id} — "${taskTitle}"`);
    return { taskId: taskResponse.id, taskUrl: taskResponse.url };
  },
});
6

Workflow Canvas > + > Todoist > Create Task

Add a Todoist step to create the task

Click '+' below your parse_message step and search for 'Todoist'. Select the 'Create Task' action. Connect your Todoist account via OAuth when prompted. You'll see fields for Content, Due Date, Project, Priority, and Description. Map these from your previous code step outputs using the reference syntax {{steps.parse_message.$return_value.taskTitle}}.

  1. 1Click '+' and search for 'Todoist'
  2. 2Select 'Create Task' from the action list
  3. 3Click 'Connect Todoist' and authorize via OAuth
  4. 4In the Content field, click the reference icon and select steps.parse_message.$return_value.taskTitle
  5. 5In the Description field, paste the Slack thread URL using steps.fetch_message.$return_value.threadUrl
What you should see: You should see all Todoist fields populated with references to your upstream step data. The step preview panel will show the exact values that will be sent.
Common mistake — Todoist's free plan only allows tasks in the Inbox project. If you're routing to a specific project by name, you need the Project ID (a number), not the project name string. The Todoist API does not accept project names.
7

Todoist Step Config > Description field

Map the Slack thread URL into the task description

Todoist tasks are useless without context. Construct a direct Slack message link using the channel ID and timestamp from the trigger event. The URL format is https://yourworkspace.slack.com/archives/{channel_id}/p{timestamp_without_dot}. Include this in the Todoist Description field so anyone clicking the task can jump straight to the Slack thread.

  1. 1In the Todoist Create Task step, click the Description field
  2. 2Type 'Source: ' then click the reference icon
  3. 3Navigate to steps.fetch_message.$return_value.threadUrl
  4. 4Also add the original sender's Slack display name for attribution
What you should see: The Description field preview should show a fully formed Slack URL that opens the correct message when clicked.
Common mistake — Slack timestamps use a dot (e.g. 1698765432.000100). To build a valid permalink URL, remove the dot before appending it to /p — so 1698765432.000100 becomes p1698765432000100.

Paste this into two separate Node.js code steps in Pipedream: the first step (fetch_and_parse) fetches the message text via Slack API and cleans it, the second step creates the Todoist task with full error handling. The deduplication check uses Pipedream's built-in $.data key-value store so you don't need an external database.

JavaScript — Code Step// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
▸ Show code
// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";
export default defineComponent({

... expand to see full code

// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    slack: {
      type: "app",
      app: "slack",
    },
  },
  async run({ steps, $ }) {
    const { channel, ts } = steps.trigger.event.item;
    const reactionUser = steps.trigger.event.user;

    // Deduplication: skip if we've already processed this message+reaction
    const dedupKey = `slack-task-${channel}-${ts}`;
    const alreadySeen = await $.data.get(dedupKey);
    if (alreadySeen) {
      $.flow.exit("Duplicate reaction — task already created for this message.");
    }

    // Fetch the original message text from Slack
    const historyResp = await axios($, {
      url: "https://slack.com/api/conversations.history",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: {
        channel,
        latest: ts,
        inclusive: true,
        limit: 1,
      },
    });

    if (!historyResp.ok) {
      throw new Error(`Slack API error: ${historyResp.error}`);
    }

    const message = historyResp.messages?.[0];
    if (!message) throw new Error("Message not found at timestamp.");

    // Resolve the reacting user's display name
    const userResp = await axios($, {
      url: "https://slack.com/api/users.info",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: { user: message.user || reactionUser },
    });
    const senderName = userResp.user?.profile?.display_name || "Unknown";

    // Clean Slack mention syntax: <@U012AB3CD> → @username
    let cleanText = message.text.replace(/<@([A-Z0-9]+)>/g, (match, uid) => `@${uid}`);
    // Strip channel references like <#C012ABC|general> → #general
    cleanText = cleanText.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
    // Strip URLs wrapped in angle brackets
    cleanText = cleanText.replace(/<(https?:\/\/[^|>]+)(?:\|[^>]+)?>/g, "$1");

    // Extract due date if message contains 'due: <day>'
    const dueDateMatch = cleanText.match(/due:\s*(\w+)/i);
    let dueString = null;
    if (dueDateMatch) {
      // Basic day-name to date — extend this for production
      const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
      const targetDay = days.indexOf(dueDateMatch[1].toLowerCase());
      if (targetDay !== -1) {
        const now = new Date();
        const diff = (targetDay - now.getDay() + 7) % 7 || 7;
        const due = new Date(now);
        due.setDate(now.getDate() + diff);
        dueString = due.toISOString().split("T")[0];
      }
      // Remove the 'due: Thursday' fragment from task title
      cleanText = cleanText.replace(/due:\s*\w+/i, "").trim();
    }

    // Build Slack permalink
    const tsForUrl = ts.replace(".", "");
    const threadUrl = `https://slack.com/archives/${channel}/p${tsForUrl}`;

    // Limit task title to 140 chars
    const taskTitle = cleanText.length > 140 ? cleanText.substring(0, 137) + "..." : cleanText;

    // Mark as processed to prevent duplicate tasks
    await $.data.set(dedupKey, true);

    return { taskTitle, dueString, senderName, threadUrl, channel };
  },
});

// ── STEP 2: create_todoist_task ──────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    todoist: {
      type: "app",
      app: "todoist",
    },
  },
  async run({ steps, $ }) {
    const { taskTitle, dueString, senderName, threadUrl, channel } = steps.fetch_and_parse.$return_value;

    // Route to different Todoist projects based on Slack channel
    const channelProjectMap = {
      "C05XABCDE": 2312345678,   // #product-feedback → Product project
      "C02XBUGFIX": 2398765432,  // #bugs → Engineering project
    };
    const projectId = channelProjectMap[channel] || null; // null = Inbox

    const payload = {
      content: taskTitle,
      description: `Source: ${threadUrl}\nFrom: ${senderName}`,
      priority: 2,
      ...(dueString && { due_date: dueString }),
      ...(projectId && { project_id: projectId }),
      labels: ["slack"],
    };

    let taskResponse;
    try {
      taskResponse = await axios($, {
        method: "POST",
        url: "https://api.todoist.com/rest/v2/tasks",
        headers: {
          Authorization: `Bearer ${this.todoist.$auth.oauth_access_token}`,
          "Content-Type": "application/json",
        },
        data: payload,
      });
    } catch (err) {
      // Log full error for debugging, then rethrow so Pipedream marks the run failed
      console.error("Todoist API error:", err.response?.data || err.message);
      throw new Error(`Failed to create Todoist task: ${err.response?.data?.error || err.message}`);
    }

    console.log(`Task created: ${taskResponse.id} — "${taskTitle}"`);
    return { taskId: taskResponse.id, taskUrl: taskResponse.url };
  },
});
Slack fields
text
user
channel
ts
thread_ts
available as variables:
1.props.text
1.props.user
1.props.channel
1.props.ts
1.props.thread_ts
8

Workflow Canvas > + > Run Node.js code (error handler)

Add error handling with a code step

Wrap your Todoist step in a try/catch or add a dedicated error step using Pipedream's $.flow.exit() helper. If the Todoist API call fails (rate limit, bad token), you want the workflow to log the failure and optionally send a Slack DM to the person who added the reaction so they know the task wasn't created. Without this, failures are silent.

  1. 1Click '+' after the Todoist step
  2. 2Select 'Run Node.js code'
  3. 3Rename the step to 'error_handler'
  4. 4Add logic to check if the Todoist step succeeded and send a Slack DM on failure
What you should see: When you manually trigger a failure (e.g. by temporarily revoking the Todoist token), you should see a Slack DM sent to the reacting user within 5 seconds.

Paste this into two separate Node.js code steps in Pipedream: the first step (fetch_and_parse) fetches the message text via Slack API and cleans it, the second step creates the Todoist task with full error handling. The deduplication check uses Pipedream's built-in $.data key-value store so you don't need an external database.

JavaScript — Code Step// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
▸ Show code
// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";
export default defineComponent({

... expand to see full code

// ── STEP 1: fetch_and_parse ──────────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    slack: {
      type: "app",
      app: "slack",
    },
  },
  async run({ steps, $ }) {
    const { channel, ts } = steps.trigger.event.item;
    const reactionUser = steps.trigger.event.user;

    // Deduplication: skip if we've already processed this message+reaction
    const dedupKey = `slack-task-${channel}-${ts}`;
    const alreadySeen = await $.data.get(dedupKey);
    if (alreadySeen) {
      $.flow.exit("Duplicate reaction — task already created for this message.");
    }

    // Fetch the original message text from Slack
    const historyResp = await axios($, {
      url: "https://slack.com/api/conversations.history",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: {
        channel,
        latest: ts,
        inclusive: true,
        limit: 1,
      },
    });

    if (!historyResp.ok) {
      throw new Error(`Slack API error: ${historyResp.error}`);
    }

    const message = historyResp.messages?.[0];
    if (!message) throw new Error("Message not found at timestamp.");

    // Resolve the reacting user's display name
    const userResp = await axios($, {
      url: "https://slack.com/api/users.info",
      headers: {
        Authorization: `Bearer ${this.slack.$auth.oauth_access_token}`,
      },
      params: { user: message.user || reactionUser },
    });
    const senderName = userResp.user?.profile?.display_name || "Unknown";

    // Clean Slack mention syntax: <@U012AB3CD> → @username
    let cleanText = message.text.replace(/<@([A-Z0-9]+)>/g, (match, uid) => `@${uid}`);
    // Strip channel references like <#C012ABC|general> → #general
    cleanText = cleanText.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
    // Strip URLs wrapped in angle brackets
    cleanText = cleanText.replace(/<(https?:\/\/[^|>]+)(?:\|[^>]+)?>/g, "$1");

    // Extract due date if message contains 'due: <day>'
    const dueDateMatch = cleanText.match(/due:\s*(\w+)/i);
    let dueString = null;
    if (dueDateMatch) {
      // Basic day-name to date — extend this for production
      const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
      const targetDay = days.indexOf(dueDateMatch[1].toLowerCase());
      if (targetDay !== -1) {
        const now = new Date();
        const diff = (targetDay - now.getDay() + 7) % 7 || 7;
        const due = new Date(now);
        due.setDate(now.getDate() + diff);
        dueString = due.toISOString().split("T")[0];
      }
      // Remove the 'due: Thursday' fragment from task title
      cleanText = cleanText.replace(/due:\s*\w+/i, "").trim();
    }

    // Build Slack permalink
    const tsForUrl = ts.replace(".", "");
    const threadUrl = `https://slack.com/archives/${channel}/p${tsForUrl}`;

    // Limit task title to 140 chars
    const taskTitle = cleanText.length > 140 ? cleanText.substring(0, 137) + "..." : cleanText;

    // Mark as processed to prevent duplicate tasks
    await $.data.set(dedupKey, true);

    return { taskTitle, dueString, senderName, threadUrl, channel };
  },
});

// ── STEP 2: create_todoist_task ──────────────────────────────────────
import { axios } from "@pipedream/platform";

export default defineComponent({
  props: {
    todoist: {
      type: "app",
      app: "todoist",
    },
  },
  async run({ steps, $ }) {
    const { taskTitle, dueString, senderName, threadUrl, channel } = steps.fetch_and_parse.$return_value;

    // Route to different Todoist projects based on Slack channel
    const channelProjectMap = {
      "C05XABCDE": 2312345678,   // #product-feedback → Product project
      "C02XBUGFIX": 2398765432,  // #bugs → Engineering project
    };
    const projectId = channelProjectMap[channel] || null; // null = Inbox

    const payload = {
      content: taskTitle,
      description: `Source: ${threadUrl}\nFrom: ${senderName}`,
      priority: 2,
      ...(dueString && { due_date: dueString }),
      ...(projectId && { project_id: projectId }),
      labels: ["slack"],
    };

    let taskResponse;
    try {
      taskResponse = await axios($, {
        method: "POST",
        url: "https://api.todoist.com/rest/v2/tasks",
        headers: {
          Authorization: `Bearer ${this.todoist.$auth.oauth_access_token}`,
          "Content-Type": "application/json",
        },
        data: payload,
      });
    } catch (err) {
      // Log full error for debugging, then rethrow so Pipedream marks the run failed
      console.error("Todoist API error:", err.response?.data || err.message);
      throw new Error(`Failed to create Todoist task: ${err.response?.data?.error || err.message}`);
    }

    console.log(`Task created: ${taskResponse.id} — "${taskTitle}"`);
    return { taskId: taskResponse.id, taskUrl: taskResponse.url };
  },
});
9

pipedream.com > Workflow > Event History

Test end-to-end with a real Slack message

Go to your Slack workspace and react to any message in the monitored channel with your chosen emoji. Switch back to Pipedream and click 'Event History' in the left sidebar to watch for the incoming event. You should see the workflow trigger, the fetch_message step fetch the text, and the Todoist step return a task ID within 3-5 seconds.

  1. 1Open Slack and react to a test message with your configured emoji (e.g. ✅)
  2. 2Switch to Pipedream and click 'Event History' in the left sidebar
  3. 3Click the incoming event to inspect each step's input and output
  4. 4Open Todoist and verify the task appears in the correct project with the Slack link in the description
What you should see: The task should appear in Todoist within 3 seconds of adding the reaction. The task content should match the Slack message text and the description should contain a clickable Slack thread URL.
Common mistake — If you react to a message that is itself a bot message or an app message, Slack may return a different message subtype that lacks a 'user' field. Your fetch step should handle this case or the workflow will throw a undefined property error.
Pipedream
▶ Deploy & test
executed
Slack
Todoist
Todoist
🔔 notification
received
10

Workflow Canvas > Deploy button (top right)

Deploy the workflow

Click the blue 'Deploy' button in the top right of the workflow canvas. Pipedream will activate the Slack event source and start listening for reactions in real time via webhook. The workflow status will change from 'Development' to 'Active'. Any reaction matching your filter from this point forward will trigger the workflow automatically.

  1. 1Click the blue 'Deploy' button in the top right
  2. 2Confirm the deployment in the dialog that appears
  3. 3Check the workflow status badge — it should read 'Active' in green
  4. 4Add a final test reaction in Slack to confirm the live workflow fires correctly
What you should see: The workflow status badge in the top bar should show 'Active' in green. The Event History tab will populate with live events as reactions come in.
Common mistake — Pipedream free plans have a limit of 100 credits/day. Each workflow run costs roughly 1-3 credits depending on step count and duration. Deploy to a paid plan if your team adds reactions frequently throughout the day.

Going live

Production Checklist

Before you turn this on for real, confirm each item.

Troubleshooting

Common errors and how to fix them.

Frequently Asked Questions

Common questions about this workflow.

Analysis

VerdictWhy n8n for this workflow

Use Pipedream for this if your team needs custom logic that no-code tools can't handle: parsing due dates out of message text, routing to different Todoist projects based on channel, resolving Slack user IDs to names, or deduplicating reactions from multiple users. Pipedream processes the Slack webhook in under a second and lets you write that logic in real Node.js without workarounds. The one case where you'd skip Pipedream: if your team is non-technical and just needs emoji → task with no customization. Zapier's native Slack + Todoist integration does that in 10 minutes flat.

Cost

Pipedream's free tier gives you 100 credits/day. This workflow runs 3-4 steps per trigger, costing roughly 3-4 credits per run. That's about 25-33 task creations per day before you hit the free limit. At the Basic paid plan ($19/month), you get 10,000 credits/month — enough for roughly 2,500-3,300 task creations per month, which covers most teams comfortably. Zapier's equivalent workflow costs $19.99/month for 750 tasks. Make's free tier handles 1,000 operations/month with the same workflow logic, which beats Pipedream's free tier if volume is your only concern.

Tradeoffs

Zapier handles the basic reaction-to-task trigger faster to set up — their native integration has both apps pre-connected and the field mapping is visual. Make has better conditional branching in the UI if you want to route to different projects without writing code. n8n's self-hosted version has zero per-run cost if you're running it on your own server, which matters at scale. Power Automate's Slack connector is still rough — reaction triggers are unreliable as of early 2024 and not worth the debugging time. Pipedream wins here specifically because the Slack event source is solid, the webhook fires instantly, and the Node.js steps mean you can handle every edge case (thread replies, bot messages, mention resolution) in one place without stitching together five workaround steps.

Three things you'll run into after you deploy. First, Slack's conversations.history API has a rate limit of 50 requests per minute per token — in a busy workspace where many reactions fire at once, you'll hit it. Add exponential backoff in your fetch step or the whole workflow fails silently. Second, Todoist's REST API v2 returns a 429 Too Many Requests if you fire more than 450 requests per 15 minutes — this rarely happens for task creation specifically, but if you're also fetching collaborators for assignee lookup, the calls add up. Third, Slack message text with block kit formatting (the newer rich text format) doesn't come through as plain text in the message.text field — it comes in blocks[]. If your team uses newer Slack clients, your text extraction needs to handle both formats or you'll get empty task titles.

Ideas for what to build next

  • Add a Todoist comment with the full Slack threadAfter task creation, fetch all replies in the Slack thread using conversations.replies and post them as a single Todoist task comment so assignees have full context without opening Slack.
  • Route tasks to different assignees by channelExtend the channel-to-project map to also set a Todoist assignee_id based on which Slack channel the message came from — so #design reactions always assign to the design lead's Todoist account.
  • Post a Slack confirmation message when the task is createdAfter the Todoist task is created, use the Slack API to post a threaded reply on the original message confirming 'Task created in Todoist: [link]' — closes the loop for the team without anyone leaving Slack.

Related guides

Was this guide helpful?
Slack + Todoist overviewPipedream profile →