Call quality comes down to three things you can measure: delay, jitter and packet loss. Together they produce one score, MOS, from 1 (bad) to 5 (excellent), and a business call needs at least 3.5. You can measure call quality in Kenya without a QoS lab by logging stats on the endpoints you control and running scheduled test calls scored by a small listening panel.
Watching the call outcomes you already collect adds a third signal. The fixes run from the cheapest (codec choice) to the last resort (escalating to the carrier).
Mean Opinion Score comes from ITU-T P.800: a panel of listeners rates call audio from 1 (bad) to 5 (excellent), and the average is the MOS. Narrowband telephony tops out around 4.4, which is what a clean G.711 landline call scores, so no call gets a 5.
Because convening listener panels for every call is impractical, the industry mostly estimates MOS from network measurements using the ITU-T G.107 E-model. The E-model combines delay, loss, and codec impairments into an R-factor from 0 to 100, which maps onto MOS: R 80 is roughly MOS 4.0, R 70 roughly 3.6, R 60 roughly 3.1. Two things follow from that model. First, MOS is not linear: the drop from 4.2 to 3.5 is far more noticeable than from 4.4 to 4.2. Second, impairments add up: 250 ms of delay and 2% loss are each tolerable alone, and together they can make a call unusable.
Delay does not distort audio. It makes people talk over each other. ITU-T G.114 recommends keeping one-way mouth-to-ear delay under 150 ms; between 150 and 400 ms callers start talking over each other, and beyond 400 ms interactive conversation breaks down. One caution: the round-trip time you measure at the IP layer is not mouth-to-ear delay. Add codec framing, 20 ms packetisation, and the jitter buffers at both ends. In practice mouth-to-ear delay is more than half the RTT, because buffering and processing add to it.
Voice is sent at a steady rhythm: one RTP packet every 20 ms. Jitter is how unevenly those packets arrive. RFC 3550 defines the standard interarrival-jitter statistic that RTCP reports carry. The receiver fixes uneven arrival with a jitter buffer: it holds packets briefly and re-spaces them. That buys smoothness at the price of delay, and packets that arrive later than the buffer can wait for are discarded, so excess jitter shows up as packet loss even when nothing was lost on the wire. A modest buffer absorbs small jitter. Beyond that you are trading added delay against gaps from discarded packets. Mobile data paths, where jitter changes by the minute, are where adaptive jitter buffers help most.
Loss is the fraction of packets that never play, including the late ones the jitter buffer dropped. At 20 ms packetisation you send 50 packets a second, so 1% loss is one 20 ms hole every two seconds. Whether that is audible depends on two things:
Distribution. 2% loss scattered randomly can mostly be concealed. 2% concentrated in bursts deletes whole syllables. Mobile networks produce burst loss: a radio handover or a congested cell drops a run of packets, not one in fifty.
Codec resilience. G.711 only has basic receiver-side concealment for short gaps. Opus embeds forward error correction, so a single lost packet is rebuilt from its neighbours, which makes Opus the better choice on a lossy mobile data path.
Indistinguishable from a good landline; rare end-to-end over mobile
4.0 to 4.3
80 to 90
Toll quality; nobody repeats anything
3.5 to 4.0
70 to 80
Audible artefacts; conversation flows with occasional effort
3.1 to 3.5
60 to 70
Amounts and names get repeated; IVR menus get replayed
Below 3.1
Below 60
Callers talk over each other, mishear, or give up
The table shows why 3.5 is a sensible floor for business calls. At 4.2, a payment-reminder call states a KES amount once and the customer hears it. At 3.5, the customer asks for the amount again. At 3.2, the reference number in your script is misheard, the customer pays the wrong amount, and the call has created a support ticket.
Scripted calls suffer more from marginal quality than human conversations do. A human agent repeats and rephrases without thinking about it. A say action reads "your balance is KES 4,850" once, and if a burst of packet loss lands on "850" the customer cannot work it out from context.
The part of the path that varies most is the caller's radio leg. Congestion follows the clock: evenings are busier than mid-morning. Coverage follows the map: our network realities briefing covers the areas still served mainly by 2G, where audio quality and in-band DTMF both degrade. The phone network also sets a ceiling: once a call has crossed a G.711 leg, no codec choice on your side recovers what was discarded.
So attach time of day, carrier, and rough location to every quality number you collect. A single average MOS across all your traffic hides the pattern you are looking for.
Sautikit gives you call outcomes: statuses, durations and hangup causes, through the API and per-number events. Per-call jitter and loss belong to the media path, and you measure them at the endpoints you control. A workable programme has three layers.
If you run a browser softphone, WebRTC gives you the numbers: RTCPeerConnection.getStats() exposes jitter, packets lost, and round-trip time for the leg between the browser and Sautikit. Log a stats snapshot every 10 seconds during calls and ship it with your application telemetry. SIP softphones and PBXs log the RTCP metrics both ends exchange. This only covers your own segment, but it is the segment you can fix, so measure it first.
For the end-to-end path, use the method behind MOS in a simpler form. Place automated test calls to real handsets on each carrier at fixed hours, play a reference script, and have the listener score each call from 1 to 5. Fill the script with what is hardest to hear: digit strings, KES amounts, and both English and Swahili sentences, the same material your production calls carry.
// Cron at 08:00, 13:00 and 19:00 EAT: one scored test call per listener.// The number's voice_callback_url serves the reference script (say/play actions).const hourKey = new Date().toISOString().slice(0, 13); // "2026-09-22T16"const res = await fetch("https://api.sautikit.com/v1/calls", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`, "Content-Type": "application/json", // A re-run of the job in the same hour does not call the listener twice. "Idempotency-Key": `qa-call:${listener.msisdn}:${hourKey}`, }, body: JSON.stringify({ from: process.env.QA_NUMBER, to: [listener.msisdn] }),});const { call_id } = await res.json();// Listener logs: { call_id, hour: hourKey, carrier, location, score: 1-5 }
Three listeners across carriers and locations, three time slots and five working days give you 45 scored calls a week. A 40-second test call costs KES 2.00, so the week costs about KES 90. The sample is small, but evening congestion and a weak carrier in one area are large effects that show in the scores quickly.
Quality problems show up in ordinary call data before anyone complains:
Short-call rate. Many completed calls under 10 seconds are calls where the person answered, could not hear properly and hung up.
No-input loops. Rising getDigits retries and menu replays often mean callers could not hear the prompt. See the DTMF reliability post for the detection side.
Complaint rate. By the time "the line was bad" reaches support, the panel above would have caught it days earlier. Track it anyway to check your other metrics against.
For call counts and minutes by hour over the week your panel scored, GET /v1/calls/stats returns the totals and, with bucket=hour, an hourly series:
Short-call rate is something you count yourself. Set the number's events_url and bucket call.completed events per hour. The event kind is in the X-Sautikit-Event-Kind header and the body is flat JSON:
app.post("/events", express.raw({ type: "application/json" }), (req, res) => { // Verify X-Sautikit-Signature against the raw body before trusting it. const kind = req.headers["x-sautikit-event-kind"]; const event = JSON.parse(req.body); if (kind === "call.completed") { const hour = event.ended_at.slice(0, 13); const b = stats.get(hour) ?? { completed: 0, short: 0 }; b.completed += 1; if (event.duration_seconds < 10) b.short += 1; stats.set(hour, b); } res.sendStatus(200);});
Chart short-call rate by hour next to your panel scores. When the two move together, you can trust the short-call rate as an early warning.
Start at the top. Each step is cheaper than the one after it.
Fix the codec on the segment you control. If your softphone or WebRTC leg rides mobile data, prefer Opus with FEC enabled and G.711 as fallback.
Right-size the jitter buffer. Prefer adaptive buffers where the client offers them (WebRTC's is adaptive by default). For fixed buffers, start around 40 ms and tune against your measured jitter. Every extra millisecond of buffer is a millisecond of delay in the conversation, so do not solve a 20 ms jitter problem with a 120 ms buffer.
Slow the script down. Pacing costs nothing: use short sentences, repeat amounts once, read digits in pairs ("four eight, two one"), and add a getDigits confirmation for anything involving money. A prompt that can be understood on a 3.4 call is cheaper than the infrastructure work to guarantee 3.9.
Move the traffic. If your panel shows an evening dip, stop dialling into it. Campaign calls should retry in a different hour, not into the same congested cell 60 seconds later. Broadcast scheduling windows, retry_on and backoff_minutes do this for you.
Escalate with evidence. Poor quality on one carrier only, at all hours, in one region, is a network problem that no change in your application will fix. Before you contact Sautikit support, collect the call IDs, timestamps in EAT, both numbers, durations and hangup causes, your endpoint's jitter and loss logs, and the geographic pattern. A dated table of scored test calls gets a carrier ticket moving much faster than "customers say calls sound bad".
To start measuring, claim a Kenyan number for KES 500 a month, point its events_url at the collector above, and schedule your first week of scored test calls.