denis bauer· belgrade, serbia·linkedIn·cv·email·Book a call with me
GlobalWorkSupport Stats
Case study

Every support number, and how it was counted

A leadership dashboard for two brands’ support: ticket volume and refunds, chargebacks and Visa’s VAMP rate, Trustpilot outcomes, team speed. Computed offline from Zendesk, Solidgate and Trustpilot, published as static JSON — and honest about which numbers are still moving.

🔒 support-stats · Basic Auth

Support Stats

RemoteJobsFinder + GlobalWork · Jul ’25 – Sep ’26

CombinedRJFGWMonthsWeeksAll12M6M3M
OverviewChargebacks & VAMPTrustpilotTeam
Total tickets
121,809
↑ 3.1%
Refund requests
28,421
↑ 1.4%
Refund rate
11.9%
↓ 0.8%
Chargebacks · PPG
8,055
↓ 2.2%
Disputes · Solidgate
2,366
↑ 12.5%

Volume & refunds

Tickets created

Combined · per month
i
All createdReal inbound

Refund requests vs confirmed

Combined · per month
i
RequestsConfirmed

Refund rate

confirmed ÷ requests · incl. vs excl. Solidgate voids
i
Incl. voidsExcl. voids
Generated 9/22/2026 · tag metrics by created_at · Zendesk data auto-refreshed weekly
0
tabs, one domain each
0
months of history, since July 2025
0
datasets, computed offline and committed
~0
Python scripts behind the numbers
every chart on this page runs on invented numbers — the real ones stay behind the login
What this was

Monthly numbers assembled by hand, and nowhere to look them up

RemoteJobsFinder and GlobalWork are two job-search products with one support team on Zendesk, two payment providers, and a company-level target: keep Visa’s dispute-monitoring rate under 1%. The support numbers that fed that conversation were produced once a month by running a pipeline and pasting the output into Slack and a Google Doc. Nobody could compare periods, split by brand, or check a figure without asking.

I built Support Stats so leadership has one URL with the whole history: tickets and refunds, chargebacks and VAMP, Trustpilot, team speed — by brand and by provider, monthly or weekly.

The charts were the easy part. The work was making every number state what it counts, by which date, and whether it is final — and building the checks that keep it that way when nobody is looking.

Architecture

Compute offline, store in git, the page only reads

A month’s numbers take minutes of API calls to compute and would burn the rate limit on every page view. So the page never computes anything. It reads finished JSON.

Zendesk API
tickets, tags, audit logs
Solidgate exports
disputes, fraud alerts, card orders · manual CSV
Trustpilot workbook
the team’s own tracking sheet
GitHub Actions
~50 Python scripts, every Monday
recount the recent months · verify · commit
7 JSON files in git
aggregates only · commit history is the audit log
Netlify static site
React + Recharts · reads the JSON · Basic Auth at the edge · loads in under a second
waiting for Monday…

The Zendesk sets refresh themselves. The Solidgate and Trustpilot sets come from files with no API behind them, so they refresh when a new export is dropped in — and every card built on them says which export it is reading.

The dashboard

Four tabs, one domain each

It started as seven tabs. A restructure folded them into four, one domain per tab, and moved every definition behind an (i) so the page shows numbers and the popover shows what they count.

1

Overview

  • Five headline numbers over whatever period is picked, each with its change against the previous window of the same length and a sparkline of the periods inside it.
  • “All created” against “real inbound”: the second line strips the machine notices the payment provider raises for every order and renewal. The gap between them is automation load, not work.
  • The refund rate is drawn twice — including and excluding voids, the Solidgate authorisation cancellations where no money moved — because the two tell different stories.
  • Six support policies as small multiples, each on its own scale, so a tag applied a handful of times a month stays visible next to one applied hundreds of times.

Support Stats

RemoteJobsFinder + GlobalWork · Jul ’25 – Sep ’26

