Voice
Voice places an automated call that reads a text-to-speech script. It is the
opt-in channel for one-time passcodes and other prompts that must not sit
unread. It is delivered over POST /v1/events/trigger — the same endpoint as
every other channel — with overrides.voice present.
Request contract
POST /v1/events/trigger accepts a ProviderOverrides object. Its voice
property is the opt-in and the per-request configuration:
| Field | Type | Required | Meaning |
|---|---|---|---|
overrides.voice.script | string | null | No | The spoken script. Falls back to the workspace voice template when omitted. {{variable}} placeholders are rendered from payload. |
overrides.voice.voice_id | string | null | No | A named TTS voice profile. Honoured by Infobip; ignored by Termii. The wire name is snake_case (voice_id). |
overrides.voice.language | string | null | No | A BCP-47 language tag for the voice profile, e.g. en-US. |
script, voice_id, and language are all optional and nullable. Supplying
the voice object at all — even empty — is what marks the event as a voice
event; a recipient merely having a phone number does not make it eligible.
The full request schema is in the gateway
reference.
{
"name": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" },
"overrides": {
"voice": {
"script": "Your verification code is 4827",
"voice_id": "Ada",
"language": "en-US"
}
}
}
Routing: eligibility versus order
Supplying overrides.voice makes voice eligible for the recipient; it does
not, on its own, make the call first. Eligibility and order are separate:
- Eligible — the recipient has an E.164
to.phoneand the request suppliesoverrides.voice. - Attempted first — only when the event priority is
CRITICALorHIGH(the reachability order puts voice ahead of SMS), or when the request setsextraParams.voice_primaryto"true"(or"1"). - Failover otherwise — for a
NORMALorLOWevent with neither of those, the cost order applies: SMS is tried first and voice is the fallback if the SMS attempt times out or fails.
The chosen channel is returned as channel on the trigger response, so a
routine event that supplied overrides.voice still comes back as "sms". Use
CRITICAL/HIGH (or extraParams.voice_primary=true) when you genuinely want
the call attempted first.
Phone numbers (E.164)
Voice calls are dialled on the recipient's to.phone, which must be an E.164
number:
- a leading
+ - the country calling code, then the national number
- digits only — no spaces, dashes, brackets, or trunk prefixes
| Value | Valid | Why |
|---|---|---|
+2348012345678 | Yes | +, country code, national number |
+14155552671 | Yes | Same shape, US number |
08012345678 | No | No country code or + |
+234 801 234 5678 | No | Contains spaces |
A recipient with no phone is not eligible for voice. If no supplied contact
detail makes any channel eligible, the trigger is rejected with 400
BAD_REQUEST before it is enqueued.
TTS digit pacing
Text-to-speech engines read a long digit run as one cardinal number — "forty-eight thousand two hundred and ninety-one" — which is useless as a passcode. The worker therefore paces any contiguous run of four or more ASCII digits by inserting a short pause between each digit. The pause is written as a comma, which both Termii and Infobip TTS engines understand:
| Script you send | Script that is spoken |
|---|---|
Your code is 48291 | Your code is 4, 8, 2, 9, 1 |
Your code is 4827 | Your code is 4, 8, 2, 7 |
Total is 500 | Total is 500 (fewer than four digits) |
Pacing is applied after interpolation, so you write the code the way a human
reads it (48291) and the platform inserts the pauses. The transform is
idempotent: an already-paced 4, 8, 2, 9, 1 is left untouched.
Pacing also runs over template variables that survive sanitisation, so a code
injected as {{code}} is paced exactly like one typed into the script.
Language and voice profile
languageaccepts a BCP-47 tag.en_USis normalised toen-US, a bareenresolves toen-US, and an omitted tag defaults toen-US.- Infobip reduces the tag to its primary subtag before calling TTS
(
en-US→en); Termii uses its own default. voice_idnames a TTS profile and is only honoured by Infobip. On the Termii leg it is ignored, so do not depend on a specific voice for the primary call.
Cost
A voice call is billed at roughly $0.015 — about three times an SMS — which
is why the channel is opt-in per event rather than inferred. Cost is reported
per message as cost_micro_usd (15000 micro-USD per call).
Delivery receipts and call status
A carrier accepting the call is not the same as the call finishing, so a voice
event's lifecycle is completed by the carrier's call-status callback. Point
your Termii, Infobip or Twilio voice webhook at
https://api.nervly.io/v1/webhooks/{provider}; Nerve verifies the callback's
signature (Termii X-Termii-Signature, Infobip X-Hub-Signature, Twilio
X-Twilio-Signature) and normalises the carrier's own call state onto the
platform's delivery vocabulary.
Carrier call states such as in-progress, ringing, busy and completed
are never emitted as statuses of their own. They map onto one of
QUEUED, SENT, DELIVERED, FAILED or SUPPRESSED:
| Carrier state | Termii | Infobip | Twilio | Delivery status |
|---|---|---|---|---|
| Accepted / queued | Sent, Sending, Pending, Queued, Accepted, Submitted | PENDING, PENDING_ACCEPTED, PENDING_ENROUTE, ACCEPTED | queued | QUEUED |
| Dialling / ringing / answered | In-Progress, Ringing, Calling, Answered | RINGING, CALL_IN_PROGRESS, ESTABLISHED, ANSWERED | initiated, ringing, in-progress | SENT |
| Call completed | Delivered, Completed, Finished | DELIVERED, COMPLETED, FINISHED | completed | DELIVERED |
| Not reached | Busy, No-Answer, Unanswered, Failed, Rejected, Cancelled, Missed | BUSY, NO_ANSWER, UNDELIVERABLE, UNDELIVERED, EXPIRED, REJECTED, FAILED, DISCARDED | busy, no-answer, failed, canceled | FAILED |
| Blocked before dial | DND, Suppressed | DND, SUPPRESSED | — | SUPPRESSED |
| Anything unrecognised | — | — | — | FAILED |
The callback carries the call duration in seconds and the integer
cost_micro_usd; the message timeline records both, and the message's
cost_micro_usd is updated to the carrier's figure. Nerve stores no recording
URL, transcript or plaintext phone number for a voice event: the receipt carries
a masked recipient and the carrier's own message id only.
If your carrier cannot present a signature, its callback is refused in production rather than trusted — configure webhook signing (or a shared secret) in the carrier's dashboard before pointing it at Nerve.
Error codes
Trigger-time validation failures use the standard error
contract: a 400 with error: "BAD_REQUEST". Once an
event is accepted (202), failures are reported on the message record returned
by GET /v1/events/{eventId}:
error_code | status | Meaning |
|---|---|---|
DISPATCH_FAILED | FAILED | Every carrier in the voice chain failed. error_detail names what was tried, e.g. all voice providers failed. |
SUPPRESSED | SUPPRESSED | Delivery was withheld before any call — for example the subscriber set channels.voice to false, or a promotional voice event was aimed at a DND-screened number. |
A voice event can also fail closed on a scripting error rather than dial an
incomplete prompt. If script references a {{placeholder}} that payload
does not supply, the call is not placed and the event ends FAILED with
error_detail naming the event and the missing keys:
voice template "otp-login" references undefined variables: code. Fix the
payload and send again with a fresh idempotency key.
A nerve_sk_test_... key short-circuits carrier dispatch: no call is placed, a
normal timeline is recorded, and the event reports synthetic success. This is
the safe way to exercise the voice path in CI.