Configure Voice failover (Termii → Infobip)
Nervly places Voice OTP calls through a two-carrier chain: Termii first, then Infobip. If Termii fails or stalls, Infobip places the call within the same dispatch, so your code sees one event and one delivery receipt regardless of which carrier answered.
Both carriers are Bring-Your-Own: you add your own credentials in the dashboard. This how-to takes about five minutes.
Voice calls carry a caller-ID binding: the number the recipient sees is
resolved from a caller ID you configure as a sender identity for the workspace,
never inferred from the recipient. Voice is provider-gated on this — only
Infobip honours a caller ID today. A per-message overrides.voice.caller_id is
validated against the caller IDs your workspace owns (anti-spoof); one that
names a caller ID the workspace does not own is refused at dispatch with
SENDER_NOT_ALLOWED, and a call with no eligible caller-ID-capable carrier is
refused rather than dialled from an arbitrary number.
1. Add the Termii credential
- Open the dashboard and go to Providers.
- Add a Termii provider and paste the API key from your Termii account. Voice uses the same Termii credential as SMS — there is no separate voice key.
- Set its priority to
1so it is tried first. - Click Test Connection to verify the credential before real traffic.
Termii carries the primary call. It does not accept a named voice profile, so
voice_id is ignored on this leg — the carrier uses its default voice. It has no
caller-ID configuration surface either, so a caller_id binding cannot be
presented on this leg.
2. Add the Infobip credential
- Add an Infobip provider with your Infobip API key and base URL.
- Set its priority to
2so it is the fallback. - Click Test Connection.
Infobip is the failover leg and the only one of the two that honours
voice_id, so name a TTS voice if you want a specific one. It is also the only
leg that honours caller_id, so name the caller ID you configured if you want a
specific number on the call.
priority controls failover across all channels. A credential used for both
SMS and Voice keeps the same order. See Provider
credentials for the full model.
3. What failover actually does
Voice is opt-in per event: a call is only eligible when the request supplies
overrides.voice (the SDK's voice.send always does). Eligibility is not the
same as being tried first:
- Voice is attempted first only when the event priority is
CRITICAL/HIGHor the request setsextraParams.voice_primaryto"true"/"1". - For a routine (
NORMAL/LOW) event without that flag, the cost order applies: SMS is tried first, and voice is the failover once the SMS attempt times out or fails.
Once voice is the channel being attempted:
- Each carrier attempt gets a 5-second budget. A carrier that accepts the connection but never answers is treated as failed rather than left to ring.
- The first success wins. The receipt names the carrier that placed the call as
provider, so you never have to guess which leg ran. - If both carriers fail, the event ends
FAILEDwitherror_codeDISPATCH_FAILEDand the carriers that were tried inerror_detail.
Each carrier also has its own per-workspace circuit breaker
(breaker:{workspace_id}:{provider}:state). When a carrier's breaker trips
OPEN, that carrier is refused without a network round trip and the chain moves
straight to the next one; after the cool-off it goes HALF_OPEN, admits a single
probe, and either closes on success or re-opens. See Failover & circuit
breakers for the thresholds.
You do not implement this retry yourself — Nervly has already tried both
carriers. Retrying a 202-accepted event only risks a duplicate call.
4. Provider differences to know
| Behaviour | Termii | Infobip |
|---|---|---|
| Role | Primary | Fallback |
voice_id (TTS profile) | Ignored | Honoured |
caller_id (caller ID) | Not supported | Honoured |
language | BCP-47 tag | Reduced to the primary subtag (en-US → en) |
| Credential | API key | API key, base URL |
For the full request contract, see the Voice reference.
5. Place the call first
In Test Mode (nervly_sk_test_...) neither carrier is dialled: the worker
records a normal timeline and returns a synthetic success, so you can exercise
the chain without cost. When you are ready for a real call, send a CRITICAL
event (or set extraParams.voice_primary=true) so voice is attempted first
rather than ordered behind SMS:
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
# CRITICAL puts voice at the head of the order; the event id comes back in the 202.
curl -X POST https://api.nervly.io/v1/events/trigger \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "X-Priority-Override: CRITICAL" \
-H "Content-Type: application/json" \
-d '{
"name": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" },
"overrides": {
"voice": {
"script": "Your verification code is 4827. It expires in five minutes.",
"voice_id": "Ada",
"language": "en-US"
}
}
}'
const response = await fetch('https://api.nervly.io/v1/events/trigger', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NERVLY_API_KEY}`,
'X-Priority-Override': 'CRITICAL',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'otp-login',
to: { subscriberId: 'user_8f21c', phone: '+2348012345678' },
overrides: {
voice: {
script: 'Your verification code is 4827. It expires in five minutes.',
voice_id: 'Ada',
language: 'en-US',
},
},
}),
});
if (!response.ok) {
throw new Error(`Nervly returned ${response.status}: ${await response.text()}`);
}
const result = await response.json();
console.log(`voice queued as ${result.eventId} on ${result.channel}`);
import os
import requests
response = requests.post(
"https://api.nervly.io/v1/events/trigger",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"X-Priority-Override": "CRITICAL",
"Content-Type": "application/json",
},
json={
"name": "otp-login",
"to": {"subscriberId": "user_8f21c", "phone": "+2348012345678"},
"overrides": {
"voice": {
"script": "Your verification code is 4827. It expires in five minutes.",
"voice_id": "Ada",
"language": "en-US",
}
},
},
timeout=10,
)
response.raise_for_status()
result = response.json()
print(f"voice queued as {result['eventId']} on {result['channel']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
payload := map[string]any{
"name": "otp-login",
"to": map[string]string{
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
},
"overrides": map[string]any{
"voice": map[string]string{
"script": "Your verification code is 4827. It expires in five minutes.",
"voice_id": "Ada",
"language": "en-US",
},
},
}
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "https://api.nervly.io/v1/events/trigger", bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
req.Header.Set("X-Priority-Override", "CRITICAL")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
panic(fmt.Sprintf("Nervly returned %d", resp.StatusCode))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Printf("voice queued as %v on %v\n", result["eventId"], result["channel"])
}
<?php
$payload = [
'name' => 'otp-login',
'to' => [
'subscriberId' => 'user_8f21c',
'phone' => '+2348012345678',
],
'overrides' => [
'voice' => [
'script' => 'Your verification code is 4827. It expires in five minutes.',
'voice_id' => 'Ada',
'language' => 'en-US',
],
],
];
$ch = curl_init('https://api.nervly.io/v1/events/trigger');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
'X-Priority-Override: CRITICAL',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 202) {
throw new RuntimeException("trigger rejected with HTTP {$status}: {$response}");
}
$result = json_decode($response, true);
echo "voice queued as {$result['eventId']} on {$result['channel']}\n";
use reqwest::Client;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let payload = json!({
"name": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" },
"overrides": {
"voice": {
"script": "Your verification code is 4827. It expires in five minutes.",
"voice_id": "Ada",
"language": "en-US"
}
}
});
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.post("https://api.nervly.io/v1/events/trigger")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.header("X-Priority-Override", "CRITICAL")
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {raw}").into());
}
let result: serde_json::Value = serde_json::from_str(&raw)?;
println!("voice queued as {} on {}", result["eventId"], result["channel"]);
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class VoiceTrigger {
public static void main(String[] args) throws Exception {
String payload = """
{
"name": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" },
"overrides": {
"voice": {
"script": "Your verification code is 4827. It expires in five minutes.",
"voice_id": "Ada",
"language": "en-US"
}
}
}
""";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/events/trigger"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.header("X-Priority-Override", "CRITICAL")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("Nervly returned " + response.statusCode() + ": " + response.body());
}
System.out.println("voice queued: " + response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
var payload = new
{
name = "otp-login",
to = new { subscriberId = "user_8f21c", phone = "+2348012345678" },
overrides = new
{
voice = new
{
script = "Your verification code is 4827. It expires in five minutes.",
voice_id = "Ada",
language = "en-US",
},
},
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.nervly.io/v1/events/trigger");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
request.Headers.Add("X-Priority-Override", "CRITICAL");
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {responseBody}");
}
var result = JsonNode.Parse(responseBody)!;
Console.WriteLine($"voice queued as {result["eventId"]} on {result["channel"]}");
}
}
require 'json'
require 'net/http'
require 'uri'
uri = URI('https://api.nervly.io/v1/events/trigger')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['X-Priority-Override'] = 'CRITICAL'
request['Content-Type'] = 'application/json'
request.body = {
name: 'otp-login',
to: { subscriberId: 'user_8f21c', phone: '+2348012345678' },
overrides: {
voice: {
script: 'Your verification code is 4827. It expires in five minutes.',
voice_id: 'Ada',
language: 'en-US'
}
}
}.to_json
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
raise "Nervly returned #{response.code}: #{response.body}"
end
result = JSON.parse(response.body)
puts "voice queued as #{result['eventId']} on #{result['channel']}"
import Foundation
struct VoiceOverride: Codable {
let script: String
let voice_id: String
let language: String
}
struct Overrides: Codable {
let voice: VoiceOverride
}
struct Recipient: Codable {
let subscriberId: String
let phone: String
}
struct VoiceTriggerRequest: Codable {
let name: String
let to: Recipient
let overrides: Overrides
}
struct TriggerResponse: Codable {
let eventId: String
let channel: String
}
let request = VoiceTriggerRequest(
name: "otp-login",
to: Recipient(subscriberId: "user_8f21c", phone: "+2348012345678"),
overrides: Overrides(
voice: VoiceOverride(
script: "Your verification code is 4827. It expires in five minutes.",
voice_id: "Ada",
language: "en-US"
)
)
)
let body = try JSONEncoder().encode(request)
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/events/trigger")!)
urlRequest.httpMethod = "POST"
urlRequest.timeoutInterval = 10
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("CRITICAL", forHTTPHeaderField: "X-Priority-Override")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = body
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data,
(200..<300).contains(http.statusCode),
let result = try? JSONDecoder().decode(TriggerResponse.self, from: data) else {
print("trigger failed: \(String(data: data ?? Data(), encoding: .utf8) ?? "")")
return
}
print("voice queued as \(result.eventId) on \(result.channel)")
}.resume()
semaphore.wait()
6. Read which carrier placed the call
Poll the event and read channel and provider from the receipt. termii means
the primary carrier answered; infobip means voice failover ran:
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
curl https://api.nervly.io/v1/events/evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8 \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY" \
| jq -r '"channel=\(.channel) provider=\(.provider) status=\(.status)"'
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
const receipt = await nervly.events.get('evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8');
console.log(`channel=${receipt.channel} provider=${receipt.provider} status=${receipt.status}`);
import os
import requests
event_id = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
response = requests.get(
f"https://api.nervly.io/v1/events/{event_id}",
headers={"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}"},
timeout=10,
)
response.raise_for_status()
receipt = response.json()
print(f"channel={receipt['channel']} provider={receipt['provider']} status={receipt['status']}")
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
eventId := "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
req, _ := http.NewRequest("GET", "https://api.nervly.io/v1/events/"+eventId, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var receipt map[string]any
if err := json.NewDecoder(resp.Body).Decode(&receipt); err != nil {
panic(err)
}
fmt.Printf("channel=%v provider=%v status=%v\n", receipt["channel"], receipt["provider"], receipt["status"])
}
<?php
$eventId = 'evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8';
$ch = curl_init("https://api.nervly.io/v1/events/{$eventId}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NERVLY_API_KEY')],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
curl_close($ch);
$receipt = json_decode($response, true);
echo "channel={$receipt['channel']} provider={$receipt['provider']} status={$receipt['status']}\n";
use reqwest::Client;
use std::env;
use std::error::Error;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let event_id = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8";
let url = format!("https://api.nervly.io/v1/events/{event_id}");
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
let receipt: serde_json::Value = serde_json::from_str(&raw)?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {receipt}").into());
}
println!(
"channel={} provider={} status={}",
receipt["channel"], receipt["provider"], receipt["status"]
);
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class VoiceReceipt {
public static void main(String[] args) throws Exception {
String eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/events/" + eventId))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("Nervly returned " + response.statusCode() + ": " + response.body());
}
System.out.println("Receipt: " + response.body());
}
}
using System;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
var eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.nervly.io/v1/events/{eventId}");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {body}");
}
var receipt = JsonNode.Parse(body)!;
Console.WriteLine($"channel={receipt["channel"]} provider={receipt["provider"]} status={receipt["status"]}");
}
}
require 'json'
require 'net/http'
require 'uri'
event_id = 'evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8'
uri = URI("https://api.nervly.io/v1/events/#{event_id}")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
raise "Nervly returned #{response.code}: #{response.body}"
end
receipt = JSON.parse(response.body)
puts "channel=#{receipt['channel']} provider=#{receipt['provider']} status=#{receipt['status']}"
import Foundation
struct MessageReceipt: Codable {
let channel: String
let provider: String?
let status: String
}
let eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/events/\(eventId)")!)
urlRequest.httpMethod = "GET"
urlRequest.timeoutInterval = 10
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data else { return }
guard (200..<300).contains(http.statusCode) else {
print("Nervly returned \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
return
}
let receipt = try? JSONDecoder().decode(MessageReceipt.self, from: data)
print("channel=\(receipt?.channel ?? "") provider=\(receipt?.provider ?? "none") status=\(receipt?.status ?? "")")
}.resume()
semaphore.wait()
{
"event_id": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"event_name": "otp-login",
"subscriber_id": "user_8f21c",
"status": "DELIVERED",
"channel": "voice",
"provider": "infobip",
"priority": 1,
"attempts": 2,
"cost_micro_usd": 15000,
"test_mode": false,
"variables_keys": [],
"created_at": "2026-09-11T21:00:00.123Z",
"updated_at": "2026-09-11T21:00:12.456Z"
}
Read channel before provider: provider uses the same Termii/Infobip names
for SMS as for voice, so provider: 'termii' alone does not prove a call was
placed. Only once channel: 'voice' does the carrier tell you about the call —
provider: 'termii' means the primary voice carrier answered, and
provider: 'infobip' means voice failover ran, worth a look at the Termii
credential or its account balance. A receipt with channel: 'sms' is an SMS
dispatch, not a voice call.