OverviewChargebacks & VAMPTrustpilotTeam
Total tickets
121,809
↑ 3.1%
Refund requests
28,421
↑ 1.4%
Refund rate
11.9%
↓ 0.8%

Volume & refunds

Tickets created

Combined · per month
i
All createdReal inbound
Generated 9/22/2026 · tag metrics by created_at · Zendesk data auto-refreshed weekly
2

Chargebacks & VAMP

  • Visa’s dispute-monitoring rate against the 1% line the company is judged on. The freshest month is drawn dashed with a hollow point: shown, not hidden, but visibly not final.
  • What RDR costs per month — the fee Visa’s auto-refund charges for every dispute it settles on our behalf. It keeps disputes out of the metric, not out of the bill.
  • A funnel of everyone who disputed a charge: how many had written to support first, and of those, how many had asked for a refund and been turned down.

Support Stats

RemoteJobsFinder + GlobalWork · Jul ’25 – Sep ’26

OverviewChargebacks & VAMPTrustpilotTeam

VAMP · Visa only · Solidgate export 18 Sep 2026

VAMP rate · Aug ’26
1.21%

over the 1% target · latest closed month · matches the hub

VAMP events · Aug ’26
684

646 fraud alerts (TC40) + 38 chargebacks past RDR (TC15)

Transactions · Aug ’26
48,900

settled Visa orders · the denominator · hub figure

VAMP rate vs the 1% target

Mar ’26 → Aug ’26 · Sep ’26 provisional
i
Sep ’26 is provisional: its TC15 has not been swept by RDR yet and its denominator is still settling, so the real figure lands lower.

Solidgate disputes · did they contact support first?

Dispute funnel

1,000 chargeback users · Mar ’26 – Sep ’26
i
All chargeback users
1,000
100.0%
Never contacted support
840
84.0%
Contacted us first
160
16.0%
Asked → refused → charged back
90
9.0%
Contacted for another reason
60
6.0%
Refunded — disputed anyway
10
1.0%
Generated 9/22/2026 · tag metrics by created_at · Zendesk data auto-refreshed weekly
3

Trustpilot

  • Review requests and one-star reviews by brand, from the team’s own tracking workbook.
  • What happened to each negative review after outreach: unchanged, raised to a higher rating, or removed by Trustpilot. The coloured part above the red is what the team turned around.

Support Stats

RemoteJobsFinder + GlobalWork · Jul ’25 – Sep ’26

OverviewChargebacks & VAMPTrustpilotTeam

Review requests

by brand · per month
i
RJFGW

Outcomes of negative reviews

after support outreach · 298 reviews in period
i
1★ (unchanged)11840%
changed to 5★9632%
removed by TP4415%
changed to 4★217%
changed to 3★124%
changed to 2★72%
The team turned around 180 of 298 — a higher rating or a removal.
Generated 9/22/2026 · tag metrics by created_at · Zendesk data auto-refreshed weekly
4

Team

  • First response time as the real wall-clock wait, validated against Zendesk’s own metric. The in-shift line counts business-hours minutes only and reads flatteringly small, so it is labelled as speed once online, not the customer’s wait.
  • Close time measured on handled tickets — solved with at least one agent reply — with the all-tickets median kept as a faint reference, because that one turned out to measure something else entirely.
  • Replies and tickets solved per agent, with former team members behind a toggle.

Support Stats

RemoteJobsFinder + GlobalWork · Jul ’25 – Sep ’26

OverviewChargebacks & VAMPTrustpilotTeam
FRT (median)
5h 10m

real customer wait

NRT (median)
1h 48m

speed on follow-ups

Close time (median)
41h 00m

handled tickets · created → solved

Response time

First response time

median minutes · per month · real wait vs in-shift
i
CalendarIn-shift

Per-agent

Replies (touches)

human public comments per agent · per month
i
NadiaTomIris
Generated 9/22/2026 · tag metrics by created_at · Zendesk data auto-refreshed weekly
Metric basis

One refund, three dates, and two of them lie

