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).
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.
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:
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:
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.
{ "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 }}
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).
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.
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.
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:
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.
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 placeCallSMI function on the serverless component. The API key never touches the browser; the serverless side reads it from iparams through the request template.
// app/scripts/app.jslet 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:
// 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.
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.
// 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.
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.
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, the multi-channel CX platform Sautikit is part of, provides those channels on the same numbers.
Sautikit pricing (as of 2026-06-30): 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, 5 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.
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.
The webhooks concept page covers the full event list, signature format, and retry schedule. The voice actions reference documents say, dial, record, getDigits, and hangup. For wallet management and M-Pesa top-up, see /pricing.