Zendesk is a capable support platform, and it already ships the extension points you need for third-party telephony: the Zendesk Apps Framework (ZAF) for the agent-side softphone UI, and Talk Partner Edition (TPE) for wiring an external voice provider into the Support workspace as a first-class channel. This post shows how to build a native Zendesk App on top of those two frameworks so Sautikit becomes your voice layer (screen pop, click-to-call, and voice tickets) while Zendesk stays the agent UI and system of record. For a 10-agent Kenyan team, this cuts the monthly voice cost by roughly 90%.
Outbound calls to Kenyan numbers: approximately USD 0.014/min (varies by carrier routing)
At 200 minutes of outbound calls per day × 22 working days: 4,400 min/month × USD 0.014 ≈ USD 62/month in calls
Total voice-related cost: approximately USD 152/month on top of the USD 990 Suite licence
That total, around USD 1,140/month (approximately KES 148,000 at current rates) for 10 agents, does not include inbound call costs or recording storage.
Sautikit equivalent (as of 2026-06-30):
You keep Zendesk Suite Professional for the ticketing/agent UI: USD 990/month
Sautikit outbound: KES 3/min (KES 0.05/sec) × 4,400 min/month = KES 13,200/month
Sautikit local Nairobi number: KES 100/month (ex. VAT; KES 116 incl. VAT)
Sautikit inbound: free (KES 0/min, no per-agent fee)
Recording: KES 0.50/min, first 5 GB storage free
Total Sautikit voice cost: approximately KES 13,300/month ≈ USD 102
You still pay Zendesk for the Suite. But the voice component drops from ~USD 152/month to ~USD 102/month, and critically, you gain: KES-denominated billing, M-Pesa top-up, no per-agent voice licensing, free inbound calls, and local Kenyan carrier routing which reduces dropped calls on Safaricom and Airtel networks.
For teams whose outbound volume is higher, say 500 minutes/day, the savings widen significantly.
Zendesk has no server runtime of its own: a ZAF app is a static bundle (HTML/JS/CSS) that runs in a sandboxed iframe inside the agent's browser. So the integration splits into two native halves, and neither requires a self-hosted middleware server proxying between the two products:
Agent side (ZAF app): a top_bar softphone and a ticket_sidebar click-to-call button. The app talks to Sautikit's REST API through Zendesk's own request proxy, client.request({ secure: true, ... }), so the Sautikit key never touches the browser. It talks to Zendesk through the same client.request against relative /api/v2/... paths, which the framework authenticates as the signed-in agent.
Inbound side (Sautikit → Zendesk): when a call lands on a Sautikit number, Sautikit posts to your Sautikit-hosted voice_callback_url to control the call flow, and creates the voice ticket by calling Zendesk's own Talk Partner Edition API (POST /api/v2/channels/voice/tickets). There is no Sautikit-owned relay sitting inside Zendesk. If you need Zendesk to react to a ticket event (e.g. fire an outbound webhook on solve), you use Zendesk's native webhooks and triggers, not a bespoke server.
Agents continue to work in Zendesk. The Sautikit voice interaction appears as a native voice ticket with a recording link and call metadata, so there is no new UI to learn.
Scaffold and iterate locally with ZCLI (the successor to ZAT):
npm install @zendesk/zcli -gzcli login -i # authenticate against your subdomainzcli apps:new # scaffold manifest.json + assets/ + translations/zcli apps:server # local dev server; add ?zcli_apps=true to a Zendesk URL to load it
The manifest declares two locations: a persistent top_bar softphone (Zendesk's recommended pattern for a web softphone) and a ticket_sidebar for click-to-call from an open ticket. The Sautikit key is a secure parameter, and domainWhitelist authorises the proxy to reach the Sautikit API.
The sidebar app reads the requester's phone from ticket context with client.get, then places the call. The Sautikit request goes through the framework proxy with secure: true; the {{setting.sautikitApiKey}} placeholder is substituted by Zendesk's server, never exposed in the browser's dev tools.
The persistent top_bar softphone (assets/softphone.html) follows the same client.request({ secure: true }) pattern for call controls (hold, mute, hang up) and uses client.invoke('resize', ...) to expand from the collapsed bar into the full dialpad. Because top_bar only has user-level context (not ticket context), screen pop for inbound calls is driven server-side by TPE (below), then surfaced in the app with client.invoke('routeTo', 'ticket', ticketId).
When a call reaches a Sautikit number, two things happen in parallel. Sautikit posts to your voice_callback_url to drive the call flow (answer, say, dial), and, because you want the ticket to exist early enough for Zendesk triggers to fire, it also creates a native voice ticket through Talk Partner Edition.
TPE voice tickets are not generic tickets: they use the dedicated endpoint POST /api/v2/channels/voice/tickets with a via_id that marks the channel (45 inbound call, 46 outbound call, 44 voicemail) and a voice_comment object carrying the call metadata Zendesk renders natively. Authentication is a Zendesk API token via HTTP Basic ({email}/token:{api_token}).
The signature-verified handler below runs on your Sautikit-hosted voice callback (a serverless function or small service; it belongs to your infrastructure, not Zendesk's):
import crypto from "node:crypto";const SAUTIKIT_WEBHOOK_SECRET = process.env.SAUTIKIT_WEBHOOK_SECRET;const ZENDESK_SUBDOMAIN = process.env.ZENDESK_SUBDOMAIN;const ZENDESK_EMAIL = process.env.ZENDESK_EMAIL;const ZENDESK_API_TOKEN = process.env.ZENDESK_API_TOKEN;function verifySignature(rawBody, signatureHeader) { const parts = Object.fromEntries( signatureHeader.split(",").map((s) => s.split("=")) ); const { t, v1 } = parts; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = crypto .createHmac("sha256", SAUTIKIT_WEBHOOK_SECRET) .update(`${rawBody}.${t}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(v1, "hex") );}function zendeskAuthHeader() { const token = Buffer .from(`${ZENDESK_EMAIL}/token:${ZENDESK_API_TOKEN}`) .toString("base64"); return `Basic ${token}`;}// Create a native voice ticket through Talk Partner Edition.// The voice_comment (from, to, started_at, recording_type, answered_by_id)// is what makes Zendesk render this as a native call rather than a text ticket.async function createVoiceTicket({ from, to, startedAt, viaId, answeredById,}) { const resp = await fetch( `https://${ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/channels/voice/tickets.json`, { method: "POST", headers: { Authorization: zendeskAuthHeader(), "Content-Type": "application/json", }, body: JSON.stringify({ ticket: { via_id: viaId, // 45 = inbound call subject: `Inbound call from ${from}`, tags: ["sautikit_voice", "inbound_call"], voice_comment: { from, to, recording_type: "call", started_at: startedAt, call_duration: 0, // enriched on recording.ready answered_by_id: answeredById, location: "Nairobi, KE", }, }, }), } ); if (!resp.ok) { throw new Error(`TPE voice ticket failed: ${resp.status} ${await resp.text()}`); } return resp.json();}// Append a voice comment (recording, final duration) to an existing voice ticket.async function addVoiceComment({ ticketId, body }) { await fetch( `https://${ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/${ticketId}.json`, { method: "PUT", headers: { Authorization: zendeskAuthHeader(), "Content-Type": "application/json", }, body: JSON.stringify({ ticket: { comment: { body, public: false } }, }), } );}// call_id -> ticket_id (use Redis/Durable KV in production)const callToTicket = new Map();export async function handler(req) { const rawBody = await req.text(); const sig = req.headers.get("x-sautikit-signature") || ""; if (!verifySignature(rawBody, sig)) { return new Response(null, { status: 401 }); } const event = JSON.parse(rawBody); const { event: kind, call_id, from, to, direction, duration_seconds, answered_by_id, } = event; try { if (kind === "call.answered" && direction === "inbound") { const result = await createVoiceTicket({ from, to, startedAt: new Date().toISOString(), viaId: 45, answeredById: answered_by_id, }); callToTicket.set(call_id, result.ticket.id); } if (kind === "call.completed") { const ticketId = callToTicket.get(call_id); if (ticketId) { await addVoiceComment({ ticketId, body: `Call ended. Duration: ${duration_seconds}s. Status: ${event.status}`, }); } } if (kind === "recording.ready") { const ticketId = callToTicket.get(call_id) || event.metadata?.zendesk_ticket_id; if (ticketId && event.recording_url) { await addVoiceComment({ ticketId, body: `Call recording available: ${event.recording_url}`, }); } } return new Response(null, { status: 200 }); } catch (err) { console.error("Voice callback error:", err); return new Response(null, { status: 500 }); }}
The voice_comment object is what makes the ticket render as a native voice interaction in the agent workspace, rather than a plain text ticket. Following Zendesk's CTI guidelines, create the ticket early (at call.answered) so triggers and SLAs apply, then enrich it as the call progresses.
To pop a customer profile instead of a ticket, use .../users/{user_id}/display.json. From inside the softphone app you can achieve the same in-browser jump with client.invoke('routeTo', 'ticket', ticketId) once you know the id.
For missed inbound calls, Sautikit fires call.failed with a status of no_answer or busy. Create a callback ticket so agents can follow up. Because the caller left no recording, this is a plain inbound voice ticket tagged for a callback View:
if (kind === "call.failed") { await createVoiceTicket({ from, to, startedAt: new Date().toISOString(), viaId: 45, }).then(({ ticket }) => addVoiceComment({ ticketId: ticket.id, body: `Missed call from ${from} (${event.status}). Please call back.`, }) );}
Tag these tickets with callback_needed so a Zendesk View surfaces them as a callback queue.
When agents go offline, reroute or pause inbound calls on the Sautikit side. Zendesk exposes real-time agent state through its Availability API. Your Sautikit voice_callback_url queries availability at call time and returns Sautikit voice actions to steer the flow:
// Called by your voice_callback_url when an inbound call arrives.export async function inboundRouter(req) { const onlineAgents = await fetchAvailableZendeskAgents(); if (onlineAgents.length === 0) { return Response.json({ actions: [ { say: { text: "All agents are currently unavailable. Please call back during business hours or press 1 to leave a message.", language: "en-KE", }, }, { getDigits: { numDigits: 1, timeout: 8000, finishOnKey: "", action: "https://voice.yourcompany.example/sautikit/voicemail", }, }, ], }); } const body = await req.json(); return Response.json({ actions: [ { say: { text: "Thank you for calling. Connecting you to an agent now.", language: "en-KE", }, }, { dial: { to: onlineAgents[0].phone, callerId: body.to, timeout: 25, }, }, ], });}
Once the app runs correctly against zcli apps:server, package and install it as a private app:
zcli apps:validate . # lint the manifest and locationszcli apps:create . # package + upload + install as a private app
zcli apps:create builds the ZIP, uploads it, and installs it into the active profile's Zendesk instance, prompting for the app settings, including the secure sautikitApiKey, which is stored encrypted and only ever referenced via {{setting.sautikitApiKey}}. To ship updates later, use zcli apps:update.
When moving from Zendesk Talk to a native Sautikit app:
Provision a Sautikit local Nairobi number (KES 100/month ex. VAT) to replace the Zendesk Talk number
Port your existing number if needed; contact Sautikit support for porting assistance
Deploy your Sautikit voice callback (serverless or small service) and set its URL as the number's voice_callback_url in the Sautikit developer console
Create a Zendesk API token and an agent for the TPE integration; set ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN, and SAUTIKIT_WEBHOOK_SECRET in your callback's environment
Build and install the ZAF app with zcli apps:create, supplying the secure sautikitApiKey and sautikitOutboundNumber
Test with a single agent (verify click-to-call, an inbound voice ticket, screen pop, and a recording comment) before rolling out to the team
Disable Zendesk Talk on your account to avoid double charges
Sautikit replaces Zendesk Talk with KES-billed voice. When you want to consolidate SMS, WhatsApp, USSD, and a full human-agent desk under one platform, Helloduty, the multi-channel CX platform Sautikit is part of, adds those channels without leaving your local carrier routing behind.