---
title: >-
  Add KES-billed click-to-call and auto callback tickets to Freshdesk with a
  native Sautikit FDK app
description: >-
  Package Sautikit voice as a Freshworks FDK app: serverless external-event
  handlers create tickets, and a CTI sidebar does click-to-call.
summary: >-
  Ship Sautikit voice as a native Freshworks (FDK) marketplace app: serverless
  external-event handlers auto-create callback tickets, and a cti_global_sidebar
  softphone places click-to-call over SMI.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

Freshdesk is a support platform used by Kenyan SMEs and enterprises for ticket management and customer communication. This post shows how to ship Sautikit voice as a **native Freshworks app** built with the Freshworks Development Kit (FDK), not an external relay you host yourself. Serverless event handlers in `server/server.js` receive Sautikit webhooks and create callback tickets through the Freshdesk API. A CTI softphone rendered in the `cti_global_sidebar` gives agents click-to-call over Serverless Method Invocation (SMI). Everything runs inside Freshworks' own serverless runtime, and the whole thing installs from a single `.zip` (or the Freshworks Marketplace).

## Why a native FDK app

Freshdesk handles tickets, agent routing, and SLA management well. What it does not provide natively for Kenyan teams is KES-denominated telephony that routes over local Safaricom and Airtel carrier infrastructure. Freshdesk's native call integration options are priced in USD and billed per minute through international routing, which adds latency and cost for calls originating and terminating in the markets where Sautikit operates today.

Building the integration as an FDK app instead of a hosted middleware relay buys three things. The **serverless component runs inside Freshworks**: no server to provision, no relay to keep alive, and the Sautikit webhook lands on a Freshworks-generated target URL. Credentials live in **installation parameters (`iparams`)**, encrypted by Freshworks, never in your own environment. And the agent UI is a real **CTI app** in the `cti_global_sidebar` placeholder, so click-to-call and screen pop use the same interface the platform gives first-party telephony vendors.

<!-- diagram omitted -->

## App structure

An FDK app is a directory with a `manifest.json`, a frontend (`app/`), a serverless component (`server/server.js`), request templates (`config/requests.json`), and installation parameters (`config/iparams.json`). Scaffold it with the CLI:

```bash
npm install -g fdk
fdk create --products freshdesk --template your_first_serverless_app sautikit-voice
cd sautikit-voice
```

The `manifest.json` declares the product, the CTI placeholder for the frontend, the serverless events we listen to, and the registered SMI functions and request templates:

```json
{
  "platform-version": "3.0",
  "product": {
    "freshdesk": {
      "location": {
        "cti_global_sidebar": {
          "url": "index.html",
          "icon": "icon.svg"
        }
      },
      "events": {
        "onExternalEvent": { "handler": "onExternalEventHandler" },
        "onAppInstall": { "handler": "onAppInstallHandler" },
        "onAppUninstall": { "handler": "onAppUninstallHandler" }
      },
      "requests": {
        "createTicket": {},
        "addNote": {},
        "listAgents": {},
        "placeCall": {}
      },
      "functions": {
        "placeCall": {}
      }
    }
  },
  "engines": {
    "node": "18.16.0",
    "fdk": "9.0.0"
  }
}
```

## Installation parameters

The Sautikit API key and outbound number are stored as `iparams`. Freshworks encrypts anything marked `secure`, and secure values are only readable from the serverless component (never the browser) via request-template substitution.

```json
{
  "sautikit_api_key": {
    "display_name": "Sautikit API Key",
    "description": "Secret key from your Sautikit workspace settings.",
    "type": "text",
    "required": true,
    "secure": true
  },
  "sautikit_from_number": {
    "display_name": "Outbound Caller ID",
    "description": "The Sautikit number outbound calls originate from, e.g. +254203893800.",
    "type": "text",
    "required": true
  },
  "sautikit_webhook_secret": {
    "display_name": "Webhook Signing Secret",
    "description": "Used to verify inbound Sautikit webhook signatures.",
    "type": "text",
    "required": true,
    "secure": true
  }
}
```

## Request templates

Instead of raw `fetch` calls with hand-built auth headers, FDK apps declare outbound HTTP as **request templates** in `config/requests.json`. Templates keep secrets out of app code: Freshdesk substitutes `iparam` and `context` values at runtime, and `encode()` handles Basic-auth encoding for the Freshdesk API (API key as username, `X` as password).

