Interactive Zendesk + CSAT Dashboard inside Google Sheets
One Google Apps Script that pulls tickets and CSAT survey responses from Zendesk, filters by date range and tag, aggregates the metrics, and renders charts directly into a spreadsheet — giving any support lead a live analytics view with zero infrastructure.
What it looks like in a real workspace
Screenshots from a live Zendesk account running this script over May 2026. Customer names, ticket subjects, subdomain, and private tags are redacted.
Dashboard sheet
69 tickets filtered by prefix tag, rendered as a pie chart (tag distribution) and column chart (status breakdown). Charts are generated by Charts Service.
Tag list output
1,831 tickets scanned across 1–11 May 2026, 30 unique tags discovered and sorted by frequency. Output of the Scan Available Tags menu item.
Raw Data sheet
Per-ticket detail view with matched tag, full tag list, priority, and timestamps. Each row links back to a real Zendesk ticket via the Ticket URL column.
Live Dashboard
Pick a filter mode, then click refresh to simulate the pipeline
📈 Status Breakdown
Waiting...⭐ CSAT Rating Category
No responses💬 Recent CSAT Comments
0 comments📄 Raw Data — Ticket Details (Mock)
0 tickets| ID | Subject | Status | Priority | Matched Tag | All Tags | Updated |
|---|
📖 Metrics Glossary
What each number and chart on the dashboard above actually means
Total Tickets
Raw Data sheetCOUNT(ticket) after filterVolume of tickets inside the date range and tag filter. A sudden spike usually signals a trending incident or campaign fallout.
Matched Tag
Top Matched Tags panelcountBy(rows, 'tag')Issue category trend. If Billing dominates, payment flow is probably broken. The label strips the filter prefix so it stays readable.
Status Breakdown
Status panelcountBy(rows, 'status')Workflow distribution. High open = backlog building. High solved = productive day. pending = waiting on customer reply.
Tag Filter Mode
Set Tag Filter menuALL · EXACT · PREFIXALL = no filter. EXACT = match one tag literally. PREFIX = match a tag family (coke_utc_*) — useful for multi-tenant tagging.
Avg CSAT Rating
CSAT Data sheetΣrating / countMean of the 1–5 rating scale. Industry benchmark for support is ≥ 4.0. Drop > 0.3 week-over-week deserves a retro.
Rating Category
CSAT Rating Category panelcountBy(csat, 'ratingCategory')Zendesk classifies each rating as good, bad, or neutral. Ratio of good vs bad = proxy for CSAT%.
CSAT Comments
Recent CSAT Comments panelanswers[].type === 'open_ended'Qualitative signal. Numbers tell you what happened; comments tell you why. Read bad ones first to find fixable patterns.
Date Range
Set Date Range menucreated_at ∈ [from, to]All metrics respect this window. Blank = last 7 days. Tickets are matched by created_at, CSAT by survey submitted_at.
⚡ How It Works
Three core pieces from the Google Apps Script
function fetchTicketsFromIncrementalExport(range) {
let url = buildZendeskUrl(
'/api/v2/incremental/tickets.json',
{ start_time: range.fromUnixSeconds }
);
while (hasMore) {
const data = zendeskGet(url);
data.tickets.forEach(t =>
ticketMap[t.id] = t);
hasMore = data.next_page
&& !data.end_of_stream
&& data.end_time < range.toUnixSeconds;
url = data.next_page;
Utilities.sleep(650);
}
return Object.values(ticketMap);
}
function getMatchedTagInfo(tags, tagFilter) {
if (tagFilter.mode === 'all')
return { tag: '', value: '' };
for (const value of tagFilter.values) {
for (const tag of tags) {
const matched = tagFilter.mode === 'exact'
? tag === value
: tag.indexOf(value) === 0;
if (matched) return { tag, value };
}
}
return { tag: '', value: '' };
}
function extractTicketId(response) {
// e.g. "zen:ticket:12345"
for (const zrn of response.subject_zrns || []) {
const m = String(zrn)
.match(/^zen:ticket:(\d+)$/);
if (m) return m[1];
}
return '';
}
// Then fetch in batches of 100
// via /api/v2/tickets/show_many?ids=...
🧩 Extending the Dashboard
The refresh pipeline follows a repeatable 3-step pattern. Pick a category below to see ideas and a concrete code recipe.
Extract
Add the field inside buildRawTicketRows or buildCsatRows.
Aggregate
Write a reducer function. Reuse countBy() when possible.
Render
Push a new block into writeTicketMetrics or writeCsatMetrics.
Adding a CSAT Score (%) metric
Industry standard: good / (good + bad) × 100. More meaningful than a raw average
because it ignores neutral responses.
ratingCategory is already built by buildCsatRows().function calculateCsatScore(csatRows) {
const good = csatRows.filter(r => r.ratingCategory === 'good').length;
const bad = csatRows.filter(r => r.ratingCategory === 'bad').length;
if (good + bad === 0) return '';
return Number(((good / (good + bad)) * 100).toFixed(1));
}
// inside writeCsatMetrics()
const metrics = [
['Metric', 'Value'],
['Total CSAT Responses', csatRows.length],
['Average Rating', averageRating],
['CSAT Score (%)', calculateCsatScore(csatRows)], // ← new row
];
✨ Key Features
3 Tag Filter Modes
ALL (no filter), EXACT (match one tag), or PREFIX (match a whole tag family like coke_utc_*).
Incremental Export
Cursor-based pagination through /incremental/tickets with rate-limit + retry-after handling built in.
CSAT + Ticket Join
Survey responses are mapped back to their source ticket via subject_zrns, then re-filtered by tag.
Auto-Rendered Charts
Pie chart for tag + CSAT category, column chart for status — all drawn directly on the Dashboard sheet.