Automation

n8n Client Reporting Workflow: ClickUp, Toggl and Gmail

A step-by-step n8n workflow that reads active clients from Notion, pulls completed ClickUp tasks and Toggl time entries, builds an HTML summary and leaves a Gmail draft per client for review before sending.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
9 min read

A weekly client report usually means the same routine for every client: open the project tracker, count what was finished, open the time tracker, total the hours, then paste both into an email. This guide builds that routine as one n8n workflow. It reads the list of active clients from Notion, fetches completed tasks from the ClickUp API and time entries from the Toggl Track API, builds an HTML summary in a Code node and leaves one Gmail draft per client, so a person still reads every report before it goes out.

Every node and option named below is taken from the n8n documentation, linked where it is used. The ClickUp and Toggl calls follow those services' own API references.


What the workflow does#

  1. Runs every Friday afternoon on a Schedule Trigger.
  2. Fetches the week's Toggl time entries once.
  3. Reads the client list from a Notion database and keeps only active clients.
  4. For each client, fetches tasks completed in the last seven days from that client's ClickUp list.
  5. Totals hours and groups tasks per client in a Code node.
  6. Builds an HTML email in a second Code node.
  7. Creates a Gmail draft per client, then archives the figures to a second Notion database.

The order matters. The Toggl request returns every entry for the authenticated user, not one client's entries, so it runs once at the top and each client's report filters it by project. ClickUp, by contrast, is queried per client.


Keep the client list outside the workflow#

Hardcoding clients as a JSON array inside a Code node works until a client changes address or pauses work, and every change then means editing the workflow. A small Notion database is easier to maintain. Give it these properties:

  • Client name
  • Email address
  • ClickUp list ID
  • Toggl project ID
  • Status (active, paused, finished)

The Notion node reads it with Database Page → Get Many. Notion returns properties under names derived from your database, so add an Edit Fields (Set) node in Manual Mapping mode, named Client fields, to give every client the same flat shape: name, email, clickupListId, togglProjectId and status. The rest of the workflow only reads those five fields.

Then add an If node with the String condition {{ $json.status }} is equal to active, and continue from its true output. Adding a client now means adding a row in Notion; the workflow itself stays the same.


Building the workflow#

Step 1: the trigger#

In the Schedule Trigger, set Trigger Interval to Weeks, then:

  • Weeks Between Triggers: 1
  • Trigger on Weekdays: Friday
  • Trigger at Hour: 4pm
  • Trigger at Minute: 0

The node's documentation states it uses the workflow timezone if one is set, otherwise the instance timezone, which defaults to America/New York on self-hosted instances. Set the workflow timezone in the workflow settings (for example Europe/London) or the report will arrive at the wrong hour. The workflow must also be saved and published for the schedule to run.

Step 2: fetch the week's Toggl entries once#

n8n has a Toggl Trigger but no Toggl action node, so use the HTTP Request node. Name it Toggl Entries:

text
Method: GET
URL: https://api.track.toggl.com/api/v9/me/time_entries
Authentication: Generic Credential Type → Basic Auth
  User: <your Toggl API token>
  Password: api_token
Send Query Parameters: on
  start_date: {{ $today.minus({ days: 7 }).toISO() }}
  end_date:   {{ $now.toISO() }}

The Toggl Track authentication reference says that with Basic Auth and an API token, the token goes in the user field and the literal string api_token in the password field. The time entries reference lists start_date and end_date as optional string query parameters on GET /me/time_entries; check the accepted date format there before you rely on the ISO strings above. $now and $today are n8n's Luxon variables, described in Work with dates and times.

Next, add a Code node named Pack Toggl in Run Once for All Items mode. It turns however many items the request produced into a single item, so the Notion node after it runs once:

javascript
const entries = $input.all().map((item) => item.json);
return [{ json: { entries } }];

Step 3: read the active clients#

Connect Pack Toggl → Notion (Database Page → Get Many on the clients database) → Client fields (Edit Fields) → If (status is active). From here on, each item is one client.

Step 4: fetch completed ClickUp tasks per client#

Add an HTTP Request node named ClickUp Tasks after the If node's true output:

text
Method: GET
URL: https://api.clickup.com/api/v2/list/{{ $json.clickupListId }}/task
Authentication: Predefined Credential Type → ClickUp
Send Query Parameters: on
  include_closed: true
  date_done_gt:   {{ $today.minus({ days: 7 }).toMillis() }}

ClickUp's Get Tasks reference documents date_done_gt as "Unix time in milliseconds" and states that closed tasks are excluded unless include_closed is true, which is why both parameters are set. The HTTP Request documentation recommends the Predefined Credential Type option when n8n already has a credential for the service, as it does for ClickUp.

