---
title: >-
  Add click-to-call and screen pop to HubSpot Service Hub with a native Sautikit
  app
description: >-
  Build the Sautikit-HubSpot integration as a native app: React CRM cards, app
  functions for signed webhooks, and the Calling Extensions SDK.
summary: >-
  Ship Sautikit inside HubSpot as a native projects app. Verify webhooks in an
  app function, log calls with the CRM API, click-to-call from a React CRM card,
  and screen pop via the Calling SDK.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

HubSpot Service Hub tracks contacts, tickets, and deals. Sautikit adds programmable voice on top of it, and on HubSpot's projects platform (`2026.03`) you can build the whole thing as a native app: no external Node/Express relay, no self-hosted webhook receiver. React CRM cards (`@hubspot/ui-extensions`) render click-to-call and live call state on the contact and ticket record; app functions (`src/app/functions/`) receive Sautikit webhooks, verify the signature, and write call Engagements through the CRM API; and the Calling Extensions SDK (`@hubspot/calling-extensions-sdk`) drives the softphone and screen pop. Everything deploys with `hs project upload`.

## Architecture: Everything Runs Inside HubSpot

The integration has three components, all packaged in one projects app:

- **A React CRM card** on the contact and ticket record: a click-to-call button and a live call panel, built with UI Extensions.
- **App functions**: HubSpot-hosted serverless functions. A public-endpoint function receives Sautikit webhooks (`call.completed`, `recording.ready`) and verifies the signature; private app functions place outbound calls and are invoked from the card via `hubspot.serverless()`.
- **The calling iframe**: a Calling Extensions SDK widget that handles the dialer UI, call state, and screen pop.

Secrets (the Sautikit API key and webhook secret) live as app secrets, referenced by the functions via `process.env`. The private app access token is injected automatically as `PRIVATE_APP_ACCESS_TOKEN`.

<!-- diagram omitted -->

## Project Scaffold

A projects app is a directory tree uploaded to HubSpot. The shape you need:

```text
sautikit-hubspot/
├── hsproject.json
└── src/
    └── app/
        ├── app-hsmeta.json
        ├── functions/
        │   ├── sautikit-webhook-hsmeta.json
        │   ├── SautikitWebhook.js
        │   ├── place-call-hsmeta.json
        │   ├── PlaceCall.js
        │   └── package.json
        └── extensions/
            ├── click-to-call-card-hsmeta.json
            └── ClickToCall.jsx
```

```json
// hsproject.json
{
  "name": "sautikit-hubspot",
  "srcDir": "src",
  "platformVersion": "2026.03"
}
```

The app manifest declares scopes. Grant only what the functions and cards use:

```json
// src/app/app-hsmeta.json
{
  "uid": "sautikit_app",
  "type": "app",
  "config": {
    "name": "Sautikit Voice",
    "description": "Programmable voice for HubSpot: calls logged as Engagements.",
    "distribution": "private",
    "auth": { "type": "static" },
    "scopes": [
      "crm.objects.contacts.read",
      "crm.objects.contacts.write",
      "crm.objects.deals.read",
      "crm.objects.tickets.read",
      "crm.objects.tickets.write",
      "crm.objects.calls.read",
      "crm.objects.calls.write"
    ]
  }
}
```

## Inbound: An App Function That Verifies Sautikit Webhooks

Instead of a public relay, use a **public-endpoint app function**. HubSpot hosts it, exposes it at `https://<your-app-domain>/hs/serverless/api/<path>`, and hands your handler the raw request. That URL is what you register as the Sautikit webhook target.

The function is a paired `*-hsmeta.json` (config) + `.js` (handler). The `endpoint` block turns a private app function into a public HTTP endpoint:

```json
// src/app/functions/sautikit-webhook-hsmeta.json
{
  "uid": "sautikit_webhook",
  "type": "app-function",
  "config": {
    "entrypoint": "/app/functions/SautikitWebhook.js",
    "endpoint": {
      "path": "sautikit/webhook",
      "methods": ["POST"]
    },
    "secretKeys": ["SAUTIKIT_WEBHOOK_SECRET"]
  }
}
```

The handler exports a single `main(context)`. For a public endpoint, `context` carries `body`, `headers`, `method`, and `query`. Verify the Sautikit signature before doing anything, then create a call Engagement with the HubSpot API client (already a dependency of app functions):

