# Handlers If you know Express or ASP.NET MVC, you already know 90% of the Sinch runtime. This page is the other 10% — the conventions Sinch uses to decide which of your handlers runs when a request comes in. ## The mental model > **Node runtime is effectively Express with conventions.** You export handlers, the runtime maps URL paths to them, you get a request and return a response. **C# runtime is 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. That's it. Everything below is the specifics. ## Node.js: named exports are routes Your `function.ts` file exports an object (or individual named exports). Each export becomes an HTTP endpoint. ```typescript import type { VoiceFunction, FunctionContext, FunctionRequest } from '@sinch/functions-runtime'; import { IceSvamlBuilder } from '@sinch/functions-runtime'; export default { // POST /ice — voice callback, auto-detected by name async ice(context, data) { return new IceSvamlBuilder().say('Welcome!').hangup().build(); }, // GET /status — custom HTTP endpoint async status(context, request) { return { statusCode: 200, body: { ok: true } }; }, // GET /health — custom HTTP endpoint async health(context, request) { return { statusCode: 200, body: { healthy: true } }; }, } satisfies VoiceFunction; ``` ### Routing rules | Request path | Export called | Treated as | | --- | --- | --- | | `POST /ice` | `ice` | Voice callback | | `POST /ace` | `ace` | Voice callback | | `POST /pie` | `pie` | Voice callback | | `POST /dice` | `dice` | Voice callback | | `POST /notify` | `notify` | Voice callback | | `POST /webhook/conversation` | `conversationWebhook` | Conversation | | `GET /api/health` | `health` | Custom HTTP | | `POST /api/v2/users` | `users` | Custom HTTP | | `GET /` | `default` or `home` | Custom HTTP root | The rules in plain English: - **The last segment of the URL path is matched to an export name.** `/api/v2/users` → export `users`. - **`ice`, `ace`, `pie`, `dice`, `notify` are voice callbacks regardless of path.** They get the callback payload as `data` and should return SVAML. - **`/webhook/` is a special pattern.** It maps to a camelCase name with `Webhook` suffixed — `/webhook/conversation` → `conversationWebhook`. - **`default` and `home` both catch `GET /`.** Use whichever feels more natural; `home` is TypeScript-friendlier since `default` is a reserved word. ### Voice callbacks vs custom endpoints | Handler type | Signature | Returns | | --- | --- | --- | | Voice callback | `(context, data: Voice.IceRequest)` | SVAML (from a builder) | | Custom endpoint | `(context, request: FunctionRequest)` | `{ statusCode, body }` | Return any plain `{ statusCode, body, headers? }` object: ```typescript return { statusCode: 200, body: { status: 'healthy' } }; return { statusCode: 400, body: { error: 'Missing required field: name' } }; return { statusCode: 404, body: { error: 'Not found' } }; ``` ## C#: extend a controller ```csharp public class FunctionController : SinchVoiceController { public FunctionController( FunctionContext context, IConfiguration configuration, ILogger logger) : base(context, configuration, logger) { } public override async Task Ice(IceCallbackModel data) { return Ok(new IceSvamletBuilder() .Instructions.Say("Welcome!") .Action.Hangup() .Build()); } public override Task Pie(PieCallbackModel data) => ...; public override Task Ace(AceCallbackModel data) => ...; public override Task Dice(DiceCallbackModel data) => ...; } ``` ### The four base controllers | Base class | When to use | | --- | --- | | `SinchVoiceController` | Voice callback handling. Override `Ice`, `Ace`, `Pie`, `Dice`. | | `SinchConversationController` | Conversation API webhooks (SMS, WhatsApp, Messenger, etc.). | | `ElevenLabsController` | ElevenLabs AI voice agent webhooks. | | `SinchController` | Plain HTTP controllers (your own REST endpoints). | You can have more than one controller in your project — extend `SinchVoiceController` for voice and `SinchController` for your custom API endpoints side by side. ### Custom HTTP endpoints For non-voice endpoints, use a regular ASP.NET MVC controller that extends `SinchController`: ```csharp [Route("api")] public class MyController : SinchController { public MyController(FunctionContext context, IConfiguration config, ILogger logger) : base(context, config, logger) { } [HttpGet("status")] public IActionResult GetStatus() => Ok(new { status = "healthy" }); } ``` ### Dependency injection and startup Implement `ISinchFunctionInit` to register services, configure middleware, or map extra routes. The runtime discovers it automatically — there is no `Program.cs`. ```csharp public class FunctionInit : ISinchFunctionInit { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); services.AddHttpClient(); } 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. ## Node extension points ### `setup()` — startup hook and WebSockets Node functions can export a `setup()` function that runs once before the server accepts requests. Use it for initialization and to register WebSocket endpoints (for `connectStream` in SVAML). ```typescript import type { SinchRuntime, FunctionContext } from '@sinch/functions-runtime'; export function setup(runtime: SinchRuntime) { runtime.onStartup(async (context: FunctionContext) => { // Create tables, seed data, warm caches }); runtime.onWebSocket('/stream', (ws, req) => { ws.on('message', (data) => { // Handle binary audio frames }); }); } ``` `SinchRuntime` is intentionally narrow — you cannot reach the Express app or add middleware. That is on purpose: the runtime guarantees platform middleware (billing, logging, validation) runs regardless of what your function does. ## Multi-file Node projects `function.ts` is the entry point, and all endpoints must be exported from it. Other files in the project root are internal modules you import from. ``` function.ts ← entry point, all exports live here harness.ts ← shared business logic voice.ts ← voice handler implementations api.ts ← REST endpoint implementations assets/ ← private files (context.assets()) public/ ← static files (served at /) ``` Relative imports use `.js` extensions because the shared tsconfig uses NodeNext: ```typescript import { handleMessage } from './harness.js'; import { handleIce } from './voice.js'; ``` Keep all source files at the project root — the runtime expects them flat. ## When something goes wrong On startup, both runtimes log the set of detected handlers: ``` Detected functions: POST /webhook/conversation → conversationWebhook GET /api/health → health POST /ice → ice (voice) ``` If a request hits a path with no matching export, the error response lists the handlers you do have. That is almost always the first thing to check when a callback is not firing — make sure Sinch's callback URL matches a handler name, and make sure it is actually exported. ## Related - [Voice callbacks](#) — ICE/ACE/PIE/DICE lifecycle and payloads - [SVAML](#) — what voice handlers return - [Context object](#) — the `context` argument every handler receives - [Node.js runtime](#) / [C# runtime](#) — per-runtime reference