A Zendesk ticket has a creation date, a last-updated date, and — buried in its audit log — the exact moment each tag was added. Which one you count by decides whether a closed month stays closed.

nothing has touched the ticket yet
W10
W11
W12
created_atticket opened
tag addedrefund confirmed · audit log
updated_atlast change
created_atW10
Stable, but it is the week the ticket was opened, not the week the money went back. Right for intake tags, wrong for outcomes.
updated_atW10
Looks right today. It is the ticket’s last change, and it keeps moving.
tag_addedW10
The moment the refund was confirmed, read from the ticket’s change history. Does not move.
verify_tag_events.py · W10 · audit 118 = events 118 ✓ publish

For eleven weeks the pipeline counted confirmed refunds by updated_at while every document called it the event date. I had asked for exact tag-add dating and approved it twice; the substitution was made by the AI assistant I was building with, both times, when the approved approach hit an API limit — and every later session repeated the label. Roughly one refund in seven landed in the wrong week, and closed months moved on recount.

The fix is mechanical, not a promise. Refund and policy tags are dated by the tag-add moment from each ticket’s audit log, the builders refuse any other basis for them, and CI recounts the last finished week from a second Zendesk API, ticket for ticket. One ticket’s difference fails the run, and a failed run publishes nothing.

Intake tags stay on created_at: they are applied at creation, and a creation date never changes.

VAMP

The 1% line, and a month you cannot trust for two weeks

Visa’s dispute-monitoring rate is the number the company is judged on. It is computed from the provider’s exports and cross-checked against the provider’s own figure — exact, every month.

VAMP rate vs the 1% target(TC40 + TC15) ÷ settled Visa orders

Day 0 after month-end: September reads 1.41%, drawn dashed with a hollow point, and kept out of the headline KPI.

TC40 · fraud alerts
610
land within ~2 days, and nothing removes them — not a refund, not RDR
TC15 · chargebacks past RDR
180 → 180
shrink for ~2 weeks as Visa’s auto-refund settles them out
Days since month-end
0
provisional

Almost all of the numerator is fraud alerts: the cardholder told their bank they did not recognise the charge. Those arrive in a couple of days and are then banked for good. The chargebacks are the small, moving part — RDR refunds most of them before they count, and it takes about two weeks to finish. So a fresh month always reads worse than it closes, and the dashboard draws it as provisional rather than pretending it is final or hiding it.

The consequence turns the intuition around. Preventing disputes barely moves the rate; RDR was already keeping them out. What moves it is not giving the customer a reason to call their bank in the first place: a renewal warning, a clear descriptor, a price they recognise. The refund policy, it turned out, is a cost and experience lever, not a VAMP lever.

Refund refusal → chargeback

What a refused refund costs

Two populations that used to sit on one page with no denominators, now each with its own: disputers traced back to support, and refused requesters traced forward to the bank. A few percent of refusals come back as chargebacks; the mechanism behind them is the same every time.

Who disputed, and had they written first

Every disputer in the provider’s export, matched against Zendesk: a manual ticket in the 60 days before the dispute counts as prior contact.

All chargeback users
100%
Never contacted support
84%
Contacted us first
16%
asked for a refund → refused → charged back
9%
contacted for another reason
6%
refunded — disputed anyway
1%

The honest ceiling: more than four in five disputers never wrote to support at all. The refund policy is a real lever on the ones who did, and only on them.

The policy refuses exactly the people who dispute

The money-back policy gates on whether the product was used. The people who go to their bank are not disputing usage. They are disputing a renewal they did not see coming.

A renewal is chargedthe customer did not expect itThey ask for a refund“I thought I had cancelled”Refusedthe policy looks at usageAbout a week of silencemedian, refusal to bankChargebackthey call their bankRDR refunds them anywayminus a fee, minus the customer

Team metrics

The close time that “fell” from two days to minutes — and did not change

The median time from ticket creation to solved sat at exactly 48 hours for months, then read minutes. Nothing had sped up. The mix of tickets had changed.

