---
title: >-
  Cut Zendesk voice costs 90% for Kenyan support: a native Sautikit app on Talk
  Partner Edition
description: >-
  Build a native Zendesk App (ZAF) softphone on Talk Partner Edition. Screen
  pop, click-to-call, and voice tickets via Zendesk's own APIs, KES-billed.
summary: >-
  Replace Zendesk Talk with a native Zendesk App built on the Apps Framework and
  Talk Partner Edition. Sautikit handles voice at KES 3/min outbound, inbound
  free, while Zendesk stays the agent UI.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

Zendesk is a capable support platform, and it already ships the extension points you need for third-party telephony: the [Zendesk Apps Framework (ZAF)](https://developer.zendesk.com/documentation/apps/app-developer-guide/using-the-apps-framework/) for the agent-side softphone UI, and [Talk Partner Edition (TPE)](https://developer.zendesk.com/documentation/voice/talk-partner-edition/integrating-telephony-systems-with-talk-partner-edition/) 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%.

## The Cost Reality for a 10-Agent Kenyan Team

The numbers below are based on public pricing as of 2026-06-30, qualifying all third-party pricing as subject to change:

**Zendesk Talk Partner Edition + Suite Professional (public pricing as of 2026-06-30):**
- Zendesk Suite Professional: approximately USD 99/agent/month × 10 agents = USD 990/month
- Zendesk Talk Partner Edition: approximately USD 9/agent/month (additional) × 10 agents = USD 90/month
- 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 1 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.

## Architecture: A Native App, No Self-Hosted Relay

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:

1. **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.
2. **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](https://developer.zendesk.com/documentation/webhooks/), 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.

<!-- diagram omitted -->

## Building the ZAF App

Scaffold and iterate locally with [ZCLI](https://developer.zendesk.com/documentation/apps/getting-started/using-zcli/) (the successor to ZAT):

```bash
npm install @zendesk/zcli -g
zcli login -i                 # authenticate against your subdomain
zcli apps:new                 # scaffold manifest.json + assets/ + translations/
zcli apps:server              # local dev server; add ?zcli_apps=true to a Zendesk URL to load it
```

### App Manifest (`manifest.json`)

The manifest declares two locations: a persistent `top_bar` softphone (Zendesk's [recommended pattern for a web softphone](https://developer.zendesk.com/documentation/voice/talk-partner-edition/embedding-a-softphone-in-support/)) 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.

```json
{
  "name": "Sautikit Voice",
  "author": {
    "name": "Your Company",
    "email": "support@yourcompany.com"
  },
  "version": "1.0.0",
  "frameworkVersion": "2.0",
  "domainWhitelist": ["api.sautikit.com"],
  "location": {
    "support": {
      "top_bar": {
        "url": "assets/softphone.html",
        "size": { "width": "380px", "height": "460px" }
      },
      "ticket_sidebar": {
        "url": "assets/ticket_sidebar.html",
        "size": { "width": "100%", "height": "120px" }
      }
    }
  },
  "parameters": [
    {
      "name": "sautikitApiKey",
      "type": "text",
      "secure": true,
      "required": true
    },
    {
      "name": "sautikitOutboundNumber",
      "type": "text",
      "required": true
    }
  ]
}
```

### Click-to-Call from a Ticket (`assets/ticket_sidebar.html`)

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.

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Sautikit Call</title>
  <script src="https://static.zdassets.com/zendesk_app_framework_sdk/2.0/zaf_sdk.min.js"></script>
  <style>
    body { font-family: sans-serif; margin: 12px; }
    #call-btn { padding: 8px 16px; background: #1f73b7; color: #fff; border: none;
                border-radius: 4px; cursor: pointer; width: 100%; font-size: 14px; }
    #call-btn:disabled { background: #aaa; cursor: not-allowed; }
    #status { margin-top: 8px; font-size: 13px; color: #555; }
  </style>
</head>
<body>
  <button id="call-btn">Call requester</button>
  <div id="status">Ready</div>

  <script>
    const client = ZAFClient.init();
    let requesterPhone = null;

    async function loadContext() {
      const data = await client.get(["ticket.requester.phone", "ticket.id"]);
      requesterPhone = data["ticket.requester.phone"];
      if (!requesterPhone) {
        document.getElementById("status").textContent = "No phone number on requester";
        document.getElementById("call-btn").disabled = true;
      }
    }

    async function placeCall() {
      const btn = document.getElementById("call-btn");
      const status = document.getElementById("status");
      btn.disabled = true;
      status.textContent = "Placing call…";

      const settings = await client.metadata();
      const { ticket } = await client.get("ticket");

      try {
        // Proxied through Zendesk: the secure setting is injected server-side.
        await client.request({
          url: "https://api.sautikit.com/v1/calls",
          type: "POST",
          contentType: "application/json",
          secure: true,
          headers: { Authorization: "Bearer {{setting.sautikitApiKey}}" },
          data: JSON.stringify({
            from: settings.settings.sautikitOutboundNumber,
            to: [requesterPhone],
            voice_callback_url: "https://voice.yourcompany.example/sautikit/outbound",
            metadata: { zendesk_ticket_id: String(ticket.id) }
          })
        });
        status.textContent = "Calling…";
      } catch (err) {
        status.textContent = "Call failed. Try again.";
        btn.disabled = false;
      }
    }

    client.on("app.registered", loadContext);
    document.getElementById("call-btn").addEventListener("click", placeCall);
  </script>
</body>
</html>
```

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', ...)`](https://developer.zendesk.com/api-reference/apps/apps-core-api/core_api/#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)`.

## Inbound: Sautikit → a Native Voice Ticket via TPE

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):

```javascript
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](https://developer.zendesk.com/documentation/voice/talk-partner-edition/guidelines/), create the ticket early (at `call.answered`) so triggers and SLAs apply, then enrich it as the call progresses.

## Screen Pop: Route the Agent to the Right Ticket

Once the ticket exists, pop it in the answering agent's browser. TPE exposes a native display endpoint, so no ZAF round-trip is needed:

```javascript
async function screenPop({ agentId, ticketId }) {
  await fetch(
    `https://${ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/channels/voice/agents/${agentId}/tickets/${ticketId}/display.json`,
    { method: "POST", headers: { Authorization: zendeskAuthHeader() } }
  );
}
```

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.

## Missed Call → Callback Ticket

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:

```javascript
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.

## Agent Availability Routing

When agents go offline, reroute or pause inbound calls on the Sautikit side. Zendesk exposes real-time agent state through its [Availability API](https://developer.zendesk.com/api-reference/voice/talk-api/availabilities/). Your Sautikit `voice_callback_url` queries availability at call time and returns Sautikit voice actions to steer the flow:

```javascript
// 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,
        },
      },
    ],
  });
}
```

## Deploy the App

Once the app runs correctly against `zcli apps:server`, package and install it as a private app:

```bash
zcli apps:validate .          # lint the manifest and locations
zcli 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`.

## Migration Checklist

When moving from Zendesk Talk to a native Sautikit app:

1. Provision a Sautikit local Nairobi number (KES 100/month ex. VAT) to replace the Zendesk Talk number
2. Port your existing number if needed; contact Sautikit support for porting assistance
3. 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
4. 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
5. Build and install the ZAF app with `zcli apps:create`, supplying the secure `sautikitApiKey` and `sautikitOutboundNumber`
6. 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
7. 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](https://helloduty.com), the multi-channel CX platform Sautikit is part of, adds those channels without leaving your local carrier routing behind.

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Deploy the voice callback, install the Zendesk App with `zcli apps:create`, and let your first inbound call open a native voice ticket automatically.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

## Next Steps

The [webhooks concept page](/developers/concepts/webhooks) covers event kinds, retry schedule (30s → 2m → 10m → 30m → 2h → 6h → 24h → 7d), and signature verification. The [voice actions reference](/developers/concepts/voice-actions) documents `say`, `dial`, `getDigits`, `record`, and `redirect`. Full Sautikit pricing is at [/pricing](/pricing).