Because n8n processes every incoming item, this node runs once per active client and returns one response per client, each linked to the client item that produced it.

Step 5: total hours and group tasks#

Add a Code node named Summary in Run Once for Each Item mode:

javascript
// The client this ClickUp response belongs to (paired item)
const client = $('Client fields').item.json;
const tasks = $json.tasks || [];
 
// Pack Toggl ran once, so its first item holds every entry
const entries = $('Pack Toggl').first().json.entries;
 
// Toggl stores duration in seconds; running entries are negative
const clientEntries = entries.filter(
  (e) => e.project_id === Number(client.togglProjectId) && e.duration > 0
);
const totalHours = clientEntries.reduce((sum, e) => sum + e.duration / 3600, 0);
 
// Group completed tasks by the ClickUp list they belong to
const tasksByList = {};
for (const task of tasks) {
  const listName = task.list?.name || 'General';
  (tasksByList[listName] ||= []).push(task.name);
}
 
return {
  json: {
    clientName: client.name,
    clientEmail: client.email,
    weekStart: $today.minus({ days: 7 }).toFormat('d LLLL'),
    weekEnd: $today.toFormat('d LLLL yyyy'),
    totalHours: totalHours.toFixed(1),
    completedTasks: tasks.length,
    tasksByList,
  },
};

Three details make this code work as intended:

  • $('Client fields').item returns the matching item, the client that produced the current ClickUp response, according to the node output reference. Using .first() there would return the first client for every report.
  • .first() is correct for Pack Toggl, because that node outputs a single item.
  • The Toggl reference describes duration in seconds, with a negative value for a running entry, hence the e.duration > 0 filter. The ClickUp task object includes a list object with a name, which the grouping uses.

Step 6: build the HTML email#

A second Code node, also in Run Once for Each Item mode, turns the summary into an email body. Task names come from ClickUp, so escape them before inserting them into HTML:

javascript
const data = $json;
const esc = (s) =>
  String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 
const rows = Object.entries(data.tasksByList)
  .map(([list, names]) => `
    <tr>
      <td style="padding:12px;font-weight:600;border-bottom:1px solid #e5e7eb;">${esc(list)}</td>
      <td style="padding:12px;border-bottom:1px solid #e5e7eb;">
        ${names.map((n) => `&bull; ${esc(n)}`).join('<br>')}
      </td>
    </tr>`)
  .join('');
 
const html = `
<!DOCTYPE html>
<html>
<body style="font-family:-apple-system,'Segoe UI',sans-serif;max-width:600px;margin:0 auto;padding:20px;">
  <p style="margin:0;font-size:13px;color:#6b7280;">Weekly report</p>
  <h1 style="margin:4px 0 0;font-size:22px;">${esc(data.clientName)}</h1>
  <p style="margin:4px 0 24px;color:#6b7280;">${data.weekStart} – ${data.weekEnd}</p>
  <p>Hours logged: <strong>${data.totalHours}</strong></p>
  <p>Tasks completed: <strong>${data.completedTasks}</strong></p>
  <table style="width:100%;border-collapse:collapse;">${rows}</table>
</body>
</html>`;
 
return { json: { ...data, emailHtml: html } };

Step 7: a Gmail draft, not a sent email#

The Gmail node's draft operations include Create, with Subject, Email Type (Text or HTML) and Message parameters, and the recipient set under the To Email option:

text
Resource: Draft
Operation: Create
Subject: Weekly update – {{ $json.weekStart }} to {{ $json.weekEnd }}
Email Type: HTML
Message: {{ $json.emailHtml }}
Options → To Email: {{ $json.clientEmail }}

A draft keeps a person between the automation and the client: the workflow assembles data and formatting, and someone reads each report, adds context if needed and sends it. To send directly instead, the Gmail node's Message resource has a Send operation with the same Email Type choice.

Step 8: archive the figures#

Finish with a second Notion node using Database Page → Create on a "Weekly reports" database, mapping client name, week, hours and task count into its properties. That gives a searchable history of every report the workflow produced, which helps when a client later asks what was delivered in a given week.


Testing before you publish#

  • Run the workflow manually first. Manual executions show the data at each node in the editor; production executions appear in the workflow's Executions list, as described in Understand executions.
  • Point the Gmail node at your own address while testing, then switch the To Email expression back.
  • Check one client's hours against the Toggl app for the same date range before trusting the totals.

Further reading: 18 n8n Templates for Freelancers & Solopreneurs (2026), n8n Beginner Guide 2026: Build Your First Automation, Migrating from Zapier to n8n: Docker Setup and Zap Mapping.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.