```js
// src/app/functions/SautikitWebhook.js
const crypto = require("crypto");
const hubspot = require("@hubspot/api-client");

// Constant-time compare of the Sautikit signature header:
//   x-sautikit-signature: t=<unix>,v1=<hex hmac>
function verifySautikitSignature(rawBody, header, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((s) => s.split("=")));
  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${rawBody}.${t}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function findContactByPhone(client, phone) {
  const res = await client.crm.contacts.searchApi.doSearch({
    filterGroups: [
      { filters: [{ propertyName: "phone", operator: "EQ", value: phone }] },
    ],
    properties: ["hs_object_id", "firstname", "lastname"],
    limit: 1,
  });
  return res.results?.[0] || null;
}

exports.main = async (context = {}) => {
  const secret = process.env.SAUTIKIT_WEBHOOK_SECRET;
  const rawBody =
    typeof context.body === "string" ? context.body : JSON.stringify(context.body);
  const header = context.headers?.["x-sautikit-signature"];

  if (!verifySautikitSignature(rawBody, header, secret)) {
    return { statusCode: 401, body: { error: "invalid_signature" } };
  }

  const event = typeof context.body === "string" ? JSON.parse(context.body) : context.body;
  const client = new hubspot.Client({
    accessToken: process.env.PRIVATE_APP_ACCESS_TOKEN,
  });

  const {
    event: kind,
    call_id: callId,
    from: caller,
    to,
    direction = "inbound",
    duration_seconds: durationSeconds = 0,
    status,
    metadata = {},
  } = event;

  if (kind !== "call.completed") {
    // Acknowledge other kinds (call.answered, recording.ready) so Sautikit stops retrying.
    return { statusCode: 200, body: { received: kind } };
  }

  // Resolve the record to attach to: metadata wins, else look up by phone.
  const counterparty = direction === "inbound" ? caller : (to || [])[0];
  let contactId = metadata.hubspot_contact_id || null;
  if (!contactId && counterparty) {
    const contact = await findContactByPhone(client, counterparty);
    contactId = contact?.id || null;
  }

  const associations = [];
  // HUBSPOT_DEFINED association type ids: call→contact 194, call→deal 206, call→ticket 220.
  if (contactId)
    associations.push({
      to: { id: contactId },
      types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 194 }],
    });
  if (metadata.hubspot_deal_id)
    associations.push({
      to: { id: metadata.hubspot_deal_id },
      types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 206 }],
    });
  if (metadata.hubspot_ticket_id)
    associations.push({
      to: { id: metadata.hubspot_ticket_id },
      types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 220 }],
    });

  await client.crm.objects.calls.basicApi.create({
    properties: {
      hs_timestamp: Date.now(),
      hs_call_title: `Sautikit ${direction} call`,
      hs_call_direction: direction.toUpperCase(),
      hs_call_duration: String(durationSeconds * 1000),
      hs_call_from_number: caller || "",
      hs_call_to_number: (to || [])[0] || "",
      hs_call_status: "COMPLETED",
      hs_call_recording_url: event.recording_url || "",
      hs_call_body: [
        `Call ${status || "completed"}.`,
        event.recording_url ? `Recording: ${event.recording_url}` : null,
        `Sautikit Call ID: ${callId}`,
      ]
        .filter(Boolean)
        .join("\n"),
    },
    associations,
  });

  return { statusCode: 200, body: { logged: callId } };
};
```

App functions include `@hubspot/api-client` by default. If you pin a version, add it to `src/app/functions/package.json` and run `npm install` in that directory before uploading.

> 
> Public-endpoint app functions require a Content Hub Enterprise seat on the connected account. If that seat is not available, keep the same handler logic but subscribe to a HubSpot workflow custom-code action instead, or run the webhook receiver as a separate service and call the CRM API with the same private app token.
> 

### Attaching a Recording When It Arrives Later

Sautikit sends `recording.ready` after `call.completed`. Rather than a second object, patch the existing call Engagement so the recording lives on the same activity. Search by the `Sautikit Call ID` line, then update `hs_call_body`:

```js
async function attachRecording(client, callId, recordingUrl) {
  const found = await client.crm.objects.calls.searchApi.doSearch({
    query: `Sautikit Call ID: ${callId}`,
    properties: ["hs_call_body"],
    limit: 1,
  });
  const call = found.results?.[0];
  if (!call) return;
  await client.crm.objects.calls.basicApi.update(call.id, {
    properties: {
      hs_call_body:
        `${call.properties.hs_call_body || ""}\n` +
        `Recording (valid 7 days): ${recordingUrl}`,
    },
  });
}
```

## Outbound: A Private App Function for Click-to-Call

The card cannot hold the Sautikit API key; extension code runs in the browser. Put the outbound call behind a **private app function** that the card invokes with `hubspot.serverless()`. The key is an app secret, referenced only server-side:

```json
// src/app/functions/place-call-hsmeta.json
{
  "uid": "place_call",
  "type": "app-function",
  "config": {
    "entrypoint": "/app/functions/PlaceCall.js",
    "secretKeys": ["SAUTIKIT_API_KEY", "SAUTIKIT_OUTBOUND_NUMBER"]
  }
}
```

```js
// src/app/functions/PlaceCall.js
exports.main = async (context = {}) => {
  const { toNumber, contactId, dealId, ticketId } = context.parameters;
  if (!toNumber) return { ok: false, error: "missing_to_number" };

  const res = await fetch("https://api.sautikit.com/v1/calls", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: process.env.SAUTIKIT_OUTBOUND_NUMBER,
      to: [toNumber],
      metadata: {
        hubspot_contact_id: contactId || null,
        hubspot_deal_id: dealId || null,
        hubspot_ticket_id: ticketId || null,
      },
    }),
  });

  if (!res.ok) {
    return { ok: false, error: `sautikit_${res.status}` };
  }
  const call = await res.json();
  return { ok: true, callId: call.id };
};
```

Because the outbound call carries `metadata.hubspot_contact_id`, the `call.completed` webhook can attach the Engagement to the right record without a phone lookup; the two functions close the loop.

## The Agent UI: A React CRM Card

The card lives on the contact and ticket record. A card is a `*-hsmeta.json` config plus a `.jsx` entry point:

```json
// src/app/extensions/click-to-call-card-hsmeta.json
{
  "uid": "click_to_call_card",
  "type": "card",
  "config": {
    "name": "Sautikit: Click to Call",
    "location": "crm.record.tab",
    "entrypoint": "/app/extensions/ClickToCall.jsx",
    "objectTypes": ["contacts", "tickets"]
  }
}
```

The extension registers with `hubspot.extend`. It reads the record's phone via `actions.fetchCrmObjectProperties`, and on click invokes the private `place_call` function through `hubspot.serverless()`:

```jsx
// src/app/extensions/ClickToCall.jsx
import React, { useEffect, useState } from "react";
import {
  hubspot,
  Button,
  Flex,
  Text,
  Tile,
  LoadingSpinner,
} from "@hubspot/ui-extensions";

hubspot.extend(({ context, actions }) => (
  <ClickToCall context={context} actions={actions} />
));

const ClickToCall = ({ context, actions }) => {
  const { fetchCrmObjectProperties, addAlert } = actions;
  const [phone, setPhone] = useState("");
  const [status, setStatus] = useState("idle");

  const objectId = context.crm.objectId;
  const objectType = context.crm.objectTypeId; // "0-1" contact, "0-5" ticket

  useEffect(() => {
    fetchCrmObjectProperties(["phone", "hs_object_id"]).then((props) => {
      setPhone(props.phone || "");
    });
  }, []);

  const placeCall = async () => {
    setStatus("dialing");
    try {
      const result = await hubspot.serverless("place_call", {
        parameters: {
          toNumber: phone,
          contactId: objectType === "0-1" ? String(objectId) : null,
          ticketId: objectType === "0-5" ? String(objectId) : null,
        },
      });
      if (result.ok) {
        addAlert({ type: "success", message: `Calling ${phone}…` });
        setStatus("ringing");
      } else {
        addAlert({ type: "danger", message: `Call failed: ${result.error}` });
        setStatus("idle");
      }
    } catch (err) {
      addAlert({ type: "danger", message: err.message });
      setStatus("idle");
    }
  };

  if (status === "dialing") return <LoadingSpinner label="Placing call…" />;

  return (
    <Tile>
      <Flex direction="column" gap="sm">
        <Text>
          {phone ? (
            <>
              Ready to call <Text format={{ fontWeight: "bold" }}>{phone}</Text>
            </>
          ) : (
            "No phone number on this record."
          )}
        </Text>
        <Button variant="primary" disabled={!phone} onClick={placeCall}>
          Call with Sautikit
        </Button>
        {status === "ringing" && <Text>Ringing. Pick up on your softphone.</Text>}
      </Flex>
    </Tile>
  );
};
```