```json
{
  "createTicket": {
    "schema": {
      "method": "POST",
      "host": "<%= iparam.freshdesk_domain %>",
      "path": "/api/v2/tickets",
      "headers": {
        "Authorization": "Basic <%= encode(iparam.freshdesk_api_key + ':X') %>",
        "Content-Type": "application/json"
      }
    }
  },
  "addNote": {
    "schema": {
      "method": "POST",
      "host": "<%= iparam.freshdesk_domain %>",
      "path": "/api/v2/tickets/<%= context.ticket_id %>/notes",
      "headers": {
        "Authorization": "Basic <%= encode(iparam.freshdesk_api_key + ':X') %>",
        "Content-Type": "application/json"
      }
    }
  },
  "listAgents": {
    "schema": {
      "method": "GET",
      "host": "<%= iparam.freshdesk_domain %>",
      "path": "/api/v2/agents",
      "headers": {
        "Authorization": "Basic <%= encode(iparam.freshdesk_api_key + ':X') %>"
      }
    }
  },
  "placeCall": {
    "schema": {
      "method": "POST",
      "host": "api.sautikit.com",
      "path": "/v1/calls",
      "headers": {
        "Authorization": "Bearer <%= iparam.sautikit_api_key %>",
        "Content-Type": "application/json"
      }
    }
  }
}
```

> Freshdesk API calls run against your own domain and API key, which we add as two more (non-secret / secure) `iparams`: `freshdesk_domain` (e.g. `yourcompany.freshdesk.com`) and `freshdesk_api_key`.

## Inbound: serverless external-event handler

Sautikit is registered as an **external event source** during app install. `generateTargetUrl()` returns a Freshworks-hosted URL that you save to your Sautikit workspace as the webhook endpoint; Freshworks receives the POST and dispatches it to `onExternalEventHandler`. Because the serverless runtime is native to Freshdesk, there is no relay to host.

```js
exports = {
  // Register the Sautikit webhook against a Freshworks-generated target URL.
  onAppInstallHandler: async function (payload) {
    try {
      const targetUrl = await generateTargetUrl();
      // Point your Sautikit workspace webhook at targetUrl (store its ID for cleanup).
      await $db.set("sautikit_target", { url: targetUrl });
      renderData();
    } catch (err) {
      renderData({ message: "Install failed: " + err.message });
    }
  },

  onAppUninstallHandler: async function () {
    await $db.delete("sautikit_target");
    renderData();
  },

  // Every Sautikit webhook lands here.
  onExternalEventHandler: async function (payload) {
    const iparams = payload.iparams;
    // For external events the framework hands you the parsed webhook body as
    // payload.data (no raw body, no request headers), so Sautikit carries its
    // HMAC signature and timestamp as fields inside the JSON payload itself.
    const body = typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;

    if (!verifySautikitSignature(body, iparams.sautikit_webhook_secret)) {
      console.error("Invalid Sautikit signature; dropping event");
      return;
    }

    const kind = body.event;
    const callId = body.call_id;
    const caller = body.from;
    const direction = body.direction || "inbound";

    if (kind === "call.answered" && direction === "inbound") {
      const ticket = await createTicket({
        subject: `Inbound call from ${caller}`,
        description: `Inbound call received from ${caller}.<br/>Sautikit Call ID: ${callId}`,
        phone: caller,
        tags: ["sautikit", "inbound_call"],
      });
      await $db.set(`call:${callId}`, { ticket_id: ticket.id });
    } else if (kind === "call.failed") {
      const status = body.status || "failed";
      const ticket = await createTicket({
        subject: `Missed call from ${caller}: callback required`,
        description:
          `A call from ${caller} was not answered (status: ${status}).<br/>` +
          `Please call this customer back.<br/>Sautikit Call ID: ${callId}`,
        phone: caller,
        tags: ["sautikit", "missed_call", "callback_needed"],
      });
      await $db.set(`call:${callId}`, { ticket_id: ticket.id });
      console.log(`Created callback ticket ${ticket.id} for missed call ${callId}`);
    } else if (kind === "call.completed") {
      const record = await getStored(`call:${callId}`);
      if (record) {
        await addNote(record.ticket_id, `Call ended. Duration: ${body.duration_seconds || 0}s.`);
      }
    } else if (kind === "recording.ready" && body.recording_url) {
      const record = await getStored(`call:${callId}`);
      if (record) {
        await addNote(
          record.ticket_id,
          `Call recording for call ${callId}.<br/>` +
            `<a href="${body.recording_url}">Listen to recording</a><br/>` +
            `URL is valid for 7 days.`,
        );
      }
    }
  },
};
```

