Kenya's logistics sector, from Nairobi boda-boda networks to upcountry courier routes, runs on voice. Dispatch calls, ETA notifications, failed-delivery callbacks, and post-delivery rating are all voice interactions. This post maps the four voice touchpoints in a Kenyan last-mile delivery flow and shows how to automate each one with Sautikit, including a KES cost breakdown for a 4-touch delivery flow.
Kenyan logistics software typically handles order creation, routing optimisation, and proof-of-delivery, but the human coordination layer between that software and the riders on the road still runs on phone calls. A dispatch manager calls a boda-boda rider to assign a job. A customer calls to ask where their parcel is. The rider calls back to say the customer wasn't home.
Each of these interactions is a point of friction: manual, undocumented, and dependent on a human being available to make or answer the call. Voice automation closes these gaps without requiring riders or customers to install an app or have data connectivity.
The four automation targets:
Dispatch call: inform the rider of a new job, capture acceptance via DTMF
ETA notification: proactive outbound call to the customer before arrival
Failed delivery callback: detect no-answer on delivery, reschedule
Post-delivery NPS: collect a 1–5 rating from the customer via keypress
Boda-boda riders frequently use feature phones with no data connectivity. A dispatch app that sends push notifications reaches smartphones, typically higher-earning riders in urban areas. Voice reaches everyone.
The dispatch call reads the order details aloud and captures a DTMF acceptance. If the rider presses 1, the order is confirmed. If they press 2 or don't respond, the system queues the job for the next available rider.
// Place a dispatch call to a rider. Returns the Sautikit call object.// riderPhone must be in E.164 format, e.g. +254712345678async function dispatchRider(riderPhone, order, jobId) { const response = await fetch("https://api.sautikit.com/v1/calls", { method: "POST", headers: { Authorization: "Bearer " + process.env.SAUTIKIT_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ from: process.env.SAUTIKIT_DISPATCH_NUMBER, to: [riderPhone], voice_callback_url: `${process.env.BASE_URL}/webhooks/dispatch-voice`, }), }); if (!response.ok) { throw new Error(`Sautikit call failed: ${response.status}`); } return response.json();}
The voice webhook returns the order details and captures acceptance:
// POST /webhooks/dispatch-voiceimport express from "express";const app = express();app.use(express.json());app.use(express.urlencoded({ extended: true }));app.post("/webhooks/dispatch-voice", async (req, res) => { const sessionId = req.body.sessionId; const order = await getOrderBySession(sessionId); // your lookup res.json({ actions: [ { say: { text: `New job available. Pick up at ${order.pickup_area}. ` + `Deliver to ${order.delivery_area}. ` + `Distance approximately ${order.distance_km} kilometres. ` + `Payment K E S ${order.rider_fee}. ` + "Press 1 to accept. Press 2 to decline.", language: "en-KE", }, }, { getDigits: { numDigits: 1, timeout: 8000, finishOnKey: "", action: `${process.env.BASE_URL}/webhooks/dispatch-accept`, }, }, ], });});
The language="en-KE" parameter selects a Kenyan English voice model. User research from a Nairobi logistics company found that en-KE produced 15% higher comprehension rates than en-GB for ETA and dispatch notifications: British English accent and phrasing for place names like "Githurai" or "Rongai" is less familiar to the average Kenyan listener than Kenyan English.
The dispatch acceptance webhook records the rider's choice:
When a rider marks an order as "en route" and the estimated arrival is 30 minutes away, trigger an outbound call to the customer. This reduces failed deliveries caused by customers leaving their pickup location before the rider arrives.
app.post("/webhooks/eta-voice", (req, res) => { // Sautikit passes metadata fields in the webhook body const metadata = req.body.metadata ?? {}; const riderName = metadata.rider_name ?? "your rider"; const eta = metadata.eta_minutes ?? "30"; res.json({ actions: [ { say: { text: "Hello. This is a delivery notification. " + `${riderName} will arrive with your parcel in approximately ` + `${eta} minutes. Please ensure someone is available to receive it. ` + "Press 1 if you will be available. Press 2 if you need to reschedule.", language: "en-KE", }, }, { getDigits: { numDigits: 1, timeout: 10000, finishOnKey: "", action: `${process.env.BASE_URL}/webhooks/eta-confirm`, }, }, ], });});
After a successful delivery, wait 10 minutes and place a short rating call. A single DTMF question ("Rate your delivery from 1 to 5, then press hash") collects a numeric NPS proxy without requiring the customer to open an app.
app.post("/webhooks/nps-voice", (req, res) => { res.json({ actions: [ { say: { text: "Hello. Your delivery is complete. " + "To help us improve, please rate your delivery experience. " + "Press a number from 1 to 5, where 1 is poor and 5 is excellent, " + "then press the hash key.", language: "en-KE", }, }, { getDigits: { numDigits: 1, timeout: 12000, finishOnKey: "#", action: `${process.env.BASE_URL}/webhooks/nps-collect`, }, }, ], });});
The NPS collection webhook writes the rating and computes a rolling 7-day score:
app.post("/webhooks/nps-collect", async (req, res) => { const digit = req.body.Digits ?? ""; const sessionId = req.body.sessionId; const rating = Number(digit); if (digit && Number.isInteger(rating) && rating >= 1 && rating <= 5) { const orderId = await getOrderBySession(sessionId); await saveNpsRating(orderId, rating); } res.json({ actions: [ { say: { text: "Thank you for your feedback. Have a great day.", language: "en-KE", }, }, ], });});
The SQL query for a rolling 7-day delivery NPS score by zone:
SELECT delivery_zone, ROUND(AVG(rating)::numeric, 2) AS avg_rating, COUNT(*) AS total_ratings, SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END)::float / NULLIF(COUNT(*), 0) * 100 AS pct_positiveFROM delivery_ratingsWHERE created_at >= NOW() - INTERVAL '7 days' AND rating IS NOT NULLGROUP BY delivery_zoneORDER BY avg_rating ASC;
The four patterns above share a single event-driven architecture: an order state machine emits events, a notification worker picks them up, and places Sautikit calls.
Order State Machine
│
├── order.assigned ──► Dispatch call to rider
├── order.en_route ──► ETA notification to customer (at T-30 min)
├── delivery.failed ──► Schedule redelivery callback (T+30 min)
└── delivery.done ──► NPS call (T+10 min)
Each voice webhook is stateless: it receives the call session ID, looks up the relevant order via your internal mapping, and returns the appropriate actions JSON. The Sautikit call's metadata field passes order context through the call lifecycle so you can correlate call.completed events back to specific deliveries.
3 successful touches × average 22 seconds = 66 seconds total = KES 3.30 per delivery
For a delivery with one failed attempt:
4 touches × average 24 seconds = 96 seconds = KES 4.80 per delivery
At a dispatch platform processing 5 000 deliveries/month, the voice notification budget is approximately KES 16 500–24 000/month, roughly the cost of one part-time dispatcher spending 2 hours/day on manual coordination calls.
The difference is that automated voice creates an auditable record of every interaction, captures structured DTMF responses that update the order state machine in real time, and reaches feature-phone riders without requiring any app installation or data connectivity.
When a customer presses to reach a person, or you need to add SMS parcel receipts, WhatsApp tracking updates, or a full agent desk on top of these voice flows, Helloduty extends the same delivery journey across every channel.
For more on the pricing model and recording storage tiers, see the pricing page. The voice actions reference documents all available verbs including GetDigits, Say, and Redirect.