Salesforce Service Cloud stores your customer data; Sautikit carries the voice. This post shows how to wire them together using nothing but Salesforce's own stack: Apex and Lightning Web Components. Inbound Sautikit webhooks hit an Apex REST endpoint on a public Force.com Site, get their HMAC verified in Apex, and create Tasks on call.answered; recording URLs attach on recording.ready; agents click-to-call from a Lead or Contact record through a Named Credential; and inbound calls screen pop the matching record in real time over a Platform Event. There is no Node server, no relay, and no third-party middleware to host or secure.
Salesforce Service Cloud agents already handle cases, track customer interactions, and report on SLA compliance. When voice calls live outside that system, three things go wrong:
No screen pop. An agent answers a call without knowing who is calling or what they bought.
Manual call logging. Agents write notes after each call, creating lag and omissions.
Disconnected recordings. Call recordings sit in a separate system, inaccessible during a case review.
The Sautikit–Salesforce integration fixes all three by treating Sautikit as the telephony layer and Salesforce as the record of truth. And because every moving part runs inside your org (Apex classes, a Force.com Site, a Named Credential, and two LWCs), there is no separate application to deploy, scale, or patch.
The integration has three flows, and all of them are native:
Inbound webhooks → Apex REST. Sautikit posts events to an @RestResource class exposed on a public Force.com Site. Apex verifies the HMAC signature, then does native DML and publishes a Platform Event.
Outbound click-to-call → @AuraEnabled Apex + Named Credential. An LWC button calls an Apex method that makes an authenticated callout to the Sautikit API. The Bearer token lives in a Named Credential, never in code.
Real-time UI → Platform Event + lightning/empApi. A softphone LWC in the utility bar subscribes to the inbound Platform Event and screen pops the caller's record with NavigationMixin.
The only external surface is the Sautikit REST API itself. There is no fetch() from Node into the Salesforce REST API, and no self-hosted webhook receiver.
You do not need Open CTI for this integration. The real-time path in this post is a Platform Event consumed by a utility-bar Lightning Web Component through lightning/empApi; Open CTI is never involved. This section is included only for orgs that already standardize telephony partners on a Call Center record and want Sautikit listed there; you can skip it entirely.
If you do register a Call Center, be aware of what its adapter URL means. Open CTI's reqAdapterUrl (as of 2026-06-30, based on public Salesforce documentation) expects an externally hosted softphone page that Salesforce loads in an iframe. A /lightning/n/... tab path is not a valid adapter URL, and a softphone will not initialize from it. Building the softphone as a utility-bar LWC (below) sidesteps Open CTI altogether, so if you register a Call Center at all, treat its adapter URL as a placeholder that does not drive the real-time UI.
After importing, assign the Call Center to the relevant agent profiles under Setup → Manage Call Center Users. Remember that the adapter URL above is a placeholder: the real-time screen pop is driven entirely by the utility-bar softphone LWC and lightning/empApi (below), not by anything Open CTI loads from that URL. The Call Center record here only lists Sautikit as the org's telephony provider.
Sautikit delivers webhook events to a URL you control. Instead of a Node server, you expose an Apex REST resource on a Force.com Site and let Salesforce receive the POST directly.
Declare a global class with @RestResource. Read the raw body with RestContext.request.requestBody.toString(). You must verify the HMAC over the exact bytes Sautikit signed, so do not deserialize before verifying.
@RestResource(urlMapping='/sautikit/*')global with sharing class SautikitWebhookResource { @HttpPost global static void handleWebhook() { RestRequest req = RestContext.request; RestResponse res = RestContext.response; // Raw body, exactly as signed by Sautikit. String body = req.requestBody.toString(); String sigHeader = req.headers.get('X-Sautikit-Signature'); if (!SautikitSignature.verify(body, sigHeader)) { res.statusCode = 401; return; } Map<String, Object> event = (Map<String, Object>) JSON.deserializeUntyped(body); String kind = (String) event.get('event'); switch on kind { when 'call.answered' { handleCallAnswered(event); } when 'recording.ready' { handleRecordingReady(event); } when else { /* ignore unknown kinds */ } } res.statusCode = 200; } // handleCallAnswered / handleRecordingReady defined below}
The Site endpoint is public: the guest user has no session and anyone on the internet can POST to it. HMAC verification is what makes that safe: only Sautikit knows your signing secret, so a valid signature proves the payload is genuine and unmodified. Skipping this step would let anyone forge call.answered events and inject Tasks into your org.
Every Sautikit webhook carries an X-Sautikit-Signature header in the format t=<timestamp>,v1=<hex>. The HMAC-SHA256 is computed over body + "." + timestamp using your webhook signing secret. In Apex you generate the MAC with Crypto.generateMac, hex-encode it with EncodingUtil.convertToHex, and compare in constant time.
public with sharing class SautikitSignature { // Store the secret in Custom Metadata / a protected Custom Setting. private static String secret() { return Sautikit_Setting__mdt.getInstance('Default').Webhook_Secret__c; } public static Boolean verify(String body, String sigHeader) { if (String.isBlank(sigHeader)) return false; // Parse "t=...,v1=..." String t; String v1; for (String part : sigHeader.split(',')) { List<String> kv = part.split('=', 2); if (kv.size() != 2) continue; if (kv[0].trim() == 't') t = kv[1].trim(); if (kv[0].trim() == 'v1') v1 = kv[1].trim(); } if (String.isBlank(t) || String.isBlank(v1)) return false; // Reject webhooks whose timestamp is more than 5 minutes old. Long tsMillis = Long.valueOf(t) * 1000; if (Math.abs(System.now().getTime() - tsMillis) > 300000) { return false; } Blob mac = Crypto.generateMac( 'HmacSHA256', Blob.valueOf(body + '.' + t), Blob.valueOf(secret()) ); String expected = EncodingUtil.convertToHex(mac); return constantTimeEquals(expected, v1); } // Constant-time compare: never short-circuit on the first mismatch, // which would leak timing information about the correct signature. private static Boolean constantTimeEquals(String a, String b) { if (a == null || b == null) return false; if (a.length() != b.length()) return false; Integer diff = 0; for (Integer i = 0; i < a.length(); i++) { diff |= a.charAt(i) ^ b.charAt(i); } return diff == 0; }}
Once verified, deserialize the payload and act on it with plain Apex DML; there is no callout to the Salesforce REST API, because you are already inside Salesforce. On call.answered, resolve the caller to a Contact with SOQL and insert a Task, then publish a Platform Event so the softphone LWC can screen pop.
private static void handleCallAnswered(Map<String, Object> event) { String caller = (String) event.get('from'); // E.164, +254712345678 String callId = (String) event.get('call_id'); String direction = (String) event.get('direction'); if (direction == null) direction = 'inbound'; // Bind the caller number as a variable; never string-concat into SOQL. List<Contact> matches = [ SELECT Id, Name FROM Contact WHERE Phone = :caller LIMIT 1 ]; Id whoId = matches.isEmpty() ? null : matches[0].Id; String title = direction.capitalize(); insert new Task( Subject = 'Sautikit ' + title + ' Call', Status = 'In Progress', Priority = 'Normal', WhoId = whoId, Description = 'Call ID: ' + callId + '\nCaller: ' + caller, CallType = title, Sautikit_Call_ID__c = callId ); // Notify the softphone LWC in real time. EventBus.publish(new Sautikit_Inbound_Call__e( Call_ID__c = callId, Caller__c = caller, Contact_Id__c = whoId ));}
On recording.ready, find the Task by the correlation field and update it:
private static void handleRecordingReady(Map<String, Object> event) { String callId = (String) event.get('call_id'); String recordingUrl = (String) event.get('recording_url'); // presigned Object durObj = event.get('duration_seconds'); Integer durationSec = (durObj == null) ? 0 : Integer.valueOf(String.valueOf(durObj)); List<Task> tasks = [ SELECT Id FROM Task WHERE Sautikit_Call_ID__c = :callId LIMIT 1 ]; if (tasks.isEmpty()) return; update new Task( Id = tasks[0].Id, Status = 'Completed', CallDurationInSeconds = durationSec, CallDisposition = 'Completed', Sautikit_Recording_URL__c = recordingUrl );}
Create a Force.com Site under Setup → Sites (for example, sautikit).
Open the Site's Public Access Settings and, on the guest-user profile, enable the SautikitWebhookResource Apex class (Enabled Apex Class Access). Grant read/create on Task and the two custom fields.
Register that URL in the Sautikit dashboard as the destination for call.answered and recording.ready. Because the guest user is unauthenticated, the Apex HMAC check is the only thing standing between the public internet and your data, which is exactly why the verification above is mandatory.
For outbound calls, an LWC calls an @AuraEnabled Apex method that makes an HTTP callout to Sautikit. The endpoint and the Authorization: Bearer header come from a Named Credential, so no API key is ever hardcoded or stored in a Custom Setting.
Setup → Named Credentials → External Credentials → New. Create Sautikit_Auth with a Custom authentication protocol. Add a Named Principal with a Custom Header parameter Authorization set to Bearer <your-sautikit-api-key>.
Setup → Named Credentials → New. Create Sautikit_API with URL https://api.sautikit.com, linked to the Sautikit_Auth external credential. Uncheck "Generate Authorization Header" so Salesforce sends your custom Bearer header (set on the External Credential's Named Principal).
Grant your integration user's permission set access to the external credential's principal.
This replaces both the old Remote Site Setting and the hardcoded key: Salesforce injects the base URL and Bearer token at callout time.
This LWC lives in the utility bar. It subscribes to the inbound Platform Event via lightning/empApi and screen pops the caller's Contact with NavigationMixin when a verified inbound call arrives. This is the fully native real-time path: Apex webhook → EventBus.publish → empApi → LWC, with no iframe and no relay server.
Add the component to your Lightning app's utility bar under Setup → App Manager → (your app) → Utility Items. Keep it docked (not a pop-out) so its empApi subscription stays live.
When Sautikit places the call, it fetches voice actions from your voice_callback_url. For an outbound agent-initiated call, the simplest flow bridges the agent's SIP device into the call. Return JSON or XML, whichever fits your codebase; both work at runtime:
Add both Task fields to the page layout so agents see the recording link directly on the activity record.
Sautikit gives you programmable voice inside Salesforce. When you need the full agent desk, SMS, WhatsApp, and USSD layer on top, Helloduty, the multi-channel CX platform that Sautikit is part of, provides it.
Sautikit pricing (as of 2026-06-30): KES 3/min outbound (KES 0.05/sec), inbound free (KES 0/min), KES 0.50/min for call recording, KES 100/month (ex. VAT; KES 116 incl. VAT) per local Nairobi number. Recording storage up to 5 GB is included. See /pricing for current rates.
For a 10-agent team making 50 outbound calls/day averaging 3 minutes each: 10 × 50 × 3 × KES 3 = KES 4,500/day in outbound call costs. Inbound calls are free.
Deploy the Apex classes (SautikitWebhookResource, SautikitSignature, SautikitClickToCall) and the two LWCs to your org.
Create the Sautikit_Call_ID__c and Sautikit_Recording_URL__c fields, the Sautikit_Inbound_Call__e Platform Event, and the Sautikit_Setting__mdt record with your signing secret.
Create the Force.com Site and grant guest access to SautikitWebhookResource; register the resulting URL in the Sautikit dashboard.
Create the Sautikit_API Named Credential (+ external credential) for the Bearer callout.
Add the click-to-call button to the record page and dock the softphone LWC in the utility bar (importing an Open CTI Call Center is optional and not required for the real-time path).
The webhooks concept page covers event kinds, retry schedules, and signature verification in detail. The voice actions reference documents every available verb including say, dial, record, and getDigits. Full pricing is at /pricing.