# Build Call Routing

Route inbound calls based on business hours, caller ID, geography, or custom logic. All routing decisions happen in your ICE or PIE handler and produce a `connectPstn` or `connectSip` action.

## Create from template

```bash
sinch functions init call-forwarding --name my-router
cd my-router
sinch functions dev
```

## Time-based routing

Node.js
```typescript
import type { VoiceFunction, FunctionContext } from '@sinch/functions-runtime';
import type { Voice } from '@sinch/voice';
import { createIceBuilder, createUniversalConfig } from '@sinch/functions-runtime';

function isBusinessHours(timezone = 'America/New_York'): boolean {
  const now = new Date();
  const local = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
  const day = local.getDay();
  const hour = local.getHours();
  return day >= 1 && day <= 5 && hour >= 9 && hour < 17;
}

export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    const config = createUniversalConfig(context);
    const officeNumber = config.requireVariable('OFFICE_NUMBER');
    const voicemailNumber = config.requireVariable('VOICEMAIL_NUMBER');

    if (isBusinessHours()) {
      return createIceBuilder()
        .say('Connecting you to our team.')
        .connectPstn(officeNumber)
        .build();
    } else {
      return createIceBuilder()
        .say(
          'Our office is currently closed. We are open Monday through Friday, 9 AM to 5 PM Eastern.'
        )
        .connectPstn(voicemailNumber)
        .build();
    }
  },
} satisfies VoiceFunction;
```

C#
```csharp
using Microsoft.AspNetCore.Mvc;
using SinchFunctions.Models;
using SinchFunctions.Utils;

namespace SinchFunctions
{
    public class FunctionController : SinchVoiceController
    {
        public FunctionController(
            FunctionContext context, IConfiguration configuration,
            ILogger<FunctionController> logger) : base(context, configuration, logger) { }

        public override async Task<IActionResult> Ice([FromBody] IceCallbackModel callbackData)
        {
            var officeNumber = Configuration["OFFICE_NUMBER"]
                ?? throw new InvalidOperationException("OFFICE_NUMBER is required");
            var voicemailNumber = Configuration["VOICEMAIL_NUMBER"]
                ?? throw new InvalidOperationException("VOICEMAIL_NUMBER is required");

            if (IsBusinessHours())
            {
                return Ok(new IceSvamletBuilder()
                    .Instructions.Say("Connecting you to our team.")
                    .Action.ConnectPstn(officeNumber)
                    .Build());
            }

            return Ok(new IceSvamletBuilder()
                .Instructions.Say("Our office is currently closed. We are open Monday through Friday, 9 AM to 5 PM Eastern.")
                .Action.ConnectPstn(voicemailNumber)
                .Build());
        }

        private static bool IsBusinessHours(string timezone = "Eastern Standard Time")
        {
            var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone);
            var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
            return local.DayOfWeek >= DayOfWeek.Monday
                && local.DayOfWeek <= DayOfWeek.Friday
                && local.Hour >= 9 && local.Hour < 17;
        }
    }
}
```

## Caller ID-based routing

Node.js
```typescript
const VIP_LIST = new Set(['+15550001111', '+15550002222']);
const BLOCKED_LIST = new Set(['+19990001111']);

export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    const caller = event.cli ?? '';

    if (BLOCKED_LIST.has(caller)) {
      return createIceBuilder().say('We are unable to take your call. Goodbye.').hangup().build();
    }

    if (VIP_LIST.has(caller)) {
      return createIceBuilder()
        .say('Welcome. Connecting you to our priority line.')
        .connectPstn('+15551110010', { cli: caller })
        .build();
    }

    return createIceBuilder()
      .say('Thank you for calling. Connecting you now.')
      .connectPstn('+15551110001')
      .build();
  },
} satisfies VoiceFunction;
```

C#
```csharp
private static readonly HashSet<string> VipList = new() { "+15550001111", "+15550002222" };
private static readonly HashSet<string> BlockedList = new() { "+19990001111" };

public override async Task<IActionResult> Ice([FromBody] IceCallbackModel callbackData)
{
    var caller = callbackData.Cli ?? "";

    if (BlockedList.Contains(caller))
        return Ok(new IceSvamletBuilder()
            .Instructions.Say("We are unable to take your call. Goodbye.")
            .Action.Hangup().Build());

    if (VipList.Contains(caller))
        return Ok(new IceSvamletBuilder()
            .Instructions.Say("Welcome. Connecting you to our priority line.")
            .Action.ConnectPstn("+15551110010", cli: caller).Build());

    return Ok(new IceSvamletBuilder()
        .Instructions.Say("Thank you for calling. Connecting you now.")
        .Action.ConnectPstn("+15551110001").Build());
}
```

