# Build a Voice IVR ## How a call flows 1. Caller dials your Sinch number. 2. Sinch sends an **ICE** callback to your function. 3. Your function responds with SVAML that runs a menu. 4. Caller presses a key. Sinch sends a **PIE** callback with the selection. 5. Your function responds with SVAML that connects, plays a message, or runs another menu. 6. When the call ends, Sinch sends a **DICE** callback for logging. See [Voice Callbacks](/docs/voice/api-reference/voice-callbacks/) and [SVAML](/docs/voice/api-reference/svaml/) on developers.sinch.com for the full Voice API specification. ## Create from template ```bash sinch functions init simple-voice-ivr --name my-ivr cd my-ivr sinch functions dev ``` For C#: `sinch functions init simple-voice-ivr --name my-ivr --runtime csharp` ## Complete example Node.js ```typescript import type { VoiceFunction, FunctionContext } from '@sinch/functions-runtime'; import type { Voice } from '@sinch/voice'; import { createIceBuilder, createPieBuilder, MenuBuilder, MenuTemplates, createUniversalConfig, } from '@sinch/functions-runtime'; export default { async ice(context: FunctionContext, event: Voice.IceRequest) { const config = createUniversalConfig(context); const companyName = config.getVariable('COMPANY_NAME', 'Acme Corp'); const menu = new MenuBuilder() .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.') .option('1', 'return(sales)') .option('2', 'return(support)') .option('0', 'return(operator)') .timeout(8000) .repeats(2) .maxDigits(1) .build(); return createIceBuilder().say(`Welcome to ${companyName}.`).runMenu(menu).build(); }, async pie(context: FunctionContext, event: Voice.PieRequest) { const selection = event.menuResult?.value; switch (selection) { case 'sales': return createPieBuilder() .say('Connecting you to sales now.') .connectPstn('+15551110001', { cli: '+15550000000' }) .build(); case 'support': return createPieBuilder() .say('Connecting you to support.') .connectPstn('+15551110002') .build(); case 'operator': return createPieBuilder() .say('Please hold for an operator.') .connectPstn('+15551110000') .build(); default: const retryMenu = new MenuBuilder() .prompt('Sorry, we did not receive your selection. Press 1 for sales, or 2 for support.') .option('1', 'return(sales)') .option('2', 'return(support)') .repeats(1) .build(); return createPieBuilder().say('Invalid selection.').runMenu(retryMenu).build(); } }, async dice(context: FunctionContext, event: Voice.DiceRequest) { console.log('Call ended', { reason: event.reason, duration: event.duration }); }, } satisfies VoiceFunction; ``` C# ```csharp using Microsoft.AspNetCore.Mvc; using SinchFunctions.Models; using SinchFunctions.Utils; namespace SinchFunctions { public class FunctionController : SinchVoiceController { public FunctionController( FunctionContext context, IConfiguration configuration, ILogger logger) : base(context, configuration, logger) { } public override async Task Ice([FromBody] IceCallbackModel callbackData) { var companyName = Configuration["COMPANY_NAME"] ?? "Acme Corp"; var menu = MenuFactory.CreateMenu() .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.") .Option("1", "return(sales)") .Option("2", "return(support)") .Option("0", "return(operator)") .Timeout(8000) .Repeats(2) .MaxDigits(1) .Build(); var result = new IceSvamletBuilder() .Instructions.Say($"Welcome to {companyName}.") .Action.RunMenu(menu) .Build(); return Ok(result); } public override async Task Pie([FromBody] PieCallbackModel callbackData) { var selection = callbackData.MenuResult?.Value; return selection switch { "sales" => Ok(new PieSvamletBuilder() .Instructions.Say("Connecting you to sales now.") .Action.ConnectPstn("+15551110001", cli: "+15550000000") .Build()), "support" => Ok(new PieSvamletBuilder() .Instructions.Say("Connecting you to support.") .Action.ConnectPstn("+15551110002") .Build()), "operator" => Ok(new PieSvamletBuilder() .Instructions.Say("Please hold for an operator.") .Action.ConnectPstn("+15551110000") .Build()), _ => Ok(new PieSvamletBuilder() .Instructions.Say("Invalid selection.") .Action.Hangup() .Build()), }; } public override async Task Dice([FromBody] DiceCallbackModel callbackData) { Logger.LogInformation("Call ended: Reason={Reason}, Duration={Duration}s", callbackData.Reason, callbackData.Duration); return Ok(); } } } ``` ## MenuBuilder reference | Method | Description | | --- | --- | | `.prompt(text)` | Main prompt, spoken when the menu starts | | `.repeatPrompt(text)` | Spoken when the caller does not respond | | `.option(dtmf, action)` | Add a key mapping | | `.timeout(ms)` | Wait time before repeating (default 8000) | | `.repeats(n)` | Times to repeat before giving up (default 2) | | `.maxDigits(n)` | Digits to collect before triggering PIE (default 1) | | `.barge(bool)` | Allow keypresses to interrupt the prompt (default true) | | `.build()` | Returns the `MenuStructure` to pass to `runMenu()` | Valid action formats: `'return'` (raw digit), `'return(value)'` (custom value), `'menu(subMenuId)'` (navigate to submenu). ## Multi-level menus Use `.addSubmenu(id)` to chain submenus into a single `runMenu` action. Node.js ```typescript import { createIceBuilder, MenuBuilder } from '@sinch/functions-runtime'; const menu = new MenuBuilder() .prompt('Press 1 for English, 2 for Spanish.') .option('1', 'menu(english)') .option('2', 'menu(spanish)') .addSubmenu('english') .prompt('Press 1 for sales, 2 for support.') .option('1', 'return(en-sales)') .option('2', 'return(en-support)') .addSubmenu('spanish') .prompt('Presione 1 para ventas, 2 para soporte.') .option('1', 'return(es-sales)') .option('2', 'return(es-support)') .build(); return createIceBuilder().runMenu(menu).build(); ``` C# ```csharp // C# requires building each menu separately var mainMenu = MenuFactory.CreateMenu() .Prompt("Press 1 for English, 2 for Spanish.") .Option("1", "menu(english)") .Option("2", "menu(spanish)") .Build(); var englishMenu = MenuFactory.CreateMenu() .Prompt("Press 1 for sales, 2 for support.") .Option("1", "return(en-sales)") .Option("2", "return(en-support)") .Build(); englishMenu.Id = "english"; var spanishMenu = MenuFactory.CreateMenu() .Prompt("Presione 1 para ventas, 2 para soporte.") .Option("1", "return(es-sales)") .Option("2", "return(es-support)") .Build(); spanishMenu.Id = "spanish"; var result = new IceSvamletBuilder() .Action.RunMenu(mainMenu) .Build(); return Ok(result); ``` ## Pre-built MenuTemplates Node.js ```typescript import { MenuTemplates, createIceBuilder } from '@sinch/functions-runtime'; const menu = MenuTemplates.business('Acme Corp'); const yesNo = MenuTemplates.yesNo('Do you want to continue?'); const lang = MenuTemplates.language([ { dtmf: '1', name: 'English', value: 'en-US' }, { dtmf: '2', name: 'Spanish', value: 'es-ES' }, ]); const afterHours = MenuTemplates.afterHours('Acme Corp', '9 AM to 5 PM, Monday through Friday'); return createIceBuilder().runMenu(menu).build(); ``` C# ```csharp using SinchFunctions.Utils; var menu = MenuTemplates.Business("Acme Corp"); var yesNo = MenuTemplates.YesNo("Do you want to continue?"); var lang = MenuTemplates.Language(new List { new() { Dtmf = "1", Name = "English", Value = "en-US" }, new() { Dtmf = "2", Name = "Spanish", Value = "es-ES" }, }); var afterHours = MenuTemplates.AfterHours("Acme Corp", "9 AM to 5 PM, Monday through Friday"); var result = new IceSvamletBuilder() .Action.RunMenu(menu) .Build(); return Ok(result); ``` | Template | Node.js | C# | Returns | | --- | --- | --- | --- | | Business menu | `MenuTemplates.business(name)` | `MenuTemplates.Business(name)` | `sales`, `support`, `operator` | | Yes/No | `MenuTemplates.yesNo(question)` | `MenuTemplates.YesNo(question)` | `yes`, `no` | | Language | `MenuTemplates.language(options)` | `MenuTemplates.Language(options)` | locale value | | After hours | `MenuTemplates.afterHours(name, hours)` | `MenuTemplates.AfterHours(name, hours)` | `voicemail`, `website`, `emergency` | | Recording consent | `MenuTemplates.recordingConsent()` | `MenuTemplates.RecordingConsent()` | `consent`, `no_consent` | ## SVAML builder quick reference ### ICE builder | Method | Effect | | --- | --- | | `.say(text, locale?)` | Play TTS before action | | `.play(url)` | Play audio file | | `.answer()` | Answer the call explicitly | | `.setCookie(key, value)` | Store state across callbacks | | `.startRecording(options?)` | Begin recording | | `.runMenu(menu)` | Run IVR menu (triggers PIE) | | `.connectPstn(number, options?)` | Forward to phone number | | `.connectSip(sipUri, options?)` | Forward to SIP endpoint | | `.connectConf(conferenceId)` | Join a conference | | `.park(holdPrompt?)` | Hold the caller | | `.hangup()` | End the call | ### PIE builder Same instructions as ICE, plus `.connectPstn()`, `.runMenu()`, and `.hangup()`. Does not support `.park()`. For sinch.json configuration and environment variables, see [Templates](#) and [Configuration & Secrets](#).