Time to solve, all ticketsinstant ← → 48 h rule
p50
auto-closed share42%
Median, all tickets
48 h
flips between the two humps
p75, all tickets
49 h
does not move
Median, handled tickets
55 h
≥1 agent reply · does not move

The distribution has two humps. Provider notifications and spam close on the spot; everything else runs into a 48-hour auto-solve rule. The median is just whichever hump holds more than half the tickets, so when the auto-closed share crossed 50% it jumped from two days to minutes while the upper quartiles stayed put.

The dashboard now measures close time on handled tickets — solved with at least one agent reply — and keeps the all-tickets line as a faint reference with a caption saying what it actually measures. The same audit found the opposite case too: next-reply time really had fallen sharply, and a probe of the ticket histories showed why — follow-ups land while the agent who just replied is still online.

Granularity

The same range, cut into weeks

Which stretch you look at and how finely you slice it are two different controls. The period picker stays; one switch re-cuts the same months into ISO weeks.

Tickets createdCombined · Apr ’26 – Sep ’26 · per month

Six points. Flip to Weeks to see the same six months as twenty-six.

The mean and the anomaly flags are computed in the builder and shipped in the JSON, never on the page. The Zendesk tabs get the switch; the Solidgate and Trustpilot tabs stay monthly because their inputs are monthly exports, and their cards carry a small “monthly” badge to say so.

Where the tags come from

The machine that applies the tags

Most tags the dashboard counts are set by automation, not by people. Reconstructed from the Zendesk trigger API and the Make scenario, because the numbers cannot be read without it.

Zendesk
A ticket is created
a trigger fires a webhook
Make scenario
A cascading classifier
a regex for claims, then two small-model calls: is it a login issue? is it a clean cancellation? It writes back exactly one tag.
ai_cancel_flowSolved
Zendesk trigger on the tag: how to cancel, step by step
ai_login_flowPending
Zendesk trigger on the tag: “what issue are you seeing?”
ai_claim_flowPending
Zendesk trigger on the tag: subscription suspended · chargeback narrative
ai_human_escalationOpen → human
Zendesk trigger on the tag: “an agent will be with you shortly”

Make only tags. Zendesk sends the reply and sets the status, through a trigger keyed on that one tag. So every automated reply is a non-human comment, and the team metrics exclude it by construction rather than by guessing.

It also settles what “refund requests” means. That tag is applied by a trigger only to the payment provider’s own notification emails — so the dashboard counts refunds requested through the provider’s flow, not customers writing in. A number that looked obvious turned out to need a sentence next to it.

Impact

Before and after

Built in about three months alongside the support job, with an independent review of the analysis before any number went to leadership.

click a row to turn it back over
Under the hood
Python 3.13 exporters
Zendesk search, audit logs and incremental events; Solidgate and Trustpilot files
GitHub Actions
Weekly cron, on-demand backfills that commit month by month, PII-free ticket caches
JSON in git
Seven aggregate datasets; every revision of every number is a commit
Vite + React + Recharts
Static bundle, no server, loads in under a second
Netlify + an edge function
Basic Auth on the whole site, password in an environment variable
PayProDesk gateway
Resolves each customer to a payment provider for the provider splits
Why the numbers can be trusted
Never swap an approved method
If the approved approach hits a limit, stop and say so. Do not ship a proxy, however close it looks — that is how eleven weeks of wrongly dated refunds happened.
An approximation is called one, everywhere
In the code, the commit, the documentation and the message — with its measured error. Never name a proxy after the thing it approximates.
Matching your own earlier output proves nothing
A method compared with its own previous result is not validated. It needs an independent source: a second API, the provider’s hub, a hand count.
A closed period that changes is a bug
Until proven otherwise. Not noise, not late data, not “a couple of tickets”.
The newest month is never final
Provisional is drawn as provisional — dashed, faded, badged — and kept out of the headline.
No PII leaves the machine
Aggregates only on the site; the CI cache holds ticket ids and timestamps, never emails or text; the site sits behind a password.