How to Automate Customer Feedback Analysis with n8n

By The LightCube Labs team

The Problem

Support tickets pile up faster than anyone can read them. Product managers hear about bugs and feature requests secondhand, patterns hide inside hundreds of individual messages, and nobody notices that complaints about performance doubled last week. Paid analytics tools solve this but add cost and send customer text to a third party. This guide shows how to build the analysis yourself with n8n: a daily job pulls the last 24 hours of tickets, tags each one by topic, scores sentiment with a simple lexicon, logs everything to Google Sheets, and posts a short digest to Slack.

What You Will Need

How the n8n Workflow Fits Together

This n8n workflow has six nodes in a straight line, so it is easy to debug in the n8n editor. A Schedule Trigger starts the run, an HTTP Request node collects tickets, two Code nodes do the analysis, a Google Sheets node keeps the history, and a Slack node delivers the result. Every analysis rule lives in plain JavaScript inside n8n, which means you can read, test and change it without installing a plugin or paying for a classification service.

The design choice worth noting is that n8n never calls a text analysis API. Categorization is a keyword lookup and sentiment is a weighted word list. That keeps the n8n workflow fast, free of per-request cost, and private, because ticket text stays on your n8n server.

Step-by-Step Workflow Setup

1 Schedule the daily run in n8n

Feedback analysis only works as a habit, so the workflow starts with a Schedule Trigger that fires every morning at 9am. Running once per day gives you a stable 24 hour window and one summary per day, which makes the trend comparison in later steps meaningful.

Add an n8n Schedule Trigger node and configure:

Node: Schedule Trigger
Trigger Interval: Days
Days Between Triggers: 1
Trigger at Hour: 9
Trigger at Minute: 0
Timezone: set in Workflow Settings (for example America/Los_Angeles)

Set the timezone in the workflow settings rather than relying on the server clock, otherwise a self-hosted n8n container running in UTC will fire at the wrong local hour. The trigger emits a timestamp object that later nodes use to compute the 24 hour lookback.

Expression: {{ $now.minus({ hours: 24 }).toUTC().toISO() }}
Sample output:
{
  "timestamp": "2026-11-04T09:00:00.000-08:00",
  "Readable date": "November 4th 2026, 9:00:00 am",
  "Day of week": "Wednesday"
}

If you want a weekend break, switch the interval to Weeks and select Monday through Friday. A Monday run should then look back 72 hours, which you can handle with an IF check on $now.weekday.

2 Fetch tickets from the last 24 hours in n8n

Add an HTTP Request node that calls your support platform. This example uses the Zendesk search endpoint; Intercom works the same way with a different URL and body. The query asks only for tickets created since yesterday, so n8n never downloads your whole history.

Add an n8n HTTP Request node and configure:

