# Node.js runtime **It's Express with conventions.** You export handlers, the runtime routes URLs to them, you receive a request and return a response. If you already know Express, skim this page and you are basically done. Package: `@sinch/functions-runtime` ## Project structure A typical Node function looks like this: ``` my-function/ ├── function.ts ← entry point — all exports live here ├── package.json ├── tsconfig.json ← uses the shared config, do not change module settings ├── sinch.json ← project manifest (name, runtime, variables, secrets) ├── .env ← local dev secrets (gitignored) ├── assets/ ← private files, read with context.assets() └── public/ ← static files, served at / ``` All source files live at the project root — the runtime expects them flat. You can split logic into `harness.ts`, `db.ts`, `voice.ts`, etc., and import them from `function.ts`. Relative imports use `.js` extensions because the shared tsconfig uses NodeNext: ```typescript import { handleIce } from './voice.js'; import { handleMessage } from './harness.js'; ``` ## Hello world ```typescript // function.ts import type { VoiceFunction } from '@sinch/functions-runtime'; import { IceSvamlBuilder } from '@sinch/functions-runtime'; export default { async ice(context, data) { return new IceSvamlBuilder() .say('Hello from Sinch Functions!') .hangup() .build(); }, } satisfies VoiceFunction; ``` That's a complete, deployable voice function. Save, run `sinch functions dev`, point a Sinch number at the tunnel URL, and call it. ## Handlers: the Express part Every named property on the default export becomes an HTTP endpoint. The runtime matches request paths to export names: | 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 /webhook/conversation` | `conversationWebhook` | Conversation | | `GET /api/health` | `health` | Custom HTTP | | `POST /api/v2/users` | `users` | Custom HTTP | | `GET /` | `default` or `home` | Custom HTTP root | See [handlers](#) for the full rules. Short version: the **last path segment** is matched to an export name, `ice`/`ace`/`pie`/`dice`/`notify` are always voice callbacks, and `/webhook/` maps to `Webhook` camelCased. ## The context argument Every handler receives a `FunctionContext` as its first argument. It gives you cache, storage, database, a logger, and pre-wired SDK clients: ```typescript async ice(context, data) { await context.cache.set(`call:${data.callid}`, { cli: data.cli }, 3600); const greeting = await context.assets('greetings/en.txt'); return new IceSvamlBuilder() .say(greeting) .runMenu(MenuTemplates.business(context.config.variables.COMPANY_NAME)) .build(); } ``` See [context object](#) for the full tour, or [function context reference](/docs/functions/reference/function-context) for the Node + C# side-by-side cheat sheet. ## Custom HTTP endpoints Any named export that is not a voice callback becomes a custom HTTP endpoint. Signature is `(context, request)` and you return `{ statusCode, body, headers? }`: ```typescript import type { FunctionContext, FunctionRequest } from '@sinch/functions-runtime'; export async function status(context: FunctionContext, request: FunctionRequest) { return { statusCode: 200, body: { status: 'healthy', uptime: process.uptime() } }; } export async function createUser(context: FunctionContext, request: FunctionRequest) { const body = request.body as { name?: string }; if (!body.name) { return { statusCode: 400, body: { error: 'Missing name' } }; } return { statusCode: 201, body: { id: 'u_123', name: body.name } }; } ``` `FunctionRequest` has `method`, `path`, `query`, `headers`, `body`, `params`. Return any `{ statusCode, body, headers? }` object. See [add a custom endpoint](#) for a fuller example. ## Responding to voice callbacks Voice callbacks return SVAML via one of the three builders. Each callback type has its own builder with the right subset of actions: ```typescript import { IceSvamlBuilder, AceSvamlBuilder, PieSvamlBuilder } from '@sinch/functions-runtime'; // ICE — full action surface return new IceSvamlBuilder() .say('Welcome!') .connectPstn('+15551234567', { cli: data.cli }) .build(); // ACE — continue or hangup only return new AceSvamlBuilder().continue().build(); // PIE — respond to a menu result return new PieSvamlBuilder() .say('Connecting you to sales.') .connectPstn('+15551111111') .build(); ``` See [SVAML cheat sheet](/docs/functions/reference/svaml-cheatsheet) for the most-used actions and [voice callbacks](#) for the lifecycle. ## Conversation webhooks Two approaches: **Simple:** export a `conversationWebhook` function and parse the body yourself. **Structured:** extend `ConversationController` and override the event methods you care about. ```typescript import type { FunctionContext } from '@sinch/functions-runtime'; import { ConversationController, getText, getChannel, getContactId } from '@sinch/functions-runtime'; class MyBot extends ConversationController { async handleMessageInbound(event) { const text = getText(event); const channel = getChannel(event); await this.conversation!.messages.send({ sendMessageRequestBody: this.reply(event, `You said: ${text}`), }); } } ``` Helper functions for navigating inbound events without nested property access: ```typescript import { getText, getMedia, getPostbackData, getContactId, getConversationId, getChannel, getIdentity, getTo, getLocation, isTextMessage, isMediaMessage, isPostback, } from '@sinch/functions-runtime'; ``` See [build an SMS responder](#) for a worked example. ## Startup hook and WebSockets Export a `setup()` function to run initialization before the server accepts requests, or to register WebSocket endpoints (e.g., for `connectStream` audio bridging). ```typescript import type { SinchRuntime, FunctionContext } from '@sinch/functions-runtime'; export function setup(runtime: SinchRuntime) { runtime.onStartup(async (context: FunctionContext) => { // Create tables, warm caches, open connections }); runtime.onWebSocket('/stream', (ws, req) => { ws.on('message', (data) => { /* audio frames */ }); }); } ``` `SinchRuntime` is intentionally narrow. You cannot reach the Express app or add middleware — the runtime guarantees platform middleware runs regardless of what your function does. ## UniversalConfig Convenience wrapper around `context.config` and secrets: ```typescript import { createConfig } from '@sinch/functions-runtime'; const config = createConfig(context); const apiKey = config.requireSecret('STRIPE_SECRET_KEY'); // throws if missing const company = config.getVariable('COMPANY_NAME', 'Acme Corp'); if (config.isProduction()) { /* prod only */ } ``` ## Voice utilities ```typescript import { toLocalPhoneNumber, // '+15551234567' → '(555) 123-4567' formatDuration, // 150 → '2m 30s' extractCallerNumber, // strip non-digits VoiceErrorHelper, // .serviceUnavailable(), .invalidInput(), .goodbye() } from '@sinch/functions-runtime'; ``` ## Do and don't - **Do** use the builders for SVAML — never return raw JSON from voice callbacks. - **Do** use `context.assets(...)` for private files, not `fs.readFileSync`. - **Do** use the `.js` extension in relative imports — the tsconfig is NodeNext. - **Don't** add instructions after calling `.build()` — the builder is frozen at that point. - **Don't** use `AceSvamlBuilder` to connect calls — ACE only supports `continue` and `hangup`. - **Don't** override `module` or `moduleResolution` in your `tsconfig.json` — the shared base handles it. ## Related - [Handlers concept](#) — URL-to-handler mapping in detail - [Context object concept](#) — cache/storage/database/SDK clients - [Function context reference](/docs/functions/reference/function-context) — side-by-side cheat sheet - [SVAML cheat sheet](/docs/functions/reference/svaml-cheatsheet) — most-used actions - [C# runtime](#) — the same thing for .NET devs