10 Contact Center KPIs to Track (With Ready DAX Formulas)
KPI Design / By Giacomo Ialenti / / Updated / 8 min read
Ask three team leads how they calculate Average Handle Time and you can get three answers. One includes after-call work, one leaves out transferred calls, one copies the number from the telephony report. All three are defensible, and none of them match in a shared dashboard.
In the contact-center reporting I build, every KPI gets a written definition before it gets a measure. This post is that definition sheet for ten KPIs, with the DAX for each. Copy a measure, rename the tables and columns to match your model, then check the result against one period you can verify by hand.
The model behind the measures
Every measure below assumes the same simple model. Change the names if you need to, but keep the structure.
| Table | One row per | Columns the measures use |
|---|---|---|
| Interactions | offered contact (call, chat or email) | InteractionID, InteractionDate, AgentKey, Answered (True/False), Abandoned (True/False), WaitSeconds, TalkSeconds, HoldSeconds, ACWSeconds, IsRepeatContact7d (True/False), SurveyScore (1 to 5, blank when there is no survey) |
| Cases | case worked over hours or days | CaseID, ResolvedDate, ResolvedDateTime, MetTarget (True/False), IsEscalated (True/False) |
| AgentStaffing | agent and day | Date, AgentKey, LoggedInSeconds, ScheduledSeconds, AdherentSeconds |
| Agents | agent | AgentKey, HireDate, TerminationDate |
| Costs | day | Date, Amount |
| Dates | day | Date (marked as the date table) |
Dates[Date] is related to Interactions[InteractionDate], Cases[ResolvedDate], AgentStaffing[Date] and Costs[Date]. Agents[AgentKey] is related to Interactions[AgentKey] and AgentStaffing[AgentKey], so you can slice both by agent, team or tenure. Agents has no relationship to Dates, because the attrition measures handle the dates themselves.
Flags such as IsRepeatContact7d and MetTarget belong in Power Query, where you can test them row by row. Make sure they are never null: in DAX a blank compares equal to FALSE, so a null flag would be counted as false. Keep DAX for the aggregation.
Three base measures
Offered Contacts = DISTINCTCOUNT ( Interactions[InteractionID] )
Answered Contacts =
CALCULATE ( [Offered Contacts], Interactions[Answered] = TRUE )
Abandoned Contacts =
CALCULATE ( [Offered Contacts], Interactions[Abandoned] = TRUE )
Most of the other measures are built from these three. If the definition of “answered” changes, it changes in one place.
Some operations ignore very short abandons, for example callers who hang up within 5 seconds. If yours does, put that rule in Offered Contacts once, and Answered, Abandoned, Service Level and Abandonment Rate all follow it. Use this body instead of the first measure above (5 seconds is an example, change it here and nowhere else):
Offered Contacts =
VAR AllContacts = DISTINCTCOUNT ( Interactions[InteractionID] )
VAR ShortAbandons =
CALCULATE (
DISTINCTCOUNT ( Interactions[InteractionID] ),
Interactions[Abandoned] = TRUE,
Interactions[WaitSeconds] < 5
)
RETURN
AllContacts - ShortAbandons
1. Average Handle Time (AHT)
Definition: talk time plus hold time plus after-call work, divided by answered contacts.
Handle Seconds =
CALCULATE (
SUM ( Interactions[TalkSeconds] )
+ SUM ( Interactions[HoldSeconds] )
+ SUM ( Interactions[ACWSeconds] ),
Interactions[Answered] = TRUE
)
AHT (seconds) = DIVIDE ( [Handle Seconds], [Answered Contacts] )
AHT (mm:ss) =
VAR Total = ROUND ( [AHT (seconds)], 0 )
RETURN
IF (
ISBLANK ( [AHT (seconds)] ),
BLANK (),
INT ( Total / 60 ) & ":" & FORMAT ( MOD ( Total, 60 ), "00" )
)
The trap: whether after-call work sits inside AHT. Decide once and write it down, because this is the reason two reports disagree most often. The measure adds up seconds first and divides once. Averaging each agent’s AHT would give a part-timer the same weight as a full-timer.
2. First Contact Resolution (FCR)
Definition: the share of answered contacts with no repeat contact from the same customer about the same issue within 7 days.
FCR % =
DIVIDE (
CALCULATE ( [Answered Contacts], Interactions[IsRepeatContact7d] = FALSE ),
[Answered Contacts]
)
The trap: a repeat contact needs 7 days to show up, so the most recent 7 days of any report look better than they are. Show FCR only for periods at least 7 days old, or label the latest weeks as provisional. The flag also depends on how you match “same issue”. Customer ID plus issue category is a reasonable start.
3. Case Resolution Time (CRT%)
Definition: the share of resolved cases that were resolved inside the target time. This is for teams that work cases rather than live contacts.
CRT % =
VAR ResolvedCases =
CALCULATE ( COUNTROWS ( Cases ), Cases[ResolvedDateTime] <> BLANK () )
VAR ResolvedInTarget =
CALCULATE (
COUNTROWS ( Cases ),
Cases[ResolvedDateTime] <> BLANK (),
Cases[MetTarget] = TRUE
)
RETURN
DIVIDE ( ResolvedInTarget, ResolvedCases )
CRT % Escalated =
CALCULATE ( [CRT %], Cases[IsEscalated] = TRUE )
CRT % Non-escalated =
CALCULATE ( [CRT %], Cases[IsEscalated] = FALSE )
The trap: which date does a case belong to? Relating Cases to Dates by ResolvedDate reports a case in the period it closed. Relating it by the opened date reports it when it arrived. The two give different numbers, so pick one and state it. Report escalated and non-escalated cases separately, because they rarely move together.
4. CSAT
Definition: the share of survey responses scoring 4 or 5 on a 5-point scale.
Survey Responses = COUNT ( Interactions[SurveyScore] )
CSAT % =
DIVIDE (
CALCULATE ( COUNT ( Interactions[SurveyScore] ), Interactions[SurveyScore] >= 4 ),
[Survey Responses]
)
Survey Response Rate % = DIVIDE ( [Survey Responses], [Answered Contacts] )
The trap: a CSAT of 92% built on 8 responses is noise. Always show the response count next to the score, and segment by channel and queue, because one blended number hides where the problem is.
5. Service Level
Definition: the share of offered contacts answered within the threshold, for example 80% within 20 seconds.
The threshold lives in one place, a small calculated table that a slicer can change:
SL Threshold = GENERATESERIES ( 5, 120, 5 )
SL Threshold Seconds = SELECTEDVALUE ( 'SL Threshold'[Value], 20 )
Service Level % =
VAR Threshold = [SL Threshold Seconds]
VAR AnsweredInTime =
CALCULATE (
[Offered Contacts],
Interactions[Answered] = TRUE,
Interactions[WaitSeconds] <= Threshold
)
RETURN
DIVIDE ( AnsweredInTime, [Offered Contacts] )
Service Level Title =
"Service Level: answered within " & [SL Threshold Seconds] & " seconds"
SELECTEDVALUE falls back to 20 seconds when the slicer has no selection or several. Set the slicer to single select, and show Service Level Title as the visual title, so nobody reads a 20-second result while believing it is another threshold.
The trap: the denominator. Dividing by answered contacts removes everyone who gave up, so Service Level looks better than the customer experience. If you drop very short abandons from offered volume, use the version of Offered Contacts shown in the base measures, so Abandonment Rate follows the same rule.
6. Abandonment Rate
Definition: abandoned contacts divided by offered contacts.
Abandonment Rate % = DIVIDE ( [Abandoned Contacts], [Offered Contacts] )
The trap: consistency with Service Level. If you exclude short abandons, the Offered Contacts measure already applies the rule to both the numerator and the denominator here, and to Service Level as well. Do not apply it separately in each measure.
7. Occupancy
Definition: handle time divided by logged-in time.
Occupancy % =
DIVIDE ( [Handle Seconds], SUM ( AgentStaffing[LoggedInSeconds] ) )
The trap: scope. Handle seconds and logged-in seconds must cover the same agents and the same channels. Read Occupancy next to AHT and Service Level, because a high number can just as easily mean understaffing as efficiency.
8. Schedule Adherence
Definition: seconds spent in adherence divided by scheduled seconds.
Adherence % =
DIVIDE (
SUM ( AgentStaffing[AdherentSeconds] ),
SUM ( AgentStaffing[ScheduledSeconds] )
)
The trap: averaging percentages. Taking each agent’s adherence percentage and averaging them treats a four-hour shift like an eight-hour shift. Summing the seconds first weights every scheduled minute equally.
9. Attrition
Definition: agents who left during the period divided by average headcount for the period.
Leavers =
VAR StartDate = MIN ( Dates[Date] )
VAR EndDate = MAX ( Dates[Date] )
RETURN
COUNTROWS (
FILTER (
Agents,
NOT ISBLANK ( Agents[TerminationDate] )
&& Agents[TerminationDate] >= StartDate
&& Agents[TerminationDate] <= EndDate
)
)
Average Headcount =
VAR StartDate = MIN ( Dates[Date] )
VAR EndDate = MAX ( Dates[Date] )
VAR HeadcountAtStart =
COUNTROWS (
FILTER (
Agents,
Agents[HireDate] <= StartDate
&& ( ISBLANK ( Agents[TerminationDate] ) || Agents[TerminationDate] >= StartDate )
)
)
VAR HeadcountAtEnd =
COUNTROWS (
FILTER (
Agents,
Agents[HireDate] <= EndDate
&& ( ISBLANK ( Agents[TerminationDate] ) || Agents[TerminationDate] > EndDate )
)
)
RETURN
DIVIDE ( HeadcountAtStart + HeadcountAtEnd, 2 )
Attrition % = DIVIDE ( [Leavers], [Average Headcount] )
The trap: this is attrition for the period you select. A monthly figure is not comparable with an annual benchmark, so say which one you are showing.
10. Cost per Contact
Definition: total cost divided by answered contacts.
Cost per Contact =
DIVIDE ( SUM ( Costs[Amount] ), [Answered Contacts] )
The trap: scope again. The cost table has to cover the same operation as the contacts, and you need to decide what belongs in it: agents only, or also supervisors, licences and premises. I divide by answered contacts because those are the ones that consumed agent time. Whichever you choose, write it next to the number.
Test before you build the dashboard
Pick one week. Calculate every KPI by hand in Excel from the raw rows, then compare with the measures. When they differ, the definition is usually the cause and the DAX is usually fine. Fix the definition first, then build the visuals.
If you want to see where these measures sit in a full workflow, From Raw Ticket Data to Executive Dashboard walks through the steps around them, and the CRT% case study shows the same approach on a real reporting problem.