I've built a lot of voice integrations. Every single one needed a deployment whose only job was to talk to the Sinch API: receive the callback, translate it into SVAML or a Conversation API call, hand off to the rest of the application. Not the interesting part. Just the piece that has to exist because your code and Sinch's network live in different places, with a round trip between them on every call event. Sinch Functions moves that piece into the network itself. Your voice and messaging handlers run on the same infrastructure that's already carrying your calls and messages, right where the callbacks originate. This isn't a general-purpose Lambda replacement: the rest of your application, and any compute that isn't glue against Sinch's APIs, stays exactly where it is, whether that's AWS, Azure, or your own infrastructure. Your order lookup, your database writes, your actual business logic: still on Lambda or wherever you already have it. Functions is specifically for the code that exists only to bridge your app and the Sinch network, not a place to run your whole system. I've been trying it out and this post walks through getting set up, writing a basic voice function, and deploying it. Pricing is usage-based and separate from your Voice and Conversation usage: $0.10 per compute-hour (first hour free each month), $0.05 per GB-month of storage (first 0.1 GB free), $0.05 to $0.15 per GB-month for the database depending on tier (first 0.1 GB free), and $5.00 per month per function if you want it kept always-on instead of scaling to zero. These are the current Standard rates from your project's Billing Overview in the Sinch dashboard; treat them as a snapshot, not a guarantee, given the Alpha status below. Sinch Functions is listed as Alpha in the Sinch Build dashboard. Expect the CLI, runtime APIs, pricing, and this walkthrough itself to change before general availability. Treat it as something to experiment with, not something to put in front of production traffic yet. You'll need Node.js 20 (the other supported runtime is C#, but this walkthrough uses Node.js) A Sinch account with a phone number assigned to a Voice App, ideally a Voice App you don't use for anything real yet; more on why under Running Locally below Project ID, Key ID, and Key Secret from the Sinch dashboard Installing the CLI npm install -g @sinch/cli Enter fullscreen mode Exit fullscreen mode Then authenticate: sinch auth login Enter fullscreen mode Exit fullscreen mode The CLI prompts for your Project ID, Key ID, and Key Secret, then two follow-up questions. Say yes when it asks to add Voice application credentials (Key and Secret from your Voice App); this walkthrough builds a voice function and won't work without them. It'll also offer to install Sinch's developer skills for AI coding assistants like Claude Code and Cursor; that's a genuinely interesting feature but a big enough topic (it installs globally across every agent it finds on your machine) that it deserves its own post rather than a detour here. Everything from this step is stored locally and injected automatically when you run or deploy. You don't reference any of it in code. Confirm the connection before moving on: sinch health Enter fullscreen mode Exit fullscreen mode ✔ API is healthy 🏥 Health Status: Status: healthy API URL: https://functions.api.sinch.com Project ID: Enter fullscreen mode Exit fullscreen mode Creating your first function Initialize a project from a template: sinch functions init simple-voice-ivr --name my-simple-voice-ivr Enter fullscreen mode Exit fullscreen mode This template is also available for C#; pass --runtime csharp if you'd rather use that. The command walks you through a few prompts: a company name used in the voice prompts, whether to auto-configure voice integration on deploy (say yes, it's what wires up the callback URL automatically), and whether to enable a database (pick "No database" for this one, since a basic IVR greeting doesn't need persistence). It then installs dependencies for you, so there's no separate npm install step. Once it's done: cd my-simple-voice-ivr Enter fullscreen mode Exit fullscreen mode Here's what the template generates: my-simple-voice-ivr/ ├── .github/ ├── .vscode/ ├── node_modules/ ├── .env ├── AGENTS.md ├── CLAUDE.md ├── README.md ├── function.ts ├── package.json ├── package-lock.json ├── runtime.json ├── sinch.json ├── test.http └── tsconfig.json Enter fullscreen mode Exit fullscreen mode function.ts is the only file you need to touch for this walkthrough. AGENTS.md and CLAUDE.md are instructions for AI coding assistants working in this project, test.http is a set of ready-made requests if your editor supports the REST Client format, and runtime.json and sinch.json are metadata the CLI manages for you. function.ts is where your logic lives. Named exports become HTTP endpoints. The runtime maps them automatically based on the export name. Open it before changing anything. The template already generated a complete, working Yes/No IVR: an ice handler that plays a menu, a pie handler that responds to the key press and retries on bad input, a dice handler that logs when the call ends, and a default handler for a base HTTP endpoint. It's worth reading through once to see the full shape of a real voice function. I'm about to replace most of it with a simpler version to build it back up in stages, not because anything is wrong with what's there. Before touching the generated menu logic, replace it with the simplest possible ice handler, just answering the call: import type { VoiceFunction, FunctionContext } from '@sinch/functions-runtime'; import type { Voice } from '@sinch/voice'; import { createIceBuilder, createUniversalConfig } from '@sinch/functions-runtime'; export default { async ice(context: FunctionContext, callbackData: Voice.IceRequest) { const config = createUniversalConfig(context); const companyName = config.getVariable('COMPANY_NAME', 'Your Company'); return createIceBuilder() .say(`Hello! Thanks for calling ${companyName}.`) .hangup() .build(); }, } satisfies VoiceFunction; Enter fullscreen mode Exit fullscreen mode ice is the Incoming Call Event, the first callback Sinch sends when a call arrives, and it's one of four voice callbacks, each firing at a different point in a call's lifecycle: the call rings in (ICE), the call is answered (ACE), the caller presses a key on a menu (PIE), and the call ends (DICE). Each maps to a named export in function.ts. createIceBuilder() returns a builder with .say() and .hangup() directly, no menu required. createUniversalConfig(context) reads the COMPANY_NAME variable you set during init, so the greeting isn't hardcoded. Don't return raw JSON from voice handlers; use the builders. This walkthrough never implements ace. ice already runs first and does the real work, so most simple IVRs don't need to act separately on the moment the call is answered. I haven't wired it up myself either, so treat that as an educated guess rather than something confirmed; worth exploring if your use case actually needs it. Save that before adding anything else. The next section covers testing it, both locally and with a real call, before the menu gets added on top of it. Running locally sinch functions dev Enter fullscreen mode Exit fullscreen mode This starts a local server on port 3000 with hot reload. On first run, the CLI asks whether to enable a public tunnel, with four options: enable it once, skip it once, always enable and remember, or never enable and remember. Pick one of the "enable" options. It creates a Cloudflare tunnel and updates your Voice App's callback URL to point at your machine. Real Sinch traffic hits your local function. The tunnel only exists for the lifetime of sinch functions dev: it tears down when you stop the process, and your Voice App's callback URL reverts on next deploy. This rewrites the callback URL for the Voice App itself, not some separate dev-only layer. Use a dedicated Voice App with its own number for local development, one you never assign to real customer-facing traffic. If you run sinch functions dev against the same Voice App that's actually handling live calls, you've just rerouted production traffic to your laptop for as long as the dev server is running, so this is worth being deliberate about. Once it's up, the CLI prints the number to call directly, something like 📱 Test Phone Numbers: +15559876543. No need to go look it up in the dashboard. Test it with curl too: curl -s -X POST http://localhost:3000/ice \ -H "Content-Type: application/json" \ -d '{"event":"ice","callId":"test-123","cli":"+15551234567","to":{"type":"number","endpoint":"+15559876543"},"domain":"pstn","originationType":"pstn"}' \ | cat Enter fullscreen mode Exit fullscreen mode to.endpoint is the Sinch number the CLI just printed, the one being called. cli is a fake caller ID standing in for whoever's calling; it doesn't need to be a real number for a local curl test. The local dev server needs both event and callId (capital I) in the body; leave either out and you'll get "Missing event type in request body." That confirms the handler returns valid SVAML, but curl doesn't place an actual call. Since the tunnel is live, call the number you assigned to the Voice App and you should hear the greeting, with the company name you entered during init, for real before the call hangs up. Keep an eye on the terminal where sinch functions dev is running while you do this. It logs each incoming request and the function's actual response in real time, and it's worth seeing at least once: you'll notice a dice callback fires automatically when the call ends, even though you haven't written a dice handler yet. Locally, the runtime handles that gracefully and just logs "DICE callback not implemented, returning 200." Once deployed, the same missing handler gets a 404 ("Function 'dice' not found") instead. Harmless since the call has already ended by the time dice fires, but worth knowing so a 404 in your production logs doesn't send you looking for a real bug. Adding a menu A call that just answers and hangs up isn't much of an IVR. In function.ts, add createMenu to the existing import line (createUniversalConfig is already there from the minimal version), then replace the body of the ice method (leave everything else in the file as is) with this: import { createIceBuilder, createMenu, createUniversalConfig } from '@sinch/functions-runtime'; // inside export default { ... }, replace the ice method with: async ice(context: FunctionContext, callbackData: Voice.IceRequest) { const config = createUniversalConfig(context); const companyName = config.getVariable('COMPANY_NAME', 'Your Company'); const voiceMenu = createMenu() .prompt(`Welcome to ${companyName}! Press 1 for Yes, or press 2 for No.`) .repeatPrompt('Please press 1 for Yes, or 2 for No.') .option('1', 'return(yes)') .option('2', 'return(no)') .timeout(5000) .repeats(2) .maxDigits(1) .build(); return createIceBuilder() .runMenu(voiceMenu) .build(); }, Enter fullscreen mode Exit fullscreen mode createMenu() builds the prompt and DTMF options, and runMenu() replaces the plain .say()/.hangup() chain on the builder with one that waits for input. Call the number again with sinch functions dev still running: you'll hear the menu prompt, but pressing 1 or 2 won't do anything yet, since nothing handles the result. Responding to input Add pie, the Prompt Input Event, fired when the caller presses a key on the menu. Add createPieBuilder to the import line, then add this as a second method alongside ice inside the same export default { ... } object: import { createIceBuilder, createMenu, createUniversalConfig, createPieBuilder } from '@sinch/functions-runtime'; // inside export default { ... }, alongside ice: async pie(context: FunctionContext, callbackData: Voice.PieRequest) { const { menuResult } = callbackData; const selectedOption = menuResult?.inputMethod === 'dtmf' ? menuResult.value : null; switch (selectedOption) { case 'yes': return createPieBuilder() .say('Great! You selected Yes.', 'en-US') .say('Thank you for calling. Goodbye!', 'en-US') .hangup() .build(); case 'no': return createPieBuilder() .say('Okay, you selected No.', 'en-US') .say('Thank you for calling. Goodbye!', 'en-US') .hangup() .build(); default: { const config = createUniversalConfig(context); const companyName = config.getVariable('COMPANY_NAME', 'Your Company'); const retryMenu = createMenu() .prompt(`Welcome to ${companyName}! Press 1 for Yes, or press 2 for No.`) .option('1', 'return(yes)') .option('2', 'return(no)') .timeout(5000) .repeats(1) .maxDigits(1) .build(); return createPieBuilder() .say('Invalid selection. Please try again.', 'en-US') .runMenu(retryMenu) .build(); } } }, Enter fullscreen mode Exit fullscreen mode It reads menuResult off the callback data and responds through createPieBuilder(), using the same menu-building calls as ice to re-prompt on invalid input. Curl can't fake a DTMF session, but you don't need to deploy to test it either: with sinch functions dev still running, the tunnel already points your Voice App's callback URL at your machine, so call the number now and press 1 or 2 for real. Hot reload picks up changes to pie without restarting the dev server. One more worth adding: dice, the Disconnect Call Event, fired after the call has already ended. There's nothing to respond with here, it's just a hook for logging: async dice(context: FunctionContext, callbackData: Voice.DiceRequest) { const duration = callbackData.duration != null ? `${callbackData.duration}ms` : 'unknown'; console.info(`Call ended - reason: ${callbackData.reason}, duration: ${duration}`); }, Enter fullscreen mode Exit fullscreen mode The reason to implement it is basic operational visibility: knowing why and how long calls actually ran, whether callers hang up early, get disconnected, or make it through the whole menu, is the kind of thing you want logged for any real IVR, independent of anything else. It also means you won't see the dashboard's error rate sitting at a nonzero number for a function that's actually working fine, since every call would otherwise fire a 404 once dice runs without a handler (covered under Running Locally above). Here's the complete function.ts at this point, all four handlers assembled: import type { VoiceFunction, FunctionContext } from '@sinch/functions-runtime'; import type { Voice } from '@sinch/voice'; import { createIceBuilder, createMenu, createUniversalConfig, createPieBuilder } from '@sinch/functions-runtime'; export default { async ice(context: FunctionContext, callbackData: Voice.IceRequest) { const config = createUniversalConfig(context); const companyName = config.getVariable('COMPANY_NAME', 'Your Company'); const voiceMenu = createMenu() .prompt(`Welcome to ${companyName}! Press 1 for Yes, or press 2 for No.`) .repeatPrompt('Please press 1 for Yes, or 2 for No.') .option('1', 'return(yes)') .option('2', 'return(no)') .timeout(5000) .repeats(2) .maxDigits(1) .build(); return createIceBuilder() .runMenu(voiceMenu) .build(); }, async pie(context: FunctionContext, callbackData: Voice.PieRequest) { const { menuResult } = callbackData; const selectedOption = menuResult?.inputMethod === 'dtmf' ? menuResult.value : null; switch (selectedOption) { case 'yes': return createPieBuilder() .say('Great! You selected Yes.', 'en-US') .say('Thank you for calling. Goodbye!', 'en-US') .hangup() .build(); case 'no': return createPieBuilder() .say('Okay, you selected No.', 'en-US') .say('Thank you for calling. Goodbye!', 'en-US') .hangup() .build(); default: { const config = createUniversalConfig(context); const companyName = config.getVariable('COMPANY_NAME', 'Your Company'); const retryMenu = createMenu() .prompt(`Welcome to ${companyName}! Press 1 for Yes, or press 2 for No.`) .option('1', 'return(yes)') .option('2', 'return(no)') .timeout(5000) .repeats(1) .maxDigits(1) .build(); return createPieBuilder() .say('Invalid selection. Please try again.', 'en-US') .runMenu(retryMenu) .build(); } } }, async dice(context: FunctionContext, callbackData: Voice.DiceRequest) { const duration = callbackData.duration != null ? `${callbackData.duration}ms` : 'unknown'; console.info(`Call ended - reason: ${callbackData.reason}, duration: ${duration}`); }, } satisfies VoiceFunction; Enter fullscreen mode Exit fullscreen mode ice, pie, and dice are all confirmed working end to end: the menu, both valid answers, bad input triggering the retry, and dice returning 200 instead of a 404. Not included: default, a method name from the original generated template that becomes a base HTTP endpoint (not to be confused with the export default wrapping the whole object). Add it back if you want a custom endpoint; it's not needed for the IVR itself. Configuration and secrets Non-sensitive configuration like company names, support numbers, or feature flags goes in sinch.json under variables: { "name": "my-simple-voice-ivr", "runtime": "nodejs20", "variables": { "SUPPORT_NUMBER": "+15551234567" } } Enter fullscreen mode Exit fullscreen mode init already wrote a .env for you, populated with your Project ID, Key ID, Voice Application Key, and the company name you entered. The actual secrets (Key Secret, Voice Application Secret) are left blank there on purpose since they're pulled from your OS keychain at runtime, not stored in the file. Here's the part to pay attention to: the generated .gitignore does not exclude .env. It covers node_modules/, package-lock.json, and a few log/OS files, but not .env. If you git init && git add . in a fresh project, your Project ID and Key ID go into the repo in plaintext. Add .env to .gitignore yourself before your first commit. For your own secrets, like a third-party API key, declare the name in .env with an empty value: ELEVENLABS_API_KEY= Enter fullscreen mode Exit fullscreen mode Then set the actual value: sinch secrets add ELEVENLABS_API_KEY sk-... Enter fullscreen mode Exit fullscreen mode Locally, values go into your OS keychain. In production, the platform uses an encrypted secret store. Access them in code: import { createUniversalConfig } from '@sinch/functions-runtime'; const config = createUniversalConfig(context); const apiKey = config.requireSecret('ELEVENLABS_API_KEY'); Enter fullscreen mode Exit fullscreen mode requireSecret throws at startup if the value is missing. Good behavior for anything your function can't run without. To attach a debugger while testing any of this: sinch functions dev --debug Enter fullscreen mode Exit fullscreen mode This starts the Node.js inspector on port 9229. Connect from VS Code, Cursor, or WebStorm with F5, or attach manually to ws://localhost:9229. Secrets load automatically from your OS keychain in this mode too, so you don't need to re-enter anything to debug a handler that calls requireSecret. Deploying sinch functions deploy Enter fullscreen mode Exit fullscreen mode Deploy asks two more questions first. The first is whether to generate or update documentation. Say yes; it's more useful than it sounds. It reads your actual code and generates a full write-up: an overview, a step-by-step caller experience walkthrough, key features, and a call flow diagram showing the branches through ice and pie as boxes and arrows. You can view it anytime from the function's Documentation tab in the dashboard, or in the generated README.md. The second question is what access level the function should have. Pick Always Private (internal access only, Sinch services) rather than plain Private: this function only ever receives callbacks from Sinch's own platform, so there's no reason to expose it publicly. The "Always" matters here. Plain Private only applies to this one deploy and asks again next time. Always Private is saved to the project's sinch.json (as "accessPreference": "always-private"), so you don't have to get the answer right on every future redeploy. The CLI then lists every configuration variable and secret it's about to ship, packages your code, and uploads it. Your function ends up live at a URL like https://my-simple-voice-ivr.private.fn.sinch.com; the exact format depends on the access level you picked. The platform updates your Voice App's callback URL automatically, pointing it away from the dev tunnel and at the deployed function. Nothing to configure manually. Call the number again to confirm production behaves the same as local dev did: the Yes/No menu from ice, then a response from pie after pressing 1 or 2. None of the choices you made here are permanent. The function's Settings tab in the dashboard lets you change access level, database tier, always-on, and every configuration variable and secret after the fact, no redeploy needed for most of it. It also has a Danger Zone with a one-click "Delete function," worth knowing about once you're done experimenting, given the Alpha status. To follow logs: sinch functions logs --follow Enter fullscreen mode Exit fullscreen mode Each request streams in fully expanded: time, method, status, duration, and the complete request and response bodies, no navigation needed. Logs live here, not in CloudWatch or whatever you've already got wired up for the rest of your stack. Troubleshooting Port 3000 already in use. sinch functions dev defaults to port 3000, and that's a common default for a lot of other local dev servers too. If something else already has it, run sinch functions dev --port 8080 (or any free port) instead, and point curl at the new port. Tunnel won't establish. sinch functions dev fails to create the Cloudflare tunnel. Usually a network or firewall issue blocking outbound connections; retry, or run with --no-tunnel and test with curl against localhost instead. "Missing secret" at runtime versus at startup. config.requireSecret(...) throws immediately on cold start if the secret was never set with sinch secrets add. A runtime error deeper in your handler instead usually means the secret exists but the key name doesn't match what your code requested; check for typos between the .env declaration and the requireSecret call. Calls connect but nothing happens. If the caller hears silence or a generic carrier message instead of your greeting, the Voice App's callback URL likely isn't pointed at your deployed function. This fails silently from the caller's side; check the Voice App configuration in the dashboard against your deployment output. Cleaning up When you're done, delete the function: sinch functions delete Enter fullscreen mode Exit fullscreen mode (del also works, and it asks for confirmation unless you pass -f.) Find the function ID with sinch functions list if you don't have it handy, or in the dashboard's Overview tab. Same result as the dashboard's Settings → Danger Zone → Delete function button, either way works. Nothing else to clean up locally beyond deleting the project directory itself. What else you can build The simple-voice-ivr template gets you started, but the platform supports more than IVR menus. Browse what's available: sinch templates list sinch templates list --runtime node Enter fullscreen mode Exit fullscreen mode The dashboard lists four popular use cases: phone tree IVRs (press 1 for sales, 2 for support, built with SVAML actions like the one above), AI voice agents, number masking (connecting two parties without exposing either real phone number), and SMS autoresponders (custom or AI-powered replies to inbound messages). Some templates ship in both runtimes; simple-voice-ivr and number-masking each have a Node.js and a C# version. AI voice agents are where it gets interesting, and there's more than one way to build one. 11labs-leadgen and 11labs-support connect calls to ElevenLabs conversational agents, but both are C# templates today. On the Node.js side, realtime-deepgram, realtime-google, and realtime-openai each wire a single WebSocket up to a different provider's voice API (Deepgram's Nova STT plus Aura TTS, or native audio-in/audio-out with Gemini Live or the OpenAI Realtime API, no separate STT/TTS needed for the latter two). Picking one of those and building a real voice agent on it is specific enough to deserve its own post rather than a mention here. The Node.js runtime also gives you custom HTTP endpoints, Conversation API webhooks, and a startup hook for initialization logic. The context object includes a persistent cache shared across invocations, durable blob storage, and an embedded SQLite database. None of it requires provisioning, but all of it is still scoped to Sinch-triggered work: a place to react to calls and messages, not to relocate your API layer. Additional Resources Sinch Functions overview Node.js runtime reference Templates Configuration and secrets If you've got a Sinch integration that's really just gluing callbacks together, curious whether this looks worth trying. Let me know in the comments.
Getting Started with Sinch Functions
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.