Skip to content
Last updated

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:

import { handleIce } from './voice.js';
import { handleMessage } from './harness.js';

Hello world

// 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 pathExport calledTreated as
POST /iceiceVoice callback
POST /aceaceVoice callback
POST /piepieVoice callback
POST /dicediceVoice callback
POST /webhook/conversationconversationWebhookConversation
GET /api/healthhealthCustom HTTP
POST /api/v2/usersusersCustom HTTP
GET /default or homeCustom 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.

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:

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.

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? }:

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:

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.

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.

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.

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).

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:

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

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.