Node: HTTP Request
Method: GET
URL: https://YOURDOMAIN.zendesk.com/api/v2/search.json
Authentication: Generic Credential Type > Basic Auth
Send Query Parameters: On
  query: type:ticket created>{{ $now.minus({ hours: 24 }).toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") }}
  sort_by: created_at
  sort_order: asc
Options > Pagination: Response Contains Next URL
  Next URL: {{ $response.body.next_page }}

For Basic Auth on Zendesk, the username is you@company.com/token and the password is an API token created in the Admin Center. Pagination matters: Zendesk search returns 100 results per page, and the Next URL expression tells n8n to keep following next_page until it is null.

Expression: {{ $response.body.next_page }}
Sample response:
{
  "results": [
    {
      "id": 48213,
      "subject": "Export to CSV keeps timing out",
      "description": "Every time I export more than 5k rows the page hangs and then crashes. Very slow.",
      "created_at": "2026-11-03T21:14:09Z",
      "requester_id": 90211,
      "tags": ["export", "enterprise"]
    }
  ],
  "next_page": null,
  "count": 1
}

For Intercom, call POST https://api.intercom.io/conversations/search with a JSON body filtering on created_at greater than a Unix timestamp. Set the node to return each page as separate items so the next Code node receives one ticket per item.

3 Categorize tickets with a keyword engine in n8n

The first Code node does the analysis without calling any external service. It lowercases the subject and description, checks them against keyword lists for five categories, and picks the category with the most hits. Because the rules are plain arrays, anyone on the team can tune them without touching the rest of the workflow.

Add an n8n Code node and configure:

Node: Code
Mode: Run Once for All Items
Language: JavaScript

const RULES = {
  bug: ['error','crash','broken','not working','fails','bug','exception'],
  'feature-request': ['wish','would be great','feature','please add','support for','missing'],
  pricing: ['price','pricing','invoice','refund','billing','expensive','discount'],
  UX: ['confusing','hard to find','unclear','layout','navigation','design'],
  performance: ['slow','timeout','timing out','lag','loading','hangs']
};
const out = [];
const rows = $input.all().flatMap(i => i.json.results ? i.json.results : [i.json]);
for (const item of rows) {
  const text = ((item.subject || '') + ' ' + (item.description || '')).toLowerCase();
  let best = 'uncategorized', bestHits = 0;
  for (const [cat, words] of Object.entries(RULES)) {
    const hits = words.filter(w => text.includes(w)).length;
    if (hits > bestHits) { best = cat; bestHits = hits; }
  }
  out.push({ json: { id: item.id, subject: item.subject, category: best, text } });
}
return out;

Ties resolve to whichever category was listed first, so order the object by how much you care about each class. Tickets with zero hits land in uncategorized, which is a useful signal: if that bucket grows, your keyword lists need new words.

Expression: {{ $json.category }}
Sample output:
{
  "id": 48213,
  "subject": "Export to CSV keeps timing out",
  "category": "performance",
  "text": "export to csv keeps timing out every time i export more than 5k rows the page hangs and then crashes. very slow."
}

This ticket mentions both a crash and slowness. It scores three hits for performance (timing out, hangs, slow) against one for bug (crash), so performance wins. If you need multi-label output, return the full hit map instead of only the best category.

4 Score sentiment without an external API in n8n

Sentiment scoring in a second n8n Code node uses a small weighted lexicon. Negative words subtract, positive words add, and intensifiers such as "very" or "constantly" multiply the next word. The result is a number between -1 and 1 that is crude but consistent, and consistency is what a trend line needs.

Add an n8n Code node and configure:

Node: Code (Sentiment)
Mode: Run Once for All Items
Language: JavaScript

const POS = { thanks: 1, great: 2, love: 2, helpful: 1, works: 1, awesome: 2 };
const NEG = { slow: -1, crash: -2, broken: -2, hate: -3, frustrating: -2, useless: -3, confusing: -1 };
const BOOST = ['very','extremely','constantly','always','totally'];
return $input.all().map(item => {
  const tokens = item.json.text.split(/[^a-z']+/);
  let score = 0;
  tokens.forEach((t, i) => {
    const w = POS[t] || NEG[t] || 0;
    const mult = BOOST.includes(tokens[i - 1]) ? 1.5 : 1;
    score += w * mult;
  });
  const sentiment = Math.max(-1, Math.min(1, score / 5));
  return { json: { ...item.json, sentiment: Number(sentiment.toFixed(2)) } };
});

Dividing by 5 and clamping keeps long rants from dominating the average. The lexicon is intentionally small; add domain words such as "downtime" or "outage" as negative terms once you see them in real tickets.

Expression: {{ $json.sentiment < -0.3 ? "negative" : $json.sentiment > 0.3 ? "positive" : "neutral" }}
Sample output:
{
  "id": 48213,
  "category": "performance",
  "sentiment": -0.6
}

No lexicon understands sarcasm, so treat this as a directional signal. If a single ticket matters, the Google Sheet in the next step gives a human reviewer the raw text next to the score.

5 Append every ticket to Google Sheets in n8n

The n8n Google Sheets node stores each classified ticket as a row. This sheet is your history, and it is what makes trend tracking possible: tomorrow morning the n8n workflow reads the last seven days of rows to compare against today.

Add an n8n Google Sheets node and configure:

Node: Google Sheets
Resource: Sheet Within Document
Operation: Append Row
Document: By URL (paste your sheet URL)
Sheet: Tickets
Mapping Column Mode: Map Each Column Manually
  date: {{ $now.toFormat("yyyy-MM-dd") }}
  ticket_id: {{ $json.id }}
  subject: {{ $json.subject }}
  category: {{ $json.category }}
  sentiment: {{ $json.sentiment }}

Create the header row (date, ticket_id, subject, category, sentiment) before the first run, since the node maps by header name. Use the Google Sheets OAuth2 credential in n8n and share the sheet with that Google account.

Expression: {{ $now.toFormat("yyyy-MM-dd") }}
Sample row written:
{
  "date": "2026-11-04",
  "ticket_id": 48213,
  "subject": "Export to CSV keeps timing out",
  "category": "performance",
  "sentiment": -0.6
}

To avoid duplicates if you rerun the workflow, switch the operation to Append or Update Row and set ticket_id as the column to match on.

6 Build the daily summary and trend in n8n

A third n8n Code node collapses all items into one summary object. It counts tickets per category, finds the three most negative subjects, and computes the average sentiment. For the trend it reads yesterday's average from workflow static data, so no extra sheet lookup is needed.

Add an n8n Code node and configure:

Node: Code (Summary)
Mode: Run Once for All Items
Language: JavaScript

const items = $input.all().map(i => i.json);
const counts = {};
items.forEach(t => { counts[t.category] = (counts[t.category] || 0) + 1; });
const avg = items.reduce((s, t) => s + t.sentiment, 0) / (items.length || 1);
const worst = [...items].sort((a, b) => a.sentiment - b.sentiment).slice(0, 3);
const store = $getWorkflowStaticData('global');
const prev = typeof store.lastAvg === 'number' ? store.lastAvg : avg;
store.lastAvg = avg;
const delta = avg - prev;
return [{ json: { total: items.length, counts, avg: Number(avg.toFixed(2)),
  delta: Number(delta.toFixed(2)), worst: worst.map(t => t.subject) } }];

Static data persists between production executions, which is exactly what a day over day delta needs. Note that static data is not saved during manual test runs, so test the trend logic by activating the workflow.

Expression: {{ $json.delta > 0 ? "improving" : "worsening" }}
Sample output:
{
  "total": 37,
  "counts": { "bug": 9, "feature-request": 11, "pricing": 4, "UX": 6, "performance": 7 },
  "avg": -0.12,
  "delta": -0.08,
  "worst": ["Export to CSV keeps timing out", "Billing page crashes", "Cannot find SSO settings"]
}

Daily deltas are noisy on low volume. If you receive fewer than ten tickets a day, store a rolling seven day array in static data and compare against its mean instead.

7 Post the summary to Slack in n8n

The final n8n Slack node sends the summary to the #product-feedback channel so product managers see it with their morning coffee. A single formatted message beats forty individual notifications every time.

Add an n8n Slack node and configure:

Node: Slack
Resource: Message
Operation: Send
Send Message To: Channel
Channel: #product-feedback
Message Type: Simple Text Message
Text:
*Feedback digest for {{ $now.toFormat("MMM d") }}*
Tickets: {{ $json.total }} | Avg sentiment: {{ $json.avg }} ({{ $json.delta > 0 ? "improving" : "worsening" }})
Bug: {{ $json.counts.bug || 0 }} | Feature: {{ $json.counts["feature-request"] || 0 }} | Pricing: {{ $json.counts.pricing || 0 }}
UX: {{ $json.counts.UX || 0 }} | Performance: {{ $json.counts.performance || 0 }}
Top issues: {{ $json.worst.join("; ") }}

Use the Slack OAuth2 credential in n8n with the chat:write scope and invite the bot to the channel first, or the node returns not_in_channel. The || 0 guards stop empty categories from printing "undefined".

Expression: {{ $json.worst.join("; ") }}
Sample output:
{
  "ok": true,
  "channel": "C04PRODFB",
  "ts": "1793722800.000100",
  "message": { "text": "*Feedback digest for Nov 4*..." }
}

Link each top issue to its ticket by also carrying the ticket URL through the summary node, so readers can click straight into the support tool.

Extending This n8n Workflow

Once the base n8n workflow runs cleanly for a week, there are three upgrades that pay off quickly. First, add an IF node after the sentiment Code node so that any ticket scoring below -0.7 triggers its own immediate Slack alert, while everything else waits for the daily digest. Second, add a weekly n8n workflow that reads the Tickets sheet with the Google Sheets Get Row(s) operation and charts category counts by week. Third, tune the keyword lists using the uncategorized rows, since those are the words your customers use that your rules do not yet know.

If you outgrow the lexicon, n8n can call a hosted LLM API through the HTTP Request node, but keep the keyword engine as a fallback so the workflow still runs when that API is unavailable.

Troubleshooting

Every ticket lands in uncategorized

This usually means the Code node is reading the wrong shape. Zendesk wraps tickets in a results array, while pagination may already split them into items. Add a Split Out node on results or adjust the loop to read $input.all(), then log one item with console.log(JSON.stringify(item)) to confirm that subject and description exist.

The sentiment trend always shows zero change

Workflow static data is only saved on production executions. If you only click Execute Workflow in the editor, lastAvg never persists. Activate the n8n workflow, let it run on the schedule once, and the delta will appear from the second day.

Zendesk returns 429 or only the first 100 tickets

Search is rate limited and paginated. Enable pagination on the n8n HTTP Request node, and under Options set Batching with an interval of 1000 ms if your account is on a lower plan. Keep the query narrow with the created date filter so you stay under the 1000 result search cap.

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) Packs

Frequently Asked Questions

How accurate is keyword sentiment compared with a trained model?

It is less accurate on individual tickets but very stable over time, which is what matters for a trend line. The same input always gets the same score, so a sudden drop reflects a real change in wording across tickets rather than model drift.

Can I use Intercom or Freshdesk instead of Zendesk?

Yes. Only the n8n HTTP Request node changes. Intercom uses the conversations search endpoint with a created_at filter, and Freshdesk uses /api/v2/search/tickets with a query string. The Code nodes only need subject and description fields.

How do I add a sixth category such as security?

Add a new key to the RULES object, for example security: ['breach','password','sso','permission'], and add the new key to the Slack message. No other node needs a change because counts are built dynamically.

Does this send customer data to a third party?

Ticket text is processed inside your own n8n instance. Only the summary goes to Slack and the row data goes to your own Google Sheet, so no external classification service ever sees the raw messages.