Developers
Build a voice IVR with Sinch Functions
I have built plenty of voice and messaging integrations where a small HTTP service existed for one reason: receive a callback and turn it into the next action. The service was rarely complicated, but it still needed to be deployed, monitored, and kept available.
Sinch Functions runs that callback code on Sinch infrastructure. You write a function, deploy it with the Sinch CLI, and Sinch routes voice and messaging traffic to it. Keep your customer database, order service, and other backend services where they already run. Functions handles the code triggered by communications events.
I use a small Voice API v2 IVR in this post because it makes the event flow easy to test from a phone. The same development and deployment model applies to messaging webhooks and other communications workflows: create the function, run it locally behind a temporary tunnel, test it with curl and live traffic, then deploy it.
You’ll need
- A Sinch account and a project in the Sinch Build Dashboard
- A project ID, access key ID, and access key secret from the project’s Access Keys page
- Node.js 24 or newer
- A Voice API v2 service with a phone number assigned to it. Create one with
sinch voice services createand rent a number withsinch numbers available rent, or let theinitpicker create the service for you.
Renting a number and voice usage are billable; check your project’s Billing Overview in the dashboard for current rates. You don’t need a number to write and test the handler. The local curl request below returns the commands without placing a call. The phone call at the end is what needs the Voice service and number.
Why Functions fits this job
A simple phone tree usually has a small control loop:
- A caller reaches a Sinch phone number.
- Voice API v2 sends a
call.incomingevent to your function. - Your function returns commands to answer the call and start a menu.
- The platform plays the prompt, collects the keypress, and runs the commands in the matching branch. Your function is not called again for that step.
- When the call ends, Voice API v2 sends
call.hangup, and thecompletedhandler logs it.
When a menu declares what to do for every key, the branch runs on the Sinch platform instead of coming back to your code. That’s one fewer network hop and one fewer function invocation per keypress, and it’s what keeps a small IVR small.
Sinch Functions provides the runtime, routing, logs, local tunnel, and deployment path for that part of the application. Sinch delivers the events to your code inside its own network, and the CLI wires the callback URL for you during local development and deployment. With the private deployment used in this tutorial, there’s no public production webhook endpoint to expose and no separate deployment pipeline to maintain.
The distinction matters. A function can call your existing APIs to look up an order or customer, but it shouldn’t become a substitute for the system that owns that data. Keep the business system where it belongs and use Functions at the communications boundary.
Install and authenticate the CLI
Install the Sinch CLI globally:
npm install -g @sinch/cli
Authenticate with the project credentials from the Sinch Build Dashboard:
sinch auth login
The CLI prompts for your project ID, access key ID, and access key secret, and stores the secret in your OS credential store rather than in the project directory.
Check the signed-in project:
sinch auth status
Voice functions use this project key pair. When you create one, the CLI lets you select a Voice service and writes its ID to VOICE_SERVICE_ID in .env.
Create the function
Start from the built-in voice IVR template:
sinch functions init simple-voice-ivr --name getting-started-functions
The template uses the Node.js runtime by default. To use C# instead, add --runtime csharp to the command.
The initializer is interactive. Complete these prompts before running another command:
- Enter the company name to use in the voice prompts.
- Select the dedicated development Voice service from the picker, or enter its service ID manually.
- Accept Yes to auto-configure the selected Voice service when you deploy.
- Choose No database. The Basic and Pro options provision SQLite storage, which this IVR does not need.
The initializer extracts the template, configures secure credential access from your OS keychain, and installs dependencies. Wait for Function initialized successfully and Dependencies installed successfully, then move into the generated project:
cd getting-started-functions
The template installs its dependencies unless you pass --skip-install.
The files this tutorial touches are:
getting-started-functions/
├── .env
├── function.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── sinch.json
├── runtime.json
└── test.http
sinch.json contains the function name, runtime, and non-secret variables. .env contains local configuration. Keep .env out of source control. The template also includes AGENTS.md, a README.md, GitHub configuration, and VS Code debugging configuration, which this tutorial does not change.
Write the first handler
The template generates a working IVR. We replace it with a smaller handler and build it back up so each part of the flow is visible.
Open function.ts and replace the template code with this minimal handler:
import { onCall } from '@sinch/functions-runtime/voice';
export const voiceWebhook = onCall({
incoming: (call, builder) => {
const from = call.from?.type === 'PHONE' ? call.from.phone.number : undefined;
console.log('Call received', { from, callId: call.callId });
return builder.answer().say('Hello! Thanks for calling.').hangup();
},
});
voiceWebhook is the named export that receives Voice API v2 events. onCall selects a handler based on the event type and gives it a command builder. The local runtime dispatches a call.* event posted to the function root, POST /, to this handler.
For an incoming call, builder.answer() accepts the call, say() turns the text into speech, and hangup() ends it. The fluent builder returns valid Voice API commands without making you assemble the JSON manually.
Run it locally
Start the development server:
sinch functions dev
The server listens on port 3000 and reloads TypeScript changes automatically. The CLI asks whether to enable the Sinch tunnel for external access. Choose Yes: Enable tunnel this time for this walkthrough.
While the tunnel is up, the CLI points the selected Voice service’s callback URL at your machine, so real calls to that service reach your laptop. It lives only as long as sinch functions dev is running. Use a development Voice service, not the one carrying customer calls. The CLI warns that real calls route to your machine until you stop the development server with Ctrl+C.
You can verify the handler without renting a number or placing a call:
curl -s -X POST http://localhost:3000/ \
-H "Content-Type: application/json" \
-d '{
"event": "call.incoming",
"call": {
"callId": "01LOCALTEST0000000000000001",
"sessionId": "01LOCALTEST0000000000000002",
"from": {
"type": "PHONE",
"phone": { "number": "+15551234567" }
},
"to": {
"type": "PHONE",
"phone": { "number": "+15559876543" }
},
"direction": "INBOUND"
}
}'
The response contains the commands the builder produced:
{
"commands": [
{ "command": "answer" },
{
"command": "messages",
"messages": [
{
"type": "SAY",
"say": { "text": "Hello! Thanks for calling.", "voiceName": "Emma" }
}
],
"events": {
"onFinish": [{ "command": "hangup" }],
"onFailure": [{ "command": "hangup" }]
}
}
],
"callName": "caller"
}
The terminal also prints the log line from the handler, which is much easier to inspect than a production webhook log while you are still building the flow:
Call received { from: '+15551234567', callId: '01LOCALTEST0000000000000001' }
Then call the phone number assigned to the Voice service. You should hear the greeting and the call should end.
If port 3000 is already in use, choose another one:
sinch functions dev --port 8080
For breakpoint debugging, use --debug. The CLI detects VS Code, creates or reuses .vscode/launch.json, and prompts you to press F5 to attach the debugger. Set breakpoints in the compiled function.js file, then test the function to hit them:
sinch functions dev --debug
Answering a call is useful as a health check, but an IVR needs to collect an answer. Replace function.ts with the following example:
import { createUniversalConfig } from '@sinch/functions-runtime';
import { onCall } from '@sinch/functions-runtime/voice';
export const voiceWebhook = onCall({
incoming: (call, builder, context) => {
const config = createUniversalConfig(context);
const companyName = config.getVariable('COMPANY_NAME', 'Acme Corp');
const callerId = config.getVariable('CALLER_ID', '+15550000000');
return builder.answer().menu('main', (menu) =>
menu
.prompt(
`Thank you for calling ${companyName}. Press 1 for sales, 2 for support, or 0 for an operator.`
)
.repeatPrompt('Press 1 for sales, 2 for support, or 0 for an operator.')
.inputTimeout(8)
.repeatCount(2)
.maxLength(1)
.match('1', (commands) =>
commands.say('Connecting you to sales now.').dialPhone('+15551110001', { from: callerId })
)
.match('2', (commands) =>
commands.say('Connecting you to support.').dialPhone('+15551110002', { from: callerId })
)
.match('0', (commands) =>
commands.say('Please hold for an operator.').dialPhone('+15551110000', { from: callerId })
)
.onFail((commands) =>
commands.say('Sorry, we did not receive your selection. Goodbye.').hangup()
)
);
},
completed: (call) => {
console.log('Call ended', { event: call.event, callId: call.callId });
},
});
Set the three destination numbers to the numbers each option should reach, and set CALLER_ID to a Sinch number you own or are allowed to present as the caller ID. menu() builds the prompt and handles DTMF, the tones a keypress sends. Each match() branch returns the commands that run when the caller presses that key, and onFail() covers a timeout or an unsupported key, so no caller is left on a silent call.
The completed handler runs after the call ends. It’s a good place to record the call ID and outcome, or to notify another system that a call has completed.
The values after each getVariable() name are fallbacks. They keep the example runnable when no configuration has been set, so you do not need to duplicate them in .env for this walkthrough. Define the non-sensitive variables there only when you want to change the greeting or caller ID without editing and redeploying the handler, or when each environment needs different values. Add them to the .env file the initializer created:
COMPANY_NAME=Acme Corp
CALLER_ID=+15550000000
Because every branch has a match() or onFail(), the platform resolves the keypress without calling your function again. Run the earlier call.incomingcurl request again to inspect the generated menu safely. Its response contains startMenu: "main", the prompt, and the 0, 1, and 2 match branches, but it doesn’t dial the placeholder numbers. Then test it by calling the number: You hear the prompt, press a key, and the matching branch runs.
This is an optional alternative to the platform-resolved menu above. Do not make this change for the IVR you have just built. Use it only when the next step depends on a lookup, such as an order, calendar, or customer record.
To use this pattern, replace the current incoming handler with the one below and add manage to the same onCall() handler map. The menu has no match() or onFail(), so Voice API v2 sends the collected digits to manage in a call.menu event:
incoming: (call, builder) =>
builder.answer().menu('main', (menu) =>
menu
.prompt('Press 1 for sales, or 2 for support.')
.repeatPrompt('Press 1 for sales, or 2 for support.')
.inputTimeout(8)
.repeatCount(2)
.maxLength(1)
),
manage: (call, builder, context) => {
const config = createUniversalConfig(context);
const callerId = config.getVariable('CALLER_ID', '+15550000000');
switch (call.menu?.input) {
case '1':
return builder.say('Connecting you to sales.').dialPhone('+15551110001', { from: callerId });
case '2':
return builder.say('Connecting you to support.').dialPhone('+15551110002', { from: callerId });
default:
return builder.say('Invalid selection.').hangup();
}
},
Use manage when the branch has to check an order, a calendar, or a customer record before deciding where the call goes. Do not combine this menu with match() or onFail(): those handlers resolve the input on the platform, so manage never runs.
You can test this path without placing a call while sinch functions dev is running:
curl -s -X POST http://localhost:3000/ \
-H "Content-Type: application/json" \
-d '{
"event": "call.menu",
"call": { "callId": "01LOCALTEST0000000000000001" },
"menu": { "menuName": "main", "input": "1" }
}'
That request reaches manage with call.menu.input set to 1. Without a manage handler, the event has nowhere to go.
Store secrets safely
Put public configuration, such as COMPANY_NAME, in sinch.json. Sensitive values, such as the API token the operator route uses to look up the caller in your CRM, go in the secret store instead. Store the value outside the repository:
sinch secrets add CRM_API_KEY YOUR_SECRET_VALUE
The CLI adds CRM_API_KEY= to .env for you. Read the value in the handler that needs it:
manage: (call, builder) => {
const crmApiKey = process.env.CRM_API_KEY;
if (!crmApiKey) {
throw new Error('CRM_API_KEY is not configured');
}
// look up the caller with crmApiKey before routing
},
The guard makes a missing credential explicit before the function tries to call the CRM. Secrets are redacted in function logs.
Deploy the function
When the local flow works, deploy it:
sinch functions deploy
The CLI validates the function first, then asks whether it should generate or update README.md. Choose Yes: Generate documentation this time for this walkthrough. It performs a dependency audit, packages the function, and prepares its configuration before prompting for a key.
Choose the managed key. It’s the default, and the CLI saves the choice in sinch.json, so later deploys do not ask again. Choose the profile key only if you already manage function credentials yourself. The CLI then lists the configuration variables it will ship, marking secrets separately, and asks for the deployment access level. For an IVR that receives only Sinch voice callbacks, choose Private: Internal access only (Sinch services). You can change that default later with --public or --private.
Before uploading, the CLI type-checks and bundles a Node.js function, then validates the package. It uploads the package over HTTPS and waits for the platform build and rollout. The resulting URL is displayed when the function is running.
The CLI updates the callback URL for the Voice service selected during initialization. Call the assigned number again after deployment. You should hear the same menu without the local server or Cloudflare Tunnel running.
Follow the production logs:
sinch functions logs --follow
The log stream shows each request, response, handler logs, and any runtime warnings.
You can also inspect a function’s state:
sinch functions status
For integration with an existing operations stack, the Sinch Functions API exposes historical logs, streaming logs, metrics, and cost data. That’s the path to use when the CLI’s interactive logs are not enough for your production monitoring workflow.
Test the deployed function
Call the number assigned to the Voice service and work through the menu. That verifies the complete production path: number, Voice service, deployed function, and the menu branch. Keep sinch functions logs --follow running in another terminal while you test so you can confirm the incoming event and response.
If you intentionally deployed a public function, you can also repeat the earlier call.incomingcurl request with the function URL printed after deployment in place of http://localhost:3000. It verifies the deployed handler without placing a call. Do not change a private IVR to public only to run this test: use the assigned number for the private deployment path.
Troubleshooting
- The tunnel does not start: Run
sinch functions dev --no-tunneland post the local test payloads withcurl. That confirms the handler works while you sort out the network or firewall blocking the Cloudflare Tunnel. - The test call does not reach your laptop: Confirm that
sinch functions devis still running and that you chose the Voice service assigned to the number you dialed. The tunnel updates that service’s callback URL only while the development server is active. - Deployment stops because
.envis missing: Runsinch env init, then add any required secrets again withsinch secrets add. The command restores secret names, not their values.
Cleaning up
Delete the function when you are done:
sinch functions delete <function-id>
The CLI displays the function details and asks you to confirm because deletion cannot be undone. Afterward, it removes the function ID from sinch.json; run sinch functions deploy to deploy the project again. sinch functions list prints function IDs if you do not have one handy. Nothing else to clean up locally beyond deleting the project directory.
Where to go next
The IVR is deliberately small, but the same model applies to inbound SMS and Sinch Conversation API webhooks. A function can send messages through the Sinch SDK clients, use the distributed cache for short-lived state, store durable files, or use the built-in replicated SQLite database when the interaction needs persistence.
For voice, the command builder can also dial phone, SIP, stream, relay, and AI-agent destinations. Put the event-specific communications logic in the function, then call the service that owns your business data or workflow.