Send a Voice OTP in 5 minutes
This tutorial places one automated voice call that speaks a one-time passcode. You will install the SDK, send the call, and confirm the event was accepted.
Voice is opt-in per event. Unlike SMS, it is not inferred from the recipient
having a phone number — a call costs roughly three times an SMS and rings
someone, so you must ask for it by supplying overrides.voice.
Before you start
- An API key from the dashboard. A
nervly_sk_test_...key runs in Test Mode: no carrier is called and nothing is billed. - A destination phone number in E.164 format
(
+2348012345678). The number must be on thetorecipient. - Node.js 24 or later if you follow the SDK path.
1. Install the SDK
npm install @nervly/sdk
2. Place the call
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY! });
const { eventId } = await nervly.voice.send({
name: 'otp-login',
category: 'security',
to: { subscriberId: 'user_8f21c', phone: '+2348012345678' },
script: 'Your verification code is 4827. It expires in five minutes.',
// Optional. Only Infobip honours a named voice profile today.
voice_id: 'Ada',
language: 'en-US',
// OTP is critical, so voice is attempted first rather than ordered behind SMS.
priority: 'CRITICAL',
});
console.log(`Voice call queued as ${eventId}`);
The SDK returns as soon as the gateway has durably accepted the event, with
channel: 'voice'. The call itself is placed asynchronously.
overrides.voice makes the call eligible; it does not by itself make it
first. A NORMAL or LOW event without extraParams.voice_primary is ordered
by cost, so SMS is tried first and the call is the failover. Only CRITICAL /
HIGH priority (as above) or extraParams.voice_primary=true puts voice first.
See Voice routing.
The worker rewrites a run of four or more digits into a comma-separated
sequence so the text-to-speech engine speaks each digit: 4827 becomes
4, 8, 2, 7. You write the code the way a human reads it; the pacing is
applied for you. See the Voice reference for the exact
rule.
3. Or send it with cURL or another language
Every SDK call is a thin wrapper over the gateway. The same call looks like this on the wire across your preferred language or HTTP client:
- cURL
- Node.js (Fetch)
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
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" },
"category": "security",
"overrides": {
"voice": {
"script": "Your verification code is 4827. It expires in five minutes.",
"voice_id": "Ada",
"language": "en-US"
}
}
}'
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const response = await fetch('https://api.nervly.io/v1/events/trigger', {
method: 'POST',
signal: controller.signal,
headers: {
'Authorization': `Bearer ${process.env.NERVLY_API_KEY}`,
'X-Priority-Override': 'CRITICAL',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'otp-login',
category: 'security',
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',
},
},
}),
}).finally(() => clearTimeout(timeout));
if (!response.ok) {
throw new Error(`Nervly returned ${response.status}: ${await response.text()}`);
}
const result = await response.json();
console.log(`Voice call queued as ${result.eventId}`);
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",
"category": "security",
"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 call queued as {result['eventId']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
payload := map[string]any{
"name": "otp-login",
"category": "security",
"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()
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Voice call queued as %v\n", result["eventId"])
}
<?php
$payload = [
'name' => 'otp-login',
'category' => 'security',
'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);
curl_close($ch);
$result = json_decode($response, true);
echo "Voice call queued as " . $result['eventId'] . "\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",
"category": "security",
"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 body: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {body}").into());
}
println!("Voice call queued as {}", body["eventId"]);
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 VoiceOtp {
public static void main(String[] args) throws Exception {
String payload = """
{
"name": "otp-login",
"category": "security",
"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 call 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",
category = "security",
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",
},
},
};
var body = JsonSerializer.Serialize(payload);
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(body, 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 call queued as {result["eventId"]}");
}
}
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',
category: 'security',
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 call queued as #{result['eventId']}"
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 category: String
let to: Recipient
let overrides: Overrides
}
let request = VoiceTriggerRequest(
name: "otp-login",
category: "security",
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 else { return }
guard (200..<300).contains(http.statusCode) else {
print("Nervly returned \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
return
}
let result = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
print("Voice call queued as \(result?["eventId"] ?? "")")
}.resume()
semaphore.wait()
{
"eventId": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"status": "QUEUED",
"channel": "voice",
"priority": "CRITICAL",
"idempotencyKey": null,
"timestamp": "2026-09-18T09:15:04.221Z"
}
4. Confirm what happened
Voice calls are delivered asynchronously. Poll the event you just created:
const receipt = await nervly.events.get(eventId);
console.log(receipt.status, receipt.channel, receipt.provider);
// DELIVERED voice termii
A DELIVERED status means a carrier accepted the call for dialling. If every
carrier fails, the event ends FAILED and error_code tells you why — see
Voice error codes.
Make it safe to retry
Voice calls are expensive and a duplicate is annoying, so pass an idempotency key:
await nervly.voice.send(
{ to: { subscriberId: 'user_8f21c', phone: '+2348012345678' }, script: 'Your code is 4827' },
{ idempotencyKey: 'otp-user_8f21c-2026-09-18' },
);
A retry with the same key returns the cached response instead of placing a second call. See Idempotency.
Next
- Voice reference — pacing, E.164, and error codes.
- Configure Voice failover — Termii and Infobip in the dashboard.
- Why Voice OTP is a fallback.