---
title: >-
  Add click-to-call and screen pop to Salesforce with Sautikit, in native Apex
  and Lightning (no middleware)
description: >-
  Build the Sautikit-Salesforce integration entirely in Apex and LWC: webhook
  verification, click-to-call, and screen pop, with no external server.
summary: >-
  Wire Sautikit to Salesforce using only Apex and Lightning Web Components.
  Verify webhooks with Crypto.generateMac, place calls through a Named
  Credential, and screen pop over a Platform Event.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

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.

## Why Integrate Voice with Salesforce Service Cloud

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.

<!-- diagram omitted -->

## Architecture: Everything Runs Inside Salesforce

The integration has three flows, and all of them are native:

1. **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.
2. **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.
3. **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.

## Optional: Open CTI Call Center (Legacy)

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.

### Create the Call Center Definition (optional)

If you want the Call Center record, create a Call Center Definition XML file and upload it to **Setup → Call Centers → Import**:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<callCenter>
  <section sortOrder="0" name="reqGeneralInfo" label="General Info">
    <item sortOrder="0" name="reqInternalName" label="Internal Name">
      SautikitCTI
    </item>
    <item sortOrder="1" name="reqDisplayName" label="Display Name">
      Sautikit Voice
    </item>
    <item sortOrder="2" name="reqAdapterUrl" label="CTI Adapter URL">
      https://your-domain.my.site.com/sautikit-softphone
    </item>
    <item sortOrder="3" name="reqUseApi" label="Use CTI API">
      true
    </item>
    <item sortOrder="4" name="reqSoftphoneWidth" label="Softphone Width">
      300
    </item>
    <item sortOrder="5" name="reqSoftphoneHeight" label="Softphone Height">
      500
    </item>
  </section>
</callCenter>
```

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.

## Inbound Webhooks: Apex REST on a Public Force.com Site

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.

### The Apex REST Endpoint

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.

```apex
@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
}
```

### Verifying the HMAC in Apex

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.

```apex
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;
    }
}
```

### Handling Verified Events with Native DML

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.

```apex
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:

```apex
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
    );
}
```

### Exposing the Endpoint Publicly

1. Create a **Force.com Site** under **Setup → Sites** (for example, `sautikit`).
2. 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.
3. Your webhook URL is now:

```
https://<your-domain>--<site>.my.salesforce-sites.com/services/apexrest/sautikit/events
```

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.

## Outbound Click-to-Call: `@AuraEnabled` Apex + Named Credential

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.

### Set Up the Named Credential

1. **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>`.
2. **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).
3. 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.

### The Apex Callout

```apex
public with sharing class SautikitClickToCall {

    @AuraEnabled
    public static String placeCall(Id recordId, String toNumber, String fromNumber) {
        HttpRequest req = new HttpRequest();
        // callout:<NamedCredential> supplies the host and the Bearer header.
        req.setEndpoint('callout:Sautikit_API/v1/calls');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');

        Map<String, Object> payload = new Map<String, Object>{
            'from' => fromNumber,
            'to'   => new List<String>{ toNumber },
            'metadata' => new Map<String, Object>{
                'salesforce_record_id' => recordId
            }
        };
        req.setBody(JSON.serialize(payload));

        HttpResponse resp = new Http().send(req);
        if (resp.getStatusCode() != 201) {
            throw new AuraHandledException(
                'Sautikit call failed: HTTP ' + resp.getStatusCode()
            );
        }

        Map<String, Object> body =
            (Map<String, Object>) JSON.deserializeUntyped(resp.getBody());
        String callId = (String) body.get('id');

        // Log a pending Activity so the outbound call appears immediately.
        insert new Task(
            Subject             = 'Click-to-Call',
            WhatId              = recordId,
            Status              = 'In Progress',
            Priority            = 'Normal',
            CallType            = 'Outbound',
            Sautikit_Call_ID__c = callId
        );

        return callId;
    }
}
```

The matching `recording.ready` webhook later flips this Task to Completed and attaches the recording URL, closing the loop.

## Lightning UI: Two Web Components

### Click-to-Call Button (Record Page)

Place this LWC on the Lead or Contact record page. It imports the Apex method and calls it imperatively, toasting on success.

`sautikitCallButton.js`:

```js
import { LightningElement, api } from "lwc";
import { ShowToastEvent } from "lightning/platformShowToastEvent";
import placeCall from "@salesforce/apex/SautikitClickToCall.placeCall";

export default class SautikitCallButton extends LightningElement {
  @api recordId;
  @api toNumber; // bind from the record's Phone field
  fromNumber = "+254711000000"; // your Sautikit number

  async handleClick() {
    try {
      const callId = await placeCall({
        recordId: this.recordId,
        toNumber: this.toNumber,
        fromNumber: this.fromNumber,
      });
      this.dispatchEvent(
        new ShowToastEvent({
          title: "Calling",
          message: `Sautikit call started (${callId})`,
          variant: "success",
        }),
      );
    } catch (error) {
      this.dispatchEvent(
        new ShowToastEvent({
          title: "Call failed",
          message: error.body?.message ?? "Unknown error",
          variant: "error",
        }),
      );
    }
  }
}
```

`sautikitCallButton.html`:

```html
<template>
  <lightning-button
    label="Call with Sautikit"
    icon-name="utility:call"
    variant="brand"
    onclick={handleClick}
  ></lightning-button>
