It's ASP.NET MVC with controllers. You extend a base controller, override the methods you care about, and dependency injection wires up services and SDK clients. If you already know ASP.NET, skim this page and you are basically done.
Package: Sinch.Functions.Runtime (NuGet) Target framework: .NET 9+ Namespace: SinchFunctions.Utils
MyFunction/
├── MyFunction.csproj ← references Sinch.Functions.Runtime
├── FunctionController.cs ← voice callbacks (extends SinchVoiceController)
├── Init.cs ← optional: ISinchFunctionInit for DI and extra routes
├── appsettings.json ← config (variables, not secrets)
├── sinch.json ← project manifest (name, runtime, variables)
├── assets/ ← private files
└── public/ ← static files, served at /There is no Program.cs. The runtime discovers your ISinchFunctionInit and controllers automatically and boots the ASP.NET pipeline for you.
// FunctionController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SinchFunctions.Models;
using SinchFunctions.Utils;
public class FunctionController : SinchVoiceController
{
public FunctionController(
FunctionContext context,
IConfiguration configuration,
ILogger<FunctionController> logger)
: base(context, configuration, logger) { }
public override async Task<IActionResult> Ice(IceCallbackModel data)
{
return Ok(new IceSvamletBuilder()
.Instructions.Say("Hello from Sinch Functions!")
.Action.Hangup()
.Build());
}
public override Task<IActionResult> Pie(PieCallbackModel data) => Task.FromResult<IActionResult>(Ok());
public override Task<IActionResult> Ace(AceCallbackModel data) => Task.FromResult<IActionResult>(Ok());
public override Task<IActionResult> Dice(DiceCallbackModel data) => Task.FromResult<IActionResult>(Ok());
}That's a complete, deployable voice function. All four abstract methods must be implemented; stub the ones you do not need with Task.FromResult<IActionResult>(Ok()).
Extend whichever matches your use case:
| Base class | Use for |
|---|---|
SinchVoiceController | Voice callbacks — override Ice, Ace, Pie, Dice. |
SinchConversationController | Conversation API webhooks (SMS, WhatsApp, Messenger, etc.). |
ElevenLabsController | ElevenLabs AI voice agent webhooks. |
SinchController | Plain ASP.NET controllers — your own REST endpoints. |
You can have multiple controllers in one project — a FunctionController : SinchVoiceController and a StatusController : SinchController side-by-side.
For REST endpoints alongside your voice logic, extend SinchController and write a regular MVC controller:
[Route("api")]
public class MyController : SinchController
{
public MyController(FunctionContext context, IConfiguration config, ILogger<MyController> logger)
: base(context, config, logger) { }
[HttpGet("status")]
public IActionResult GetStatus() => Ok(new { status = "healthy" });
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest req)
{
await Context.Cache.Set($"user:{req.Id}", req, 3600);
return Ok(req);
}
}Context, Configuration, and Logger are all available on the base class. See add a custom endpoint for a worked example.
public class MyConversationController : SinchConversationController
{
public override async Task<IActionResult> MessageInbound(MessageInboundEvent callback)
{
var text = callback.GetText();
var channel = callback.GetChannel(); // "SMS", "WHATSAPP", etc.
if (text == "hello")
{
var reply = Reply(callback, "Hi there!");
await Context.Conversation!.Messages.Send(reply);
}
return Ok();
}
}Helper extensions on MessageInboundEvent:
using SinchFunctions.Utils;
callback.GetText(); // string?
callback.GetMedia(); // media message
callback.GetChannel(); // "SMS", "WHATSAPP", etc.
callback.GetContactId();
callback.GetConversationId();
callback.GetIdentity(); // sender's phone/PSID
callback.GetTo(); // your Sinch number
callback.IsTextMessage();
callback.IsMediaMessage();
callback.IsPostback();Reply(inbound, text) is a base-class helper that fills in the recipient and app_id from the inbound event so you don't have to.
Implement ISinchFunctionInit in Init.cs to register your own services, add HTTP clients, and map extra routes. The runtime discovers it automatically — no Program.cs plumbing.
public class FunctionInit : ISinchFunctionInit
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<ICustomerService, CustomerService>();
services.AddHttpClient<IMyApiClient, MyApiClient>(client =>
{
client.BaseAddress = new Uri(configuration["MY_API_URL"] ?? "");
});
}
public void ConfigureApp(SinchWebApplication app)
{
app.LandingPageEnabled = true;
app.MapGet("/custom", () => Results.Ok(new { status = "ok" }));
}
}Your middleware runs after the platform middleware, so billing and logging cannot be bypassed.
FunctionContext is injected into every controller constructor. It has cache, storage, database, logger, and pre-wired SDK clients:
public override async Task<IActionResult> Ice(IceCallbackModel data)
{
Logger.LogInformation("ICE from {Cli}", data.Cli);
await Context.Cache.Set($"call:{data.CallId}:cli", data.Cli ?? "unknown", 3600);
// Pre-wired SDK clients
if (Context.Sms is not null)
{
await Context.Sms.Batches.Send(...);
}
return Ok(new IceSvamletBuilder()
.Instructions.Say("Welcome.")
.Action.Hangup()
.Build());
}See context object for the full tour, or function context reference for the Node + C# side-by-side cheat sheet.
The pattern is always: Instructions.* → Action.* → Build().
// ICE
return Ok(new IceSvamletBuilder()
.Instructions.Say("Welcome!")
.Action.ConnectPstn("+15551234567", cli: data.Cli)
.Build());
// ACE — continue or hangup only
return Ok(new AceSvamletBuilder().Action.Continue().Build());
// PIE — respond to menu result
return Ok(new PieSvamletBuilder()
.Instructions.Say("Connecting you to sales.")
.Action.ConnectPstn("+15551111111")
.Build());See SVAML cheat sheet for the most-used actions and voice callbacks for the lifecycle.
IConfiguration is injected into your controllers by ASP.NET — no special API needed:
var companyName = Configuration["COMPANY_NAME"] ?? "Acme Corp";
var apiKey = Configuration["STRIPE_SECRET_KEY"]
?? throw new InvalidOperationException("STRIPE_SECRET_KEY is required");IConfiguration reads from environment variables (populated by the runtime from sinch.json variables + platform secrets) and from appsettings.json / appsettings.Development.json if you have them.
See configuration & secrets for how the layers fit together.
Context.Database.ConnectionString gives you a SQLite connection string. Bring your own SQLite library:
using Microsoft.Data.Sqlite;
using var conn = new SqliteConnection(Context.Database.ConnectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS call_log (id INTEGER PRIMARY KEY, caller TEXT, ts INTEGER)";
cmd.ExecuteNonQuery();Or with Dapper:
using var conn = new SqliteConnection(Context.Database.ConnectionString);
var calls = await conn.QueryAsync<CallRecord>("SELECT * FROM call_log ORDER BY ts DESC LIMIT 10");In production, the platform automatically replicates and backs up the database. Your code doesn't change.
- Do implement all four abstract methods on
SinchVoiceController, even if some just returnOk(). - Do use builder
.Build()for SVAML responses — never return raw anonymous objects. - Do register your own services via
ISinchFunctionInit.ConfigureServices, not by hand-rolling aProgram.cs. - Don't override
HandleWebhookonSinchVoiceController— the base class handles routing. - Don't use
AceSvamletBuilderto connect calls — ACE only supportsContinueandHangup. - Don't return
nullfrom any callback method — always returnOk(...).
- Handlers concept — URL-to-controller mapping in detail
- Context object concept — cache/storage/database/SDK clients
- Function context reference — side-by-side cheat sheet
- SVAML cheat sheet — most-used actions
- Node.js runtime — the same thing for Node devs