The signature check and the Freshdesk API calls use Node's built-in `crypto` plus the FDK-native `$request` and `$db` primitives: no Node HTTP server, no Express. Because the external-events framework strips request headers, Sautikit signs the event over its own `call_id` and `signed_at` fields and includes the resulting HMAC as `body.signature`:

```js
const crypto = require("crypto");

function verifySautikitSignature(body, secret) {
  const { signature, signed_at: signedAt, call_id: callId } = body;
  if (!signature || !signedAt) return false;
  // Reject events older than 5 minutes to blunt replay.
  if (Math.abs(Date.now() / 1000 - Number(signedAt)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${signedAt}.${callId}.${body.event}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Freshdesk ticket create via the declared request template.
async function createTicket({ subject, description, phone, tags }) {
  const resp = await $request.invokeTemplate("createTicket", {
    body: JSON.stringify({
      subject,
      description,
      phone,
      status: 2,   // Open
      priority: 2, // Medium
      source: 5,   // Phone
      tags,
    }),
  });
  return JSON.parse(resp.response);
}

// Private note on a ticket; ticket_id flows in through the template context.
async function addNote(ticketId, noteBody) {
  const resp = await $request.invokeTemplate("addNote", {
    context: { ticket_id: ticketId },
    body: JSON.stringify({ body: noteBody, private: true }),
  });
  return JSON.parse(resp.response);
}

async function getStored(key) {
  try {
    return await $db.get(key);
  } catch (_e) {
    return null; // key not found
  }
}
```

`$db` is Freshworks' built-in per-installation key-value store; it replaces the in-memory `Map` (or Redis) a relay would need to correlate a `call_id` with its ticket across separate webhook deliveries.

## Outbound: CTI softphone + click-to-call over SMI

The frontend lives in the `cti_global_sidebar` placeholder. It listens for the platform's native `cti.triggerDialer` event (fired when an agent clicks a phone number anywhere in Freshdesk), pre-fills the number, and places the call by invoking the `placeCall` **SMI** function on the serverless component. The API key never touches the browser; the serverless side reads it from `iparams` through the request template.

```html
<!-- app/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Sautikit Softphone</title>
  <script src="{{{appclient}}}"></script>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <h3>Sautikit Dialer</h3>
  <input id="phone-input" type="tel" placeholder="+254712345678" />
  <button id="call-btn">Call</button>
  <div id="status"></div>
  <script src="scripts/app.js"></script>
</body>
</html>
```

```js
// app/scripts/app.js
let client;

init();

async function init() {
  client = await app.initialized();

  // Native CTI event: agent clicked a phone number somewhere in Freshdesk.
  client.events.on("cti.triggerDialer", function (event) {
    const data = event.helper.getData();
    document.getElementById("phone-input").value = data.number || "";
    client.interface.trigger("show", { id: "softphone" });
  });

  document.getElementById("call-btn").addEventListener("click", placeCall);
}

async function placeCall() {
  const phone = document.getElementById("phone-input").value.trim();
  const statusEl = document.getElementById("status");
  const btn = document.getElementById("call-btn");

  if (!phone) {
    statusEl.textContent = "Enter a phone number.";
    return;
  }

  btn.disabled = true;
  statusEl.textContent = "Placing call...";

  try {
    // Invoke the serverless SMI method; no secrets in the browser.
    const result = await client.request.invoke("placeCall", { to: phone });
    const call = JSON.parse(result.response);
    statusEl.textContent = "Call connected.";

    // Screen pop: if the event carried a ticket, jump the agent to it.
    if (call.metadata && call.metadata.freshdesk_ticket_id) {
      client.interface.trigger("click", {
        id: "ticket",
        value: Number(call.metadata.freshdesk_ticket_id),
      });
    }
  } catch (err) {
    statusEl.textContent = "Call failed. Try again.";
    btn.disabled = false;
    console.error(err);
  }
}
```

The `placeCall` SMI method in `server/server.js` calls Sautikit `/v1/calls` through the `placeCall` request template. `client.request.invoke("placeCall", …)` merges its payload into the `options` argument, and the serverless runtime adds an `iparams` object to that same payload, so installation parameters are read from `options.iparams`, never from a global:

```js
// Added to the exports = { … } object in server/server.js
  placeCall: async function (options) {
    try {
      const resp = await $request.invokeTemplate("placeCall", {
        body: JSON.stringify({
          from: options.iparams.sautikit_from_number, // runtime injects iparams into the payload
          to: [options.to],
          voice_callback_url: options.voice_callback_url,
        }),
      });
      renderData(null, JSON.parse(resp.response));
    } catch (err) {
      renderData({ status: 500, message: "Call failed: " + err.message });
    }
  },
```