</template>
```

`sautikitCallButton.js-meta.xml`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
  <apiVersion>60.0</apiVersion>
  <isExposed>true</isExposed>
  <targets>
    <target>lightning__RecordPage</target>
  </targets>
</LightningComponentBundle>
```

### Softphone and Screen Pop (Utility Bar)

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.

`sautikitSoftphone.js`:

```js
import { LightningElement, wire } from "lwc";
import { NavigationMixin } from "lightning/navigation";
import { subscribe, unsubscribe, onError } from "lightning/empApi";

const CHANNEL = "/event/Sautikit_Inbound_Call__e";

export default class SautikitSoftphone extends NavigationMixin(
  LightningElement,
) {
  subscription = {};
  lastCaller;

  connectedCallback() {
    onError((err) => console.error("empApi error", JSON.stringify(err)));
    subscribe(CHANNEL, -1, (message) => this.handleInbound(message)).then(
      (sub) => {
        this.subscription = sub;
      },
    );
  }

  disconnectedCallback() {
    unsubscribe(this.subscription, () => {});
  }

  handleInbound(message) {
    const payload = message.data.payload;
    this.lastCaller = payload.Caller__c;
    const contactId = payload.Contact_Id__c;
    if (!contactId) return;

    // Screen pop the matching Contact record.
    this[NavigationMixin.Navigate]({
      type: "standard__recordPage",
      attributes: {
        recordId: contactId,
        objectApiName: "Contact",
        actionName: "view",
      },
    });
  }
}
```

`sautikitSoftphone.html`:

```html
<template>
  <lightning-card title="Sautikit Softphone" icon-name="utility:call">
    <div class="slds-p-around_medium">
      <template lwc:if={lastCaller}>
        <p>Last inbound call from {lastCaller}</p>
      </template>
      <template lwc:else>
        <p>Waiting for calls…</p>
      </template>
    </div>
  </lightning-card>
</template>
```

`sautikitSoftphone.js-meta.xml`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
  <apiVersion>60.0</apiVersion>
  <isExposed>true</isExposed>
  <targets>
    <target>lightning__UtilityBar</target>
    <target>lightning__AppPage</target>
  </targets>
</LightningComponentBundle>
```

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.

## Voice Action Response for Outbound Calls

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:

```xml
<Response>
  <Say language="en-KE">Connecting your call. Please wait.</Say>
  <Dial callerId="+254711000000" timeout="30">
    <Number>+254700000000</Number>
  </Dial>
</Response>
```

```json
{
  "actions": [
    {
      "say": {
        "text": "Connecting your call. Please wait.",
        "language": "en-KE"
      }
    },
    {
      "dial": {
        "to": "+254700000000",
        "callerId": "+254711000000",
        "timeout": 30
      }
    }
  ]
}
```

For inbound calls, return an IVR or queue greeting before `dial`-ing the agent's SIP address.

<!-- diagram omitted -->

## Custom Field Checklist

Before deploying, create these on the Task object in Salesforce Setup:

| Object | Field API Name | Type | Purpose |
|---|---|---|---|
| Task | `Sautikit_Call_ID__c` | Text(255) | Correlate events to Tasks |
| Task | `Sautikit_Recording_URL__c` | URL | Store presigned recording link |

You also need one Platform Event and one Custom Metadata type:

| Object | API Name | Fields |
|---|---|---|
| Platform Event | `Sautikit_Inbound_Call__e` | `Call_ID__c` (Text), `Caller__c` (Text), `Contact_Id__c` (Text) |
| Custom Metadata | `Sautikit_Setting__mdt` | `Webhook_Secret__c` (Text) |

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](https://helloduty.com), the multi-channel CX platform that Sautikit is part of, provides it.

## 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 for call recording, KES 100/month (ex. VAT; KES 116 incl. VAT) per local Nairobi number. Recording storage up to 1 GB is included. See [/pricing](/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.

## 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 Apex classes (`SautikitWebhookResource`, `SautikitSignature`, `SautikitClickToCall`) and the two LWCs to your org.
4. 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.
5. Create the Force.com Site and grant guest access to `SautikitWebhookResource`; register the resulting URL in the Sautikit dashboard.
6. Create the `Sautikit_API` Named Credential (+ external credential) for the Bearer callout.
7. 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).

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See KES pricing →](/pricing)** &nbsp;·&nbsp; **[Add the full agent desk with Helloduty →](https://helloduty.com)**

## Next Steps

The [webhooks concept page](/developers/concepts/webhooks) covers event kinds, retry schedules, and signature verification in detail. The [voice actions reference](/developers/concepts/voice-actions) documents every available verb including `say`, `dial`, `record`, and `getDigits`. Full pricing is at [/pricing](/pricing).