## Geographic routing

Node.js
```typescript
function getRegion(number: string): 'us' | 'uk' | 'eu' | 'default' {
  if (number.startsWith('+1')) return 'us';
  if (number.startsWith('+44')) return 'uk';
  if (number.startsWith('+3') || number.startsWith('+4')) return 'eu';
  return 'default';
}

const REGIONAL_NUMBERS: Record<string, string> = {
  us: '+15551110001',
  uk: '+441110000001',
  eu: '+33110000001',
  default: '+15551110001',
};

export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    const region = getRegion(event.cli ?? '');
    return createIceBuilder()
      .say('Connecting you to your regional support team.')
      .connectPstn(REGIONAL_NUMBERS[region])
      .build();
  },
} satisfies VoiceFunction;
```

C#
```csharp
private static string GetRegion(string number) => number switch
{
    var n when n.StartsWith("+1")  => "us",
    var n when n.StartsWith("+44") => "uk",
    var n when n.StartsWith("+3") || n.StartsWith("+4") => "eu",
    _ => "default"
};

private static readonly Dictionary<string, string> RegionalNumbers = new()
{
    ["us"] = "+15551110001", ["uk"] = "+441110000001",
    ["eu"] = "+33110000001", ["default"] = "+15551110001",
};

public override async Task<IActionResult> Ice([FromBody] IceCallbackModel callbackData)
{
    var region = GetRegion(callbackData.Cli ?? "");
    return Ok(new IceSvamletBuilder()
        .Instructions.Say("Connecting you to your regional support team.")
        .Action.ConnectPstn(RegionalNumbers[region]).Build());
}
```

## ConnectPstn options

Node.js
```typescript
return createIceBuilder()
  .connectPstn('+15551234567', {
    cli: '+15550000000', // Caller ID shown to recipient
    maxDuration: 3600, // Max call length in seconds
    timeout: 30, // Ring timeout
    enableAce: true, // Receive ACE callback when answered
    enablePie: true, // Receive PIE callback for DTMF
    enableDice: true, // Receive DICE callback on disconnect
    indications: 'us', // Ring tone country
  })
  .build();
```

C#
```csharp
return Ok(new IceSvamletBuilder()
    .Action.ConnectPstn("+15551234567", new ConnectPstnOptions
    {
        Cli = "+15550000000",
        MaxDuration = 3600,
        ConnectTimeout = 30,
        Indications = "us",
    })
    .Build());
```

## SIP routing

Node.js
```typescript
return createIceBuilder()
  .say('Connecting via SIP.')
  .connectSip('sip:support@pbx.example.com', {
    cli: event.cli,
    headers: { 'X-Custom-Header': 'value' },
  })
  .build();
```

C#
```csharp
return Ok(new IceSvamletBuilder()
    .Instructions.Say("Connecting via SIP.")
    .Action.ConnectSip("sip:support@pbx.example.com", new ConnectSipOptions
    {
        Cli = callbackData.Cli,
        Headers = new Dictionary<string, string> { ["X-Custom-Header"] = "value" }
    })
    .Build());
```

## Fallback handling

Node.js
```typescript
export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    return createIceBuilder()
      .connectPstn('+15551110001', { enableAce: true, enableDice: true, timeout: 20 })
      .build();
  },

  async dice(context: FunctionContext, event: Voice.DiceRequest) {
    if (event.reason === 'noAnswer' || event.reason === 'busy') {
      // DICE fires after disconnect — re-routing is not possible here.
      // Use an IVR menu or park action for active fallback.
      console.warn('Primary destination did not answer:', event.reason);
    }
  },
} satisfies VoiceFunction;
```

C#
```csharp
public override async Task<IActionResult> Ice([FromBody] IceCallbackModel callbackData)
{
    return Ok(new IceSvamletBuilder()
        .Action.ConnectPstn("+15551110001", new ConnectPstnOptions { ConnectTimeout = 20 })
        .Build());
}

public override async Task<IActionResult> Dice([FromBody] DiceCallbackModel callbackData)
{
    if (callbackData.Reason == "noAnswer" || callbackData.Reason == "busy")
        Logger.LogWarning("Primary destination did not answer: {Reason}", callbackData.Reason);
    return Ok();
}
```

For sinch.json configuration and environment variables, see [Templates](/docs/functions/functions/templates) and [Configuration & Secrets](/docs/functions/functions/concepts/configuration-secrets).