How to Automate Weekly KPI Reports with n8n
The Problem
Every Monday someone opens a spreadsheet, copies this week's numbers next to last week's, calculates the changes by hand, formats a table, and emails it to leadership. It takes an hour, it is easy to get wrong, and the one number that actually matters, such as churn creeping up, is buried in a wall of figures. This n8n workflow reads the metrics from Google Sheets, calculates absolute and percentage change, flags anything that crossed a threshold, builds a color coded HTML report, and delivers it to both email and Slack before the first meeting of the week.
What You Will Need
- A self-hosted n8n instance (Community Edition is free) or an n8n Cloud trial
- A Google account with a spreadsheet that has a Current tab and a History tab, SMTP credentials, and a Slack app with access to #metrics
- Every step is a core n8n node, and the n8n Code nodes use plain JavaScript only.
- Estimated setup time: 45 minutes
- A consistent layout: one row per metric with columns metric, value, and week_start
Step-by-Step Workflow Setup
1 Schedule the Monday run
The n8n Schedule Trigger uses the workflow timezone, so set it once in n8n and every later date expression in n8n follows it.
The Schedule Trigger node starts the report at 8:00 every Monday, early enough for leadership to read it before standup.
Add a Schedule Trigger node and configure:
Node: Schedule Trigger
Trigger Interval: Weeks
Weeks Between Triggers: 1
Trigger on Weekdays: Monday
Trigger at Hour: 8am
Trigger at Minute: 0
Make sure the workflow timezone matches your office. You can also compute the report week label later from the trigger time with this expression:
Expression: {{ $now.minus({ days: 7 }).startOf('week').toFormat('yyyy-MM-dd') }}
{
"timestamp": "2026-11-09T08:00:00.000-05:00",
"Day of week": "Monday"
}
2 Read the current week metrics
The n8n Google Sheets node handles OAuth for you, and n8n returns each row as a separate item for the next node.
Add a Google Sheets node that returns every row on the Current tab. Whoever maintains the data, or an upstream n8n workflow, writes the five metrics here.
Node: Google Sheets
Credential: Google Sheets OAuth2
Resource: Sheet Within Document
Operation: Get Row(s)
Document: Weekly KPIs
Sheet: Current
Return All: on
Options > Data Location on Sheet: header row 1
[
{ "metric": "revenue", "value": "48200", "week_start": "2026-11-02" },
{ "metric": "signups", "value": "312", "week_start": "2026-11-02" },
{ "metric": "churn", "value": "2.4", "week_start": "2026-11-02" },
{ "metric": "nps", "value": "41", "week_start": "2026-11-02" },
{ "metric": "tickets_resolved", "value": "187", "week_start": "2026-11-02" }
]
Google Sheets returns numbers as strings in many cases, so convert them with Number() in the Code step rather than trusting the type.
3 Read the previous week from history
Filtering inside the n8n Google Sheets node keeps the data small, which makes the later n8n Code node faster and simpler.
Use a second Google Sheets node on the History tab. Filter to last week's start date so you pull exactly five rows.
Node: Google Sheets
Operation: Get Row(s)
Sheet: History
Filters:
Column: week_start
Value: {{ $now.minus({ days: 7 }).startOf('week').toFormat('yyyy-MM-dd') }}
Return All: on
At the end of the workflow, or in a separate small n8n workflow, append the current rows to History so next week has something to compare against. Without this step the report only works once.
[
{ "metric": "revenue", "value": "44900", "week_start": "2026-10-26" },
{ "metric": "churn", "value": "2.1", "week_start": "2026-10-26" }
]
4 Calculate week-over-week change
Because n8n passes items between nodes as JSON, the join by metric name is a plain JavaScript lookup inside one n8n Code node.
A Code node joins current and previous rows by metric name, computes the absolute delta and percentage change, and applies a threshold. Churn is a lower-is-better metric, so the rule direction is stored per metric.
Node: Code
Mode: Run Once for All Items
Language: JavaScript
const cur = $('Current Sheet').all().map(i => i.json);
const prev = Object.fromEntries($('History Sheet').all().map(i => [i.json.metric, Number(i.json.value)]));
const rules = {
revenue: { good: 'up', limit: 10 },
signups: { good: 'up', limit: 15 },
churn: { good: 'down', limit: 10 },
nps: { good: 'up', limit: 10 },
tickets_resolved: { good: 'up', limit: 20 }
};
const rows = cur.map(r => {
const now = Number(r.value);
const before = prev[r.metric];
const delta = now - before;
const pct = before === 0 ? 0 : (delta / before) * 100;
const rule = rules[r.metric];
const bad = rule.good === 'up' ? pct < -rule.limit : pct > rule.limit;
const improving = rule.good === 'up' ? delta >= 0 : delta <= 0;
return { metric: r.metric, now, before, delta, pct: Math.round(pct * 10) / 10,
status: bad ? 'flag' : improving ? 'good' : 'watch' };
});
return [{ json: { rows, flagged: rows.filter(r => r.status === 'flag') } }];
The percentage formula is (current minus previous) divided by previous, times 100. Guarding against a previous value of zero avoids a divide by zero result of Infinity.
{
"rows": [
{ "metric": "revenue", "now": 48200, "before": 44900, "delta": 3300, "pct": 7.3, "status": "good" },
{ "metric": "churn", "now": 2.4, "before": 2.1, "delta": 0.3, "pct": 14.3, "status": "flag" }
],
"flagged": [{ "metric": "churn", "pct": 14.3 }]
}
5 Build the HTML report
An n8n Code node can return any string, so building HTML there needs no extra n8n templating service.
Another Code node renders the table. Email clients ignore most stylesheets, so use inline styles. Green marks improving metrics, red marks flagged ones, and amber covers movement in the wrong direction that stays under the limit.
Node: Code
Mode: Run Once for All Items
const { rows, flagged } = $input.first().json;
const color = { good: '#15803d', flag: '#b91c1c', watch: '#b45309' };
const sign = (n) => (n > 0 ? '+' : '') + n;
const tr = rows.map(r => `<tr><td>${r.metric}</td><td>${r.now}</td><td>${r.before}</td>
<td style="color:${color[r.status]}">${sign(r.delta)} (${sign(r.pct)}%)</td>
<td style="color:${color[r.status]};font-weight:bold">${r.status}</td></tr>`).join('');
const summary = flagged.length
? `${flagged.length} metric(s) need attention: ${flagged.map(f => f.metric).join(', ')}.`
: 'No metric crossed its alert threshold this week.';
const html = `<h2>Weekly KPI report</h2><p>${summary}</p>
<table border="1" cellpadding="6" style="border-collapse:collapse">
<tr><th>Metric</th><th>Current</th><th>Previous</th><th>Change</th><th>Status</th></tr>${tr}</table>`;
return [{ json: { html, summary, rows, flagged } }];
{
"summary": "1 metric(s) need attention: churn.",
"html": "<h2>Weekly KPI report</h2>..."
}
6 Email the leadership list
The n8n Send Email node accepts HTML directly, so the output of the previous n8n node can be passed in without conversion.
The Send Email node delivers the HTML body through SMTP.
Node: Send Email
Credential: SMTP account
From Email: reports@yourcompany.example
To Email: leadership@yourcompany.example
Subject: Weekly KPI report, week of {{ $now.minus({ days: 7 }).startOf('week').toFormat('LLL d') }}
Email Format: HTML
HTML: {{ $json.html }}
Use a distribution list address instead of a long list of individual recipients, so membership changes never require editing the workflow.
7 Post the summary to Slack
Run the n8n workflow manually once and check the Slack output in n8n before the first scheduled Monday.
Slack cannot render HTML tables, so send a short plain version: the top-line numbers and the flagged metrics. Build the text with an expression in a Slack node.
Node: Slack
Resource: Message
Operation: Send
Channel: #metrics
Text: *Weekly KPIs*
Revenue: {{ $json.rows[0].now }} ({{ $json.rows[0].pct }}%)
Signups: {{ $json.rows[1].now }} ({{ $json.rows[1].pct }}%)
{{ $json.summary }}
Search the rows array by metric name instead of relying on position if the sheet order may change.
Troubleshooting
The percentage change shows NaN
One of the values is empty or contains a currency symbol or comma. Strip them before conversion with Number(String(v).replace(/[^0-9.-]/g, '')) and confirm the metric names match exactly between tabs. Pin the sheet output in n8n and inspect the raw values in the n8n output panel.
The email arrives with raw HTML tags visible
The Email Format is set to Text. Switch it to HTML, or to Both if you want a plain text fallback for strict mail clients. Check the n8n Send Email node options, since the format setting lives there.
Last week's rows come back empty
The week_start filter did not match, usually because Sheets stored the date as a formatted date rather than text, or because the history append step did not run. Use the same format in both places. Run the n8n Google Sheets node alone to see what it returns for that date.
Free n8n Workflow Resources
Get started with the free workflow pack on the LightCube Labs templates page.
Production-Ready Workflow Packs
Get this workflow and more, with setup guides, editable configurations, and a shared failure-alert system.
View the Starter ($29) and Pro ($99) PacksFrequently Asked Questions
How do I choose alert thresholds for each KPI?
Start from normal weekly variation. Look at the last eight weeks of each metric and set the limit just above the typical swing. Revenue might tolerate 10 percent, while churn deserves a tighter limit because small moves are costly. You can adjust each limit inside the n8n Code node in seconds.
Why store the good direction per metric?
A rise in churn is bad and a rise in signups is good. Encoding direction in the rules object lets one formula serve every metric and keeps the red and green colors honest. That logic lives in one n8n Code node, so it is easy to audit.
Can this n8n workflow pull numbers directly from a database instead of Sheets?
Yes. Replace the Google Sheets nodes with Postgres or MySQL nodes running a query that returns metric and value columns. The Code nodes only need the same item shape. n8n has native Postgres and MySQL nodes for this.
What if Slack and email disagree on the numbers?
They cannot, because both nodes read from the same Code node output. Build the data once and format it twice, which is the main reason for the dual delivery design. Both n8n nodes read the same upstream item.