`renderData(error, data)` is the SMI contract: the first argument is always the error object, and `data` is what the frontend receives as `result.response`.

## Agent-pool routing based on Freshdesk agent status

The inbound voice callback (`voice_callback_url`) can query Freshdesk for online agents and route the call. Point that callback at the same Freshworks target URL you registered in `onAppInstallHandler`: Sautikit's routing request arrives at `onExternalEventHandler` as another external event (`body.event === "call.routing"`), and you return the voice-action response with `renderData`. The routing logic below queries agents through the `listAgents` template.

```js
// Return agents flagged available on the Freshdesk Agents API.
async function getAvailableAgents() {
  const resp = await $request.invokeTemplate("listAgents", {});
  const agents = JSON.parse(resp.response);
  return agents.filter((a) => a.available === true);
}

// Freshdesk agents do not expose a phone field on the Agents API,
// so keep agent phone numbers in $db keyed by Freshdesk agent ID.
async function getAgentPhone(agentId) {
  const map = (await getStored("agent_phones")) || {};
  return map[String(agentId)] || "";
}

// Build the voice-action response for an inbound call.
async function routeInboundCall(toNumber) {
  const available = await getAvailableAgents();

  if (available.length === 0) {
    return {
      actions: [
        {
          say: {
            text:
              "Thank you for calling. All agents are currently unavailable. " +
              "Please call back during business hours, Monday to Friday, " +
              "8 a.m. to 6 p.m. Goodbye.",
            language: "en-KE",
          },
        },
        { hangup: {} },
      ],
    };
  }

  const agentPhone = await getAgentPhone(available[0].id);
  return {
    actions: [
      {
        say: {
          text: "Thank you for calling. Connecting you to an agent now.",
          language: "en-KE",
        },
      },
      { dial: { to: agentPhone, callerId: toNumber, timeout: 25 } },
    ],
  };
}
```

Freshdesk's Agents API reports availability via the `available` boolean but does not include an agent phone field, so store agent phone numbers in `$db` (keyed by Freshdesk agent ID) rather than expecting them from the API response.

## Attaching recordings

Because Sautikit serves recordings as presigned URLs, the simplest approach is the private note with a link shown in the external-event handler above. If you need the recording as an actual Freshdesk attachment, download it from the presigned URL inside the serverless method and re-upload as multipart via a dedicated request template. This download-and-reupload pattern is only appropriate for short recordings; for longer recordings (more than a few minutes), keep the note-with-URL approach to avoid hitting the serverless execution timeout.

## Freshdesk ticket tags for reporting

Use Freshdesk tags to segment voice tickets in reports:

| Tag | When applied | Freshdesk View purpose |
|---|---|---|
| `sautikit` | All Sautikit-originated tickets | Filter all voice tickets |
| `inbound_call` | `call.answered` inbound events | Inbound volume tracking |
| `missed_call` | `call.failed` events | SLA breach risk |
| `callback_needed` | Missed calls with no follow-up | Agent callback queue |

Create a Freshdesk View filtered by `tag:callback_needed AND status:Open` so agents see their callback queue without a separate tool.

Sautikit gives you locally-routed, KES-billed voice inside Freshdesk. When those tickets need a full agent desk plus SMS, WhatsApp, and USSD outreach, [Helloduty](https://helloduty.com), the multi-channel CX platform Sautikit is part of, provides those channels on the same numbers.

## Cost Reference

Sautikit pricing (as of 2026-07-03): KES 3/min outbound (KES 0.05/sec), inbound free (KES 0/min), KES 0.50/min recording, KES 100/month (ex. VAT; KES 116 incl. VAT) per local Nairobi number, 1 GB recording storage included. A 5-agent support team handling 100 inbound calls/day at any duration: KES 0 in inbound call costs, because inbound is free. Full pricing at [/pricing](/pricing).

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Run `fdk validate` and `fdk run` to test locally at `http://localhost:10001/web/test`, then `fdk pack` and upload the `.zip` as a custom app. Your first missed call auto-creates a callback ticket in Freshdesk.

**[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 the full event list, signature format, and retry schedule. The [voice actions reference](/developers/concepts/voice-actions) documents `say`, `dial`, `record`, `getDigits`, and `hangup`. For wallet management and M-Pesa top-up, see [/pricing](/pricing).
