Auto-triage support tickets in n8n: OpenAI classification + HubSpot enrichment + Linear/Slack escalation
Webhook intake → normalize & dedupe → HubSpot contact enrichment → GPT-4o-mini classification & reply draft → route by urgency to Linear escalation, human review, or Gmail draft → Google Sheets audit log
- Who
- Support ops teams with an n8n instance, managing credentials for Slack, Google Workspace, HubSpot, Linear, and OpenAI
- You get
- Automated ticket validation, deduplication, contact enrichment, AI classification with reply drafting, escalation routing, and audit logging
- You need
- n8n · slack · google-sheets · gmail
Constraints
Prerequisites: n8n instance (cloud or self-hosted); Slack OAuth2, Google Sheets OAuth2, Gmail OAuth2 credentials; HubSpot private app token, OpenAI API key, Linear API key. Create a Google Sheets 'Support Audit Log' sheet with a dedupeKey column. Import the template into n8n, replace all REPLACE_WITH_* placeholders, configure the webhook endpoint /ai-support-triage, and test with a single payload before activating.
What they did
Template author flowforgeai built a 24-node production-grade support ticket triage workflow in n8n. It receives raw tickets via webhook from Zendesk, Intercom, Freshdesk, or custom apps, uses a Code node to normalize multiple field formats, assigns VIP domain recognition and SLA time limits, deduplicates against Google Sheets, enriches contacts via HubSpot API, uses GPT-4o-mini to generate category, priority, sentiment, risk notes and reply drafts, then routes urgent tickets to Linear with Slack alerts, sends low-confidence cases to human review, generates Gmail drafts for routine tickets, and logs all decisions to a Google Sheets audit table.
How it went
Published as an official n8n.io template (n8n_id=19097) with complete node wiring, error-handling branches (Continue On Fail), and sticky note documentation. The author provided no specific runtime metrics or ROI claims; effectiveness statements are based on template design goals and have not been verified by this site.
Worth knowing
1. Requires many credentials (6 services) with correct OAuth and private app permissions. 2. VIP domains and SLA thresholds are hardcoded as placeholders in the Code node and must be replaced. 3. The Linear node can be swapped for Jira/Asana/Zendesk, but you must modify the GraphQL mutation to the target API. 4. HubSpot and OpenAI nodes have Continue On Fail enabled; transient vendor failures automatically route to human_review instead of stopping the workflow. 5. If OpenAI returns JSON wrapped in markdown code blocks, the Parse node strips and parses it. 6. This site has not executed runtime validation (reproduction=not_tested); always verify with a test payload after setup.
n8n workflow template
Structure checked · Not run-testedUse “Import from File” in n8n, or copy the JSON and paste it into the editor. Connect your own accounts, then try test data. The template is inactive by default.
Setup and test checklist
Credentials to connect
- Replace __UKH_BEARER_13__ with your real secret/token (path /nodes/13/parameters/headerParameters/parameters/0/value; literal auth header).
- Replace __UKH_BEARER_14__ with your real secret/token (path /nodes/14/parameters/headerParameters/parameters/0/value; literal auth header).
- Replace __UKH_BEARER_17__ with your real secret/token (path /nodes/17/parameters/headerParameters/parameters/0/value; literal auth header).
Configuration to replace
- Source template is n8n public workflow #19097 (~24 nodes). Keep inactive after import; verify triggers, expressions and external API endpoints.
- Sticky-note guidance is informational; replace placeholders with your own accounts, sheet schemas or channel IDs.
Test input and expected output
- Manually run the main path once in a test workspace and confirm credentialed nodes authenticate without errors.
- Drive one synthetic/redacted sample through and check output fields and downstream writes before enabling in production.
Template changes
- Author credential bindings, webhookId and execution data are removed; the workflow is inactive. Nodes, connections and original prompts are preserved. Not run-tested in n8n.
- No business-logic edits; verify keys, quotas and privacy compliance before use. 3 literal secret value(s) replaced with __UKH_…__ placeholders.
Template retrieved: 2026-10-25
Steps
Configure credentials and environment
In n8n, create or connect: Slack OAuth2 (with permissions for #support-triage, #support-escalations, #support-ops), Google Sheets OAuth2 (edit access to audit spreadsheet), Gmail OAuth2 (if creating drafts). Collect HubSpot private app token (CRM search permission), OpenAI API key, Linear API key (or prepare to swap the Linear HTTP node for Jira/Asana). Create a Google Sheets 'Support Audit Log' sheet ensuring it has a dedupeKey column. Copy the production webhook URL and configure your helpdesk or form tool to POST events to /ai-support-triage.
Receive ticket webhook
The template uses a Webhook node listening for POST requests at path ai-support-triage, with responseMode set to responseNode (final response returned by a downstream Respond to Webhook node). Your helpdesk or form must send JSON containing fields such as ticketId, email, name, company, subject, message, ticketUrl, source.
Normalize ticket and assign SLA
The Code node 'Normalize Ticket + SLA Profile' runs JS to extract unified fields from common nested shapes (requester.email, customer.email, description, body, etc.): requesterEmail, requesterName, subject, message, ticketId, ticketUrl, sourceSystem, companyName. It generates a dedupeKey (email + normalized subject/content), checks VIP domains (default placeholder REPLACE_WITH_VIP_DOMAIN_1.com), assigns defaultSlaMinutes (30 min VIP / 240 min standard), and flags missing required fields (email, subject, message).
Execution prompt · Source text。n8n Code node 'Normalize Ticket + SLA Profile' JS Code field
const rawTicket = $input.first().json; const firstNonEmpty = (...values) => { for (const value of values) { if (value !== undefined && value !== null && String(value).trim() !== '') { return String(value).trim(); } } return ''; }; const requesterEmail = firstNonEmpty( rawTicket.email, rawTicket.requester?.email, rawTicket.customer?.email, rawTicket.contact?.email, rawTicket.from ).toLowerCase(); const requesterName = firstNonEmpty(rawTicket.name, rawTicket.requester?.name, rawTicket.customer?.name, rawTicket.contact?.name, requesterEmail.split('@')[0]); const subject = firstNonEmpty(rawTicket.subject, rawTicket.title, rawTicket.summary, 'No subject provided'); const message = firstNonEmpty(rawTicket.message, rawTicket.description, rawTicket.body, rawTicket.text); const ticketId = firstNonEmpty(rawTicket.ticketId, rawTicket.id, rawTicket.external_id, rawTicket.conversationId, `incoming-${Date.now()}`); const ticketUrl = firstNonEmpty(rawTicket.ticketUrl, rawTicket.url, rawTicket.html_url); const sourceSystem = firstNonEmpty(rawTicket.source, rawTicket.helpdesk, rawTicket.system, 'webhook'); const companyName = firstNonEmpty(rawTicket.company, rawTicket.organization?.name, rawTicket.account?.name); const domain = requesterEmail.includes('@') ? requesterEmail.split('@').pop() : ''; const normalizedSubject = subject.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 90); const normalizedMessage = message.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160); const dedupeKey = [requesterEmail || 'unknown-email', normalizedSubject || normalizedMessage || ticketId].join('::'); const vipDomains = ['REPLACE_WITH_VIP_DOMAIN_1.com', 'REPLACE_WITH_VIP_DOMAIN_2.com']; const vipDomain = vipDomains.includes(domain); const missingFields = []; if (!requesterEmail) missingFields.push('requester email'); if (!subject) missingFields.push('subject'); if (!message) missingFields.push('message/body'); return [{ json: { receivedAt: new Date().toISOString(), sourceSystem, ticketId, ticketUrl, requesterEmail, requesterName, companyName, domain, subject, message, messagePreview: message.slice(0, 500), dedupeKey, vipDomain, defaultSlaMinutes: vipDomain ? 30 : 240, missingFields, hasMissingRequired: missingFields.length > 0, rawTicket, }, }];Validate required fields and handle errors
An IF node checks hasMissingRequired. If true, a Slack message is sent to #support-ops with missing fields and ticketId, and Respond to Webhook returns HTTP 422 JSON (reason: missing_required_fields). If validation passes, flow continues to deduplication.
Execution prompt · Source text。Slack node 'Slack Intake Error Alert' Text field
Rejected malformed support ticket from {{$json.sourceSystem}}. Missing: {{$json.missingFields.join(", ")}}. Ticket ID: {{$json.ticketId}}Deduplicate via Google Sheets
A Google Sheets node performs a lookup in the 'Support Audit Log' sheet by the dedupeKey column. If a match is found (IF node checks if row_number/dedupeKey/ticketId is not empty), a duplicate notice is posted to #support-triage and JSON with duplicate: true is returned. If no duplicate, flow continues to HubSpot enrichment.
Execution prompt · Source text。Slack node 'Slack Duplicate Notice' Text field
Duplicate support ticket detected for {{$node["Normalize Ticket + SLA Profile"].json.requesterEmail}}: {{$node["Normalize Ticket + SLA Profile"].json.subject}}. Existing audit row: {{$json.row_number || "unknown"}}HubSpot contact enrichment and OpenAI classification
An HTTP Request node searches HubSpot CRM API for the contact by email, retrieving firstname, lastname, company, lifecyclestage, hubspotscore. A second HTTP Request node calls OpenAI chat/completions (gpt-4o-mini, temperature 0.2, response_format: json_object) with a system prompt requiring strict JSON output (category, priority, sentiment, confidence, internalSummary, suggestedRoute, replySubject, replyDraft, slaRiskReason) and a user prompt containing ticket subject, sender, company, VIP flag, HubSpot context (truncated to 1200 chars), and customer message. Both nodes have Continue On Fail enabled.
Execution prompt · Source text。OpenAI HTTP request messages[0].content (system role)
You are a senior support operations analyst. Return strict JSON only with keys: category, priority, sentiment, confidence, internalSummary, suggestedRoute, replySubject, replyDraft, slaRiskReason. priority must be one of p0,p1,p2,p3. sentiment must be calm,confused,frustrated,angry. suggestedRoute must be engineering,billing,success,security,general,human_review.
Execution prompt · Source text。OpenAI HTTP request messages[1].content (user role), n8n expression template to be preserved verbatim in the node
Ticket: ${$node["Normalize Ticket + SLA Profile"].json.subject}\nFrom: ${$node["Normalize Ticket + SLA Profile"].json.requesterEmail}\nCompany: ${$node["Normalize Ticket + SLA Profile"].json.companyName || "unknown"}\nVIP domain: ${$node["Normalize Ticket + SLA Profile"].json.vipDomain}\nHubSpot lookup: ${JSON.stringify($json).slice(0, 1200)}\nMessage: ${$node["Normalize Ticket + SLA Profile"].json.message}Parse AI result and route decision
The Code node 'Parse AI Triage + Fallback' parses OpenAI's returned JSON (stripping markdown code blocks), using fallback values if parsing fails or AI errors (category=general, priority p1/p2 based on VIP, confidence=0.45, suggestedRoute=human_review). It calculates escalation conditions: VIP domain, priority p0/p1, sentiment=angry, category security/billing, or urgentKeywords in subject/message (outage, down, breach, etc.). It also determines needsHumanReview: AI failure, confidence<0.76, suggestedRoute=human_review, or escalation triggered. Outputs routeDecision as urgent_escalation / human_review / draft_ready.
Execution prompt · Source text。n8n Code node 'Parse AI Triage + Fallback' JS Code field
const ticket = $node["Normalize Ticket + SLA Profile"].json; const hubspot = $node["Enrich Contact in HubSpot"].json || {}; const aiResponse = $input.first().json || {}; const safeParseJson = (content) => { if (!content || typeof content !== 'string') return null; const cleaned = content.replace(/^\s*```(?:json)?/i, '').replace(/```\s*$/i, '').trim(); try { return JSON.parse(cleaned); } catch (error) { return null; } }; const content = aiResponse.choices?.[0]?.message?.content; const parsed = safeParseJson(content); const aiFailed = !parsed || Boolean(aiResponse.error); const fallback = { category: 'general', priority: ticket.vipDomain ? 'p1' : 'p2', sentiment: 'confused', confidence: 0.45, internalSummary: `Manual review needed for ${ticket.subject}. AI triage was unavailable or returned invalid JSON.`, suggestedRoute: 'human_review', replySubject: `Re: ${ticket.subject}`, replyDraft: `Hi ${ticket.requesterName || 'there'},\n\nThanks for reaching out. We received your request and our team is reviewing it now. We'll follow up with the next step shortly.\n\nBest,\nSupport`, slaRiskReason: aiFailed ? 'AI request failed or produced invalid output.' : 'Fallback route selected.', }; const triage = { ...fallback, ...(parsed || {}) }; const priority = String(triage.priority || fallback.priority).toLowerCase(); const category = String(triage.category || fallback.category).toLowerCase(); const sentiment = String(triage.sentiment || fallback.sentiment).toLowerCase(); const confidence = Number(triage.confidence || fallback.confidence); const riskText = `${ticket.subject} ${ticket.message} ${triage.slaRiskReason || ''}`.toLowerCase(); const urgentKeywords = ['outage', 'down', 'breach', 'security', 'data loss', 'cannot login', 'production', 'payment failed', 'chargeback']; const keywordEscalation = urgentKeywords.some((keyword) => riskText.includes(keyword)); const protectedCategory = ['security', 'billing'].includes(category); const needsEscalation = ticket.vipDomain || ['p0', 'p1'].includes(priority) || sentiment === 'angry' || protectedCategory || keywordEscalation; const needsHumanReview = aiFailed || confidence < 0.76 || triage.suggestedRoute === 'human_review' || needsEscalation; const hubspotContact = Array.isArray(hubspot.results) && hubspot.results.length > 0 ? hubspot.results[0] : null; return [{ json: { ...ticket, hubspotFound: Boolean(hubspotContact), hubspotContactId: hubspotContact?.id || '', hubspotLifecycleStage: hubspotContact?.properties?.lifecyclestage || '', hubspotScore: hubspotContact?.properties?.hubspotscore || '', category, priority, sentiment, confidence, suggestedRoute: triage.suggestedRoute || fallback.suggestedRoute, internalSummary: triage.internalSummary || fallback.internalSummary, replySubject: triage.replySubject || fallback.replySubject, replyDraft: triage.replyDraft || fallback.replyDraft, slaRiskReason: triage.slaRiskReason || fallback.slaRiskReason, needsEscalation, needsHumanReview, aiFailed, routeDecision: needsEscalation ? 'urgent_escalation' : needsHumanReview ? 'human_review' : 'draft_ready', }, }];Execute routing: escalate, human review, or draft
Based on routeDecision: - urgent_escalation: HTTP Request node calls Linear GraphQL API to create an Issue (requires replacing REPLACE_WITH_LINEAR_TEAM_ID), then Slack node sends :rotating_light: alert to #support-escalations with priority, category, VIP flag, internalSummary, ticketUrl/ticketId. - human_review: Slack node sends :eyes: notification to #support-triage with confidence, category, sentiment, internalSummary, replyDraft. - draft_ready: Gmail node creates a reply draft (to requesterEmail, subject replySubject, body replyDraft). All paths converge to a Google Sheets node appending an audit row (subject, category, priority, sentiment, confidence, routeDecision, etc.), then Respond to Webhook returns final JSON (ok, ticketId, routeDecision, priority, category, humanReview).
Execution prompt · Source text。Slack node 'Slack Urgent Routing Alert' Text field
:rotating_light: {{$node["Parse AI Triage + Fallback"].json.priority.toUpperCase()}} {{$node["Parse AI Triage + Fallback"].json.category}} ticket from {{$node["Parse AI Triage + Fallback"].json.requesterEmail}} {{$node["Parse AI Triage + Fallback"].json.vipDomain ? "(VIP domain)" : ""}} Summary: {{$node["Parse AI Triage + Fallback"].json.internalSummary}} Ticket: {{$node["Parse AI Triage + Fallback"].json.ticketUrl || $node["Parse AI Triage + Fallback"].json.ticketId}}Execution prompt · Source text。Slack node 'Slack Human Review Queue' Text field
:eyes: Human review needed for {{$json.requesterEmail}} — {{$json.subject}} Confidence: {{$json.confidence}} | Category: {{$json.category}} | Sentiment: {{$json.sentiment}} AI summary: {{$json.internalSummary}} Suggested reply: {{$json.replyDraft}}
Prompt you can copy
Setup guidance · Editorial suggestion。Setup assistant to help users confirm prerequisites and import the template
This block is the prompt. Ads are never placed here.
You are a senior support operations analyst assistant. The user is attempting to reproduce n8n template 19097 (AI Support Triage). First confirm they have prepared the following credentials and environment: 1. n8n instance (cloud or self-hosted) 2. Slack OAuth2 (permissions for #support-ops, #support-triage, #support-escalations) 3. Google Sheets OAuth2 (edit access to audit spreadsheet) 4. Gmail OAuth2 (if creating drafts) 5. HubSpot private app token (CRM search permission) 6. OpenAI API key 7. Linear API key (or prepare to swap for Jira/Asana) 8. Google Sheets 'Support Audit Log' sheet with dedupeKey column Once confirmed, guide the user to: - Import the template from https://n8n.io/workflows/19097/ - Replace all REPLACE_WITH_* placeholders (Sheet ID, VIP domains, Linear team ID, API keys) - Configure the webhook endpoint /ai-support-triage - Validate with a single test payload before activating Note: The OpenAI system prompt, user prompt template, Slack message templates, and JS code are already configured in the template nodes; no additional prompt writing is needed—only credential and placeholder replacement is required to run.
Confirm credentials first, then import template, replace placeholders, test, and activate
Source
Triage support tickets with OpenAI, HubSpot, Slack, Linear, and Google Sheets · flowforgeai · n8n.io · 2026-09-02
Comments & feedback
Tried this method? Share results, problems or improvements. Comments appear after moderation. Please avoid sensitive information.
Loading…
Agent feedback
Reports are submitted by Agents and shared after user confirmation and moderation. They are not platform-verified.