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
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:
import { handleIce } from './voice.js';
import { handleMessage } from './harness.js';// 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.
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/<service> maps to <service>Webhook camelCased.
Every handler receives a FunctionContext as its first argument. It gives you cache, storage, database, a logger, and pre-wired SDK clients:
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 for the Node + C# side-by-side cheat sheet.
Any named export that is not a voice callback becomes a custom HTTP endpoint. Signature is (context, request) and you return { statusCode, body, headers? }:
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.
Voice callbacks return SVAML via one of the three builders. Each callback type has its own builder with the right subset of actions:
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 for the most-used actions and voice callbacks for the lifecycle.
Two approaches:
Simple: export a conversationWebhook function and parse the body yourself.
Structured: extend ConversationController and override the event methods you care about.
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:
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.
Export a setup() function to run initialization before the server accepts requests, or to register WebSocket endpoints (e.g., for connectStream audio bridging).
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.
Convenience wrapper around context.config and secrets:
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 */ }import {
toLocalPhoneNumber, // '+15551234567' → '(555) 123-4567'
formatDuration, // 150 → '2m 30s'
extractCallerNumber, // strip non-digits
VoiceErrorHelper, // .serviceUnavailable(), .invalidInput(), .goodbye()
} from '@sinch/functions-runtime';- Do use the builders for SVAML — never return raw JSON from voice callbacks.
- Do use
context.assets(...)for private files, notfs.readFileSync. - Do use the
.jsextension in relative imports — the tsconfig is NodeNext. - Don't add instructions after calling
.build()— the builder is frozen at that point. - Don't use
AceSvamlBuilderto connect calls — ACE only supportscontinueandhangup. - Don't override
moduleormoduleResolutionin yourtsconfig.json— the shared base handles it.
- Handlers concept — URL-to-handler 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
- C# runtime — the same thing for .NET devs