Skip to content

Add a custom HTTP endpoint

You'll end up with: a GET /status endpoint that your function exposes, returning JSON. Same pattern works for any REST-style route you want to add.

Why

Sinch Functions are mostly about reacting to voice and messaging events, but the underlying runtime is a regular HTTP server. You can expose any number of custom endpoints alongside your voice or conversation handlers — for health checks, admin APIs, external webhooks (Stripe, Shopify, ElevenLabs), or whatever else you need.

How the runtime routes requests

Node.js

Every named export on your default object becomes an HTTP endpoint. The last segment of the request path is matched to an export name:

Request pathExport called
GET /statusstatus
GET /api/healthhealth
POST /api/v2/usersusers

ice, ace, pie, dice, notify are always voice callbacks regardless of path. Everything else is a custom HTTP endpoint. See handlers for the full mapping rules.

C#

Extend SinchController (for custom endpoints) or add regular MVC controller actions alongside your voice controller. ASP.NET routing works the normal way.

Node.js example

// function.ts
import type { VoiceFunction, FunctionContext, FunctionRequest } from '@sinch/functions-runtime';
import { IceSvamlBuilder } from '@sinch/functions-runtime';

export default {
  // Voice callback — POST /ice
  async ice(context, data) {
    return new IceSvamlBuilder().say('Welcome!').hangup().build();
  },

  // Custom endpoint — GET /status
  async status(context: FunctionContext, request: FunctionRequest) {
    return {
      statusCode: 200,
      body: {
        status: 'healthy',
        functionName: context.config.functionName,
        environment: context.config.environment,
        uptime: process.uptime(),
      },
    };
  },

  // Custom endpoint — POST /inbound
  // Note: do NOT use `notify` as an export name — it's reserved for voice callbacks.
  async inbound(context: FunctionContext, request: FunctionRequest) {
    const body = request.body as { message?: string };
    if (!body.message) {
      return { statusCode: 400, body: { error: 'Missing message' } };
    }
    context.log?.info('Notification received', { message: body.message });
    return { statusCode: 200, body: { received: true } };
  },
} satisfies VoiceFunction;

Custom endpoints return a plain { statusCode, body, headers? } object.

C# example

// StatusController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SinchFunctions.Utils;

[Route("api")]
public class StatusController : SinchController
{
    public StatusController(FunctionContext context, IConfiguration config, ILogger<StatusController> logger)
        : base(context, config, logger) { }

    [HttpGet("status")]
    public IActionResult GetStatus() =>
        Ok(new
        {
            status = "healthy",
            environment = Configuration["ASPNETCORE_ENVIRONMENT"],
        });

    [HttpPost("notify")]
    public async Task<IActionResult> Notify([FromBody] NotifyRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.Message))
            return BadRequest("Missing message");

        Logger.LogInformation("Notification received: {Message}", req.Message);
        await Context.Cache.Set($"notify:{Guid.NewGuid()}", req, 3600);
        return Ok(new { received = true });
    }
}

public record NotifyRequest(string Message);

The controller lives alongside your voice controller — you do not need to pick one or the other.

Accessing the FunctionContext

Custom endpoints get the same context / Context as voice callbacks, so you have cache, storage, database, logger, and pre-wired SDK clients available. Example — rate-limiting a webhook by IP using the cache:

async inbound(context, request) {
  const ip = request.headers?.['x-forwarded-for'] ?? 'unknown';
  const key = `ratelimit:${ip}`;
  const count = (await context.cache.get<number>(key)) ?? 0;

  if (count >= 10) return { statusCode: 429, body: { error: 'rate limited' } };

  await context.cache.set(key, count + 1, 60);
  // ... handle the notification
  return { statusCode: 200, body: { received: true } };
}

Protecting your endpoints

Custom endpoints are public by default — anyone on the internet can hit them. For anything sensitive, add authentication:

  • Basic Auth. The runtime supports a declarative auth export in Node. See protect your function.
  • Bearer token. Read the token from request.headers?.authorization in Node, or use [Authorize] in C# with your own auth handler. Never accept tokens as query parameters — query strings end up in access logs.
  • Webhook signature. For external webhooks (Stripe, Shopify, etc.), verify the signature on the body before processing.
  • Private deploy. sinch functions deploy --private makes the whole function reachable only from within Sinch services.

Testing locally

Start the dev server and hit the endpoint with curl:

sinch functions dev

# In another terminal
curl http://localhost:3000/status
curl -X POST http://localhost:3000/inbound \
  -H "Content-Type: application/json" \
  -d '{"message":"hello"}'

Custom endpoints are tunneled the same way voice callbacks are — if the CLI opens a public tunnel, the endpoint is reachable from the internet at the tunnel URL during sinch functions dev.