## Softphone & Screen Pop: The Calling Extensions SDK

For a full in-CRM dialer (screen pop on inbound, live call state, and a "Log this call" prompt), register a calling iframe and drive it with the Calling Extensions SDK. The SDK sits in the calling widget and speaks to HubSpot over `postMessage`:

```js
// calling-widget/index.js, served from the calling iframe
import CallingExtensions from "@hubspot/calling-extensions-sdk";

const SAUTIKIT_OUTBOUND_NUMBER = "+254700000000"; // your Sautikit number
let currentEngagementId = null;

const extensions = new CallingExtensions({
  debugMode: false,
  eventHandlers: {
    // HubSpot is ready. Confirm the widget is ready too.
    onReady: (payload) => {
      extensions.initialized({ isLoggedIn: true, engagementId: payload.engagementId });
    },
    // Agent clicked "Call" on a record; HubSpot sends the number to dial.
    onDialNumber: (event) => {
      const { phoneNumber } = event;
      startSautikitCall(phoneNumber);
      extensions.outgoingCall({
        toNumber: phoneNumber,
        fromNumber: SAUTIKIT_OUTBOUND_NUMBER,
        createEngagement: true,
        callStartTime: Date.now(),
      });
    },
    onCreateEngagementSucceeded: (event) => {
      // HubSpot created the CALL engagement; keep its id to update on hangup.
      currentEngagementId = event.engagementId;
    },
    onVisibilityChanged: () => {},
  },
});

// Called from your Sautikit call-state stream when the far end answers / hangs up.
function onSautikitAnswered({ externalCallId }) {
  extensions.callAnswered({ externalCallId });
}
function onSautikitEnded({ externalCallId, endStatus = "COMPLETED" }) {
  extensions.callEnded({
    externalCallId,
    engagementId: currentEngagementId,
    callEndStatus: endStatus,
  });
}
function onSautikitCompleted({ externalCallId }) {
  extensions.callCompleted({
    engagementId: currentEngagementId,
    externalCallId,
    hideWidget: false,
  });
}
```

HubSpot creates and updates the CALL engagement for widget-driven calls, so you do **not** re-create it from the webhook when a call originates in the softphone; reconcile on `externalCallId` (the Sautikit call id) and let the webhook path own only calls placed from the CRM card or from Sautikit-side automations.

## Setup & Deploy

```bash
# 1. Install the HubSpot CLI and authenticate.
npm install -g @hubspot/cli
hs account auth    # authenticate with a personal access key

# 2. Store secrets (never in source).
hs secret add SAUTIKIT_WEBHOOK_SECRET
hs secret add SAUTIKIT_API_KEY
hs secret add SAUTIKIT_OUTBOUND_NUMBER

# 3. Install function deps, then upload the project.
npm install --prefix src/app/functions
hs project upload

# 4. Local dev loop for the card.
hs project dev
```

After upload, HubSpot prints the public URL for the webhook function (`https://<domain>/hs/serverless/api/sautikit/webhook`). Register that as the Sautikit webhook target for `call.completed` and `recording.ready`. Install the app on your account, add the card to a contact and ticket record view, and place your first call.

Sautikit gives you programmable voice inside HubSpot Service Hub. When your tickets need a full agent desk plus SMS, WhatsApp, and USSD follow-up, [Helloduty](https://helloduty.com), the multi-channel CX platform Sautikit is part of, adds 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 free. For a 5-agent team on HubSpot Service Hub making 40 outbound calls/day at 3 minutes average: 5 × 40 × 3 × KES 3 = KES 1,800/day. Inbound calls are 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. Scaffold the projects app, `hs project upload`, register the webhook URL, and log your first call as an Engagement on a Contact record.

**[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 all event kinds and retry semantics. The [voice actions reference](/developers/concepts/voice-actions) documents every verb available in the voice action loop. For wallet top-up via M-Pesa, see [/pricing](/pricing).
