# 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 path | Export called |
|  --- | --- |
| `GET /status` | `status` |
| `GET /api/health` | `health` |
| `POST /api/v2/users` | `users` |


`ice`, `ace`, `pie`, `dice`, `notify` are always voice callbacks regardless of path. Everything else is a custom HTTP endpoint. See [handlers](/docs/functions/functions/concepts/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

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

export default {
  // Voice callback — POST /ice
  async ice(context, data) {
    return createIceBuilder().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' } };
    }
    console.log('Notification received', { message: body.message });
    return { statusCode: 200, body: { received: true } };
  },
} satisfies VoiceFunction;
```

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

## C# example

```csharp
// 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:

```typescript
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](/docs/functions/functions/guides/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:

```bash
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`.

## Related

- [Handlers concept](/docs/functions/functions/concepts/handlers) — URL-to-handler mapping rules
- [Context object](/docs/functions/functions/concepts/context-object) — the `context` APIs you can use in custom endpoints
- [Protect your function](/docs/functions/functions/guides/protect-your-function) — authentication for custom endpoints
- [Node.js runtime](/docs/functions/functions/runtimes/nodejs) / [C# runtime](/docs/functions/functions/runtimes/csharp) — runtime reference