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.
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.
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:
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):
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.
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:
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:
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 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():
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:
// calling-widget/index.js, served from the calling iframeimport CallingExtensions from "@hubspot/calling-extensions-sdk";const SAUTIKIT_OUTBOUND_NUMBER = "+254700000000"; // your Sautikit numberlet 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.
# 1. Install the HubSpot CLI and authenticate.npm install -g @hubspot/clihs account auth # authenticate with a personal access key# 2. Store secrets (never in source).hs secret add SAUTIKIT_WEBHOOK_SECREThs secret add SAUTIKIT_API_KEYhs secret add SAUTIKIT_OUTBOUND_NUMBER# 3. Install function deps, then upload the project.npm install --prefix src/app/functionshs 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, the multi-channel CX platform Sautikit is part of, adds 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 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.