Channels & providers
A channel is how a notification reaches someone — SMS, email, push. A provider is who carries it. Nervly normalises both so your code names a recipient and a workflow, not a vendor.
Delivery status by channel
This table reflects what the running workers actually dispatch, not what the API will accept. The gateway accepts events for every channel below; only the ones marked Live are delivered.
| Channel | Status | Contact field required | Primary provider | Failover |
|---|---|---|---|---|
| SMS | Live | to.phone | Termii | Infobip → Sendchamp |
| Live | to.email | Resend (default), or Cloudflare Email when selected | ZeptoMail (Resend then ZeptoMail under a Cloudflare primary) | |
| Push (Android/web) | Live | to.deviceTokens | FCM | — |
| Push (iOS) | Live | to.deviceTokens | APNs | — |
| Live, opt-in | to.phone + overrides.whatsapp | Meta Cloud API | — | |
| Voice (TTS call) | Live, opt-in | to.phone + overrides.voice | Termii Voice | Infobip Voice |
| ITSM (PagerDuty, Opsgenie, Slack) | Not implemented | — | — | — |
How a channel is chosen
You do not name a channel. Nervly works out which channels can reach the
recipient from the contact details on to, then picks between them using the
event's priority.
Eligibility — a channel is a candidate only if you supplied what it needs:
| Channel | Eligible when |
|---|---|
push | to.deviceTokens is non-empty |
sms | to.phone is set |
whatsapp | to.phone is set and you sent overrides.whatsapp |
voice | to.phone is set and you sent overrides.voice |
email | to.email is set |
WhatsApp is opt-in per event because Meta only permits free-form text inside an
open 24-hour customer service window; outside it you must name a pre-approved
template. Since only you know which template applies, Nervly will not guess.
Voice is opt-in for a different reason: a call costs roughly three times an SMS
and rings a phone, so it is only a candidate when you ask for it with
overrides.voice.
Ordering — eligible channels are then ranked by what the priority is optimising for:
| Priority | Optimises for | Order |
|---|---|---|
CRITICAL, HIGH | Reachability | voice* → sms → push → whatsapp → email |
NORMAL, LOW | Cost | push → email → whatsapp → sms → voice* |
* voice appears in the order only when the event opted in with
overrides.voice; it is never inferred. For CRITICAL/HIGH that opt-in puts
the call first, and for NORMAL/LOW it makes the call the last-resort
fallback after SMS.
SMS leads the urgent order because it needs no app install, no inbox and no data connection — the property that matters for a one-time passcode. Push leads the cost order at roughly $0.0001 per message against SMS at $0.005.
So the same recipient with both a phone and an email gets SMS for a
CRITICAL event and email for a NORMAL one. That is the cost arbitrage
the platform exists to do; set the priority deliberately with
X-Priority-Override.
The chosen channel is returned as channel on the trigger response, so you
never have to infer it.
An event is delivered on one channel. Nervly tries the ranked channels in order and stops at the first success, so a recipient does not get the same notification twice. To deliberately reach someone on two channels, send two events.
If a recipient has no contact details at all, the event is rejected with 400
at trigger time rather than accepted and dropped later.
Email provider order
Email is the one channel whose provider order is resolved at dispatch time from workspace configuration, falling back to a deployment default — it is not chosen by priority alone.
The worker resolves the order with this precedence:
- Your workspace's provider order — if the workspace has enabled email provider credentials in an explicit order, that order is used as-is for every priority.
- The deployment's
EMAIL_PRIMARY_PROVIDER— used when no workspace order is set. Its default isresend, which yields Resend → ZeptoMail. - A per-event
overrides.email.provider— moves that provider to the front of whichever order was resolved, for that event only.
Priority only reorders the built-in order when the email primary provider is
postmark:
| Email primary provider | CRITICAL, HIGH | NORMAL, LOW |
|---|---|---|
postmark | Postmark → SendGrid | SendGrid → Postmark |
sendgrid | SendGrid → Postmark | SendGrid → Postmark |
zeptomail / zepto | ZeptoMail → Resend | ZeptoMail → Resend |
resend (default) | Resend → ZeptoMail | Resend → ZeptoMail |
cloudflare | Cloudflare → Resend → ZeptoMail | Cloudflare → Resend → ZeptoMail |
So with the default configuration, priority does not choose the email
provider: every priority tries Resend first, then ZeptoMail. The
Postmark/SendGrid flip is a Postmark-primary behaviour, not a general rule. Set
priority with the X-Priority-Override header — see
Priorities and routing.
SMS always tries Termii first, then Infobip, then Sendchamp; Voice always tries Termii first, then Infobip. Neither depends on priority.
Cloudflare Email
Cloudflare Email (Cloudflare Email Sending) is an opt-in email provider you can use two ways:
- Platform-owned — set
EMAIL_PRIMARY_PROVIDER=cloudflareon a deployment that holds the platform'sCLOUDFLARE_EMAIL_API_TOKENandCLOUDFLARE_EMAIL_ACCOUNT_ID. The order iscloudflare → resend → zeptomail. - Bring-your-own (BYO) — connect it as a workspace credential on the
console's Providers page under API Token and Account ID, as either the
JSON object
{"api_token":"…","account_id":"…"}or the colon formaccount_id:api_token. The account id is the Cloudflare account id (32 hex characters); the token needs the Email Sending: Edit permission.
A Cloudflare API token can send from any domain onboarded to Email Sending on its Cloudflare account — there is no per-tenant isolation, and every send is billed to that account. Onboard only the domains you intend to send from, and treat the token as account-wide.
Cloudflare Email is a shared floor provider: it has no per-workspace child account, so even in Platform mode this is the shared platform credential. See Platform isolation for how each provider is separated.
Cloudflare requires the sending domain to be on Cloudflare DNS and onboarded
to Email Sending on that account; a from address on a non-onboarded domain is
rejected. Email Sending is public beta and requires the Workers Paid
plan. It is transactional only — Cloudflare does not support marketing or bulk
email.
What happens when a provider fails
Each provider has its own circuit breaker. A failing provider is taken out of rotation without affecting its partner or any other channel — see Failover and circuit breakers.
If every provider on a channel fails, Nervly moves to the next eligible channel
rather than giving up — a NORMAL event that could not be emailed will be
attempted by SMS. The delivery receipt reports the channel that actually
carried the message, which is why you should read channel from the receipt
rather than assuming it matches the trigger response.
If every provider on every eligible channel fails, the event is retried with a backoff and eventually dead-lettered. See Failover and circuit breakers.
Message content
Nervly has no server-side template store. You send both the copy and the
variables in one payload, and the worker renders one into the other. That
keeps your wording in your own version control rather than in a dashboard
someone can edit without a review.
Copy keys. Five keys in payload are treated as copy rather than as
variables:
| Key | Used as |
|---|---|
subject | Email subject line |
title | Push notification title, WhatsApp/SMS lead-in |
body | Message body on every channel |
message, text | Aliases for body |
Everything else is a variable, referenced from the copy as {{key}}.
Nested objects use dot paths:
{
"payload": {
"subject": "Payment of {{amount}} {{currency}} received",
"body": "Hi {{user.name}}, we received {{amount}} {{currency}}.",
"amount": "24500.00",
"currency": "NGN",
"user": { "name": "Ada" }
}
}
renders as Hi Ada, we received 24500.00 NGN.
Fallbacks, so nothing sends blank:
- No
title→ a humanised form of the workflowname(payment_received→Payment Received). - No
subject→ the title. - No
body→ the title followed by the variables you did send, so a payload never silently vanishes.
A {{key}} with no matching variable renders as an empty string, not as
the literal {{key}} — shipping raw template syntax to an end user is worse
than shipping a gap. The worker logs every unresolved key against the event id,
so check your logs if copy comes out looking short.
SMS bodies are cut to 160 characters with a trailing …. Longer messages would
still send as a concatenated SMS, but each segment is billed separately, so an
unbounded payload could otherwise produce a surprise bill. Keep SMS copy short
deliberately rather than relying on the trim.
Per-channel overrides
Four channels accept typed per-request overrides today, on the overrides
object of POST /v1/events/trigger:
overrides.email.fromandoverrides.email.from_name— the From address and display name for this event. Email also acceptsoverrides.email.reply_to,overrides.email.cc,overrides.email.bcc,overrides.email.customHeaders, andoverrides.email.provider, which prefers or forces an email provider for this event.overrides.sms.sender_idand the optionaloverrides.sms.route— the Sender ID and the carrier routing hint for this event.overrides.whatsapp.template_nameandoverrides.whatsapp.language— both opt the event into WhatsApp and name the approved template to send.overrides.whatsapp.from(E.164) selects the configured WhatsApp sender for the event: it may name only a WhatsApp identity your workspace has configured, and an unowned value refuses withSENDER_NOT_ALLOWED.overrides.voice.caller_id,overrides.voice.script,overrides.voice.voice_id, andoverrides.voice.language— supplying thevoiceobject at all opts the event into voice and configures the caller ID, spoken script, TTS profile, and language.
The canonical fields are the only wire spellings: the old umbrella
overrides.email.sender and overrides.sms.sender were removed, and the
gateway no longer reads or writes them. Voice never had an umbrella sender;
caller_id is its only per-message caller binding.
Anything you omit falls back to the channel configuration held in your workspace. The override is not inferred from the phone number: a recipient having a number does not make voice or WhatsApp eligible.
Every sender override value is checked against the sender identities your workspace has
actually configured (anti-spoof). A per-message sender may name only an exact
sender value, an address under a verified domain the workspace owns, or the
matching Sender ID / caller ID — never an arbitrary address. The worker is the
validation authority and refuses an unowned sender at dispatch with
SENDER_NOT_ALLOWED. Voice caller IDs are provider-gated: only Infobip honours
caller_id today — see Configure Voice
failover.
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
# All four typed overrides on one event, using the canonical sender fields.
curl -X POST https://api.nervly.io/v1/events/trigger \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order-shipped",
"to": {
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
"email": "ada@example.com"
},
"payload": { "orderId": "ORDER-4471" },
"overrides": {
"sms": { "sender_id": "YourBrand", "route": "transactional" },
"email": {
"from": "billing@yourcompany.com",
"from_name": "Your Company",
"reply_to": "support@yourcompany.com",
"cc": ["ops@yourcompany.com"],
"bcc": ["audit@yourcompany.com"],
"customHeaders": { "X-Entity-Ref-ID": "ORDER-4471" },
"provider": "postmark"
},
"whatsapp": { "from": "+2348012345678", "template_name": "order_shipped_v3", "language": "en_US" },
"voice": {
"caller_id": "+2348012340000",
"script": "Your order ORDER 4471 has shipped",
"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}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'order-shipped',
to: {
subscriberId: 'user_8f21c',
phone: '+2348012345678',
email: 'ada@example.com',
},
payload: { orderId: 'ORDER-4471' },
overrides: {
sms: { sender_id: 'YourBrand', route: 'transactional' },
email: {
from: 'billing@yourcompany.com',
from_name: 'Your Company',
reply_to: 'support@yourcompany.com',
cc: ['ops@yourcompany.com'],
bcc: ['audit@yourcompany.com'],
customHeaders: { 'X-Entity-Ref-ID': 'ORDER-4471' },
provider: 'postmark',
},
whatsapp: { from: '+2348012345678', template_name: 'order_shipped_v3', language: 'en_US' },
voice: {
caller_id: '+2348012340000',
script: 'Your order ORDER 4471 has shipped',
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(`queued ${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']}",
"Content-Type": "application/json",
},
json={
"name": "order-shipped",
"to": {
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
"email": "ada@example.com",
},
"payload": {"orderId": "ORDER-4471"},
"overrides": {
"sms": {"sender_id": "YourBrand", "route": "transactional"},
"email": {
"from": "billing@yourcompany.com",
"from_name": "Your Company",
"reply_to": "support@yourcompany.com",
"cc": ["ops@yourcompany.com"],
"bcc": ["audit@yourcompany.com"],
"customHeaders": {"X-Entity-Ref-ID": "ORDER-4471"},
"provider": "postmark",
},
"whatsapp": {"from": "+2348012345678", "template_name": "order_shipped_v3", "language": "en_US"},
"voice": {
"caller_id": "+2348012340000",
"script": "Your order ORDER 4471 has shipped",
"voice_id": "Ada",
"language": "en-US",
},
},
},
timeout=10,
)
response.raise_for_status()
result = response.json()
print(f"queued {result['eventId']} on {result['channel']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
payload := map[string]any{
"name": "order-shipped",
"to": map[string]string{
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
"email": "ada@example.com",
},
"payload": map[string]string{"orderId": "ORDER-4471"},
"overrides": map[string]any{
"sms": map[string]string{
"sender_id": "YourBrand",
"route": "transactional",
},
"email": map[string]any{
"from": "billing@yourcompany.com",
"from_name": "Your Company",
"reply_to": "support@yourcompany.com",
"cc": []string{"ops@yourcompany.com"},
"bcc": []string{"audit@yourcompany.com"},
"customHeaders": map[string]string{"X-Entity-Ref-ID": "ORDER-4471"},
"provider": "postmark",
},
"whatsapp": map[string]string{
"from": "+2348012345678",
"template_name": "order_shipped_v3",
"language": "en_US",
},
"voice": map[string]string{
"caller_id": "+2348012340000",
"script": "Your order ORDER 4471 has shipped",
"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("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("queued %v on %v\n", result["eventId"], result["channel"])
}
<?php
$payload = [
'name' => 'order-shipped',
'to' => [
'subscriberId' => 'user_8f21c',
'phone' => '+2348012345678',
'email' => 'ada@example.com',
],
'payload' => ['orderId' => 'ORDER-4471'],
'overrides' => [
'sms' => ['sender_id' => 'YourBrand', 'route' => 'transactional'],
'email' => [
'from' => 'billing@yourcompany.com',
'from_name' => 'Your Company',
'reply_to' => 'support@yourcompany.com',
'cc' => ['ops@yourcompany.com'],
'bcc' => ['audit@yourcompany.com'],
'customHeaders' => ['X-Entity-Ref-ID' => 'ORDER-4471'],
'provider' => 'postmark',
],
'whatsapp' => ['from' => '+2348012345678', 'template_name' => 'order_shipped_v3', 'language' => 'en_US'],
'voice' => [
'caller_id' => '+2348012340000',
'script' => 'Your order ORDER 4471 has shipped',
'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'),
'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 "queued {$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": "order-shipped",
"to": {
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
"email": "ada@example.com"
},
"payload": { "orderId": "ORDER-4471" },
"overrides": {
"sms": { "sender_id": "YourBrand", "route": "transactional" },
"email": {
"from": "billing@yourcompany.com",
"from_name": "Your Company",
"reply_to": "support@yourcompany.com",
"cc": ["ops@yourcompany.com"],
"bcc": ["audit@yourcompany.com"],
"customHeaders": { "X-Entity-Ref-ID": "ORDER-4471" },
"provider": "postmark"
},
"whatsapp": { "from": "+2348012345678", "template_name": "order_shipped_v3", "language": "en_US" },
"voice": {
"caller_id": "+2348012340000",
"script": "Your order ORDER 4471 has shipped",
"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("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!("queued {} 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 OverrideTrigger {
public static void main(String[] args) throws Exception {
String payload = """
{
"name": "order-shipped",
"to": {
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
"email": "ada@example.com"
},
"payload": { "orderId": "ORDER-4471" },
"overrides": {
"sms": { "sender_id": "YourBrand", "route": "transactional" },
"email": {
"from": "billing@yourcompany.com",
"from_name": "Your Company",
"reply_to": "support@yourcompany.com",
"cc": ["ops@yourcompany.com"],
"bcc": ["audit@yourcompany.com"],
"customHeaders": { "X-Entity-Ref-ID": "ORDER-4471" },
"provider": "postmark"
},
"whatsapp": { "from": "+2348012345678", "template_name": "order_shipped_v3", "language": "en_US" },
"voice": {
"caller_id": "+2348012340000",
"script": "Your order ORDER 4471 has shipped",
"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("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("queued: " + response.body());
}
}
using System;
using System.Collections.Generic;
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 = "order-shipped",
to = new
{
subscriberId = "user_8f21c",
phone = "+2348012345678",
email = "ada@example.com",
},
payload = new { orderId = "ORDER-4471" },
overrides = new
{
sms = new { sender_id = "YourBrand", route = "transactional" },
email = new
{
from = "billing@yourcompany.com",
from_name = "Your Company",
reply_to = "support@yourcompany.com",
cc = new[] { "ops@yourcompany.com" },
bcc = new[] { "audit@yourcompany.com" },
customHeaders = new Dictionary<string, string>
{
["X-Entity-Ref-ID"] = "ORDER-4471",
},
provider = "postmark",
},
whatsapp = new { from = "+2348012345678", template_name = "order_shipped_v3", language = "en_US" },
voice = new
{
caller_id = "+2348012340000",
script = "Your order ORDER 4471 has shipped",
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.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($"queued {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['Content-Type'] = 'application/json'
request.body = {
name: 'order-shipped',
to: {
subscriberId: 'user_8f21c',
phone: '+2348012345678',
email: 'ada@example.com'
},
payload: { orderId: 'ORDER-4471' },
overrides: {
sms: { sender_id: 'YourBrand', route: 'transactional' },
email: {
from: 'billing@yourcompany.com',
from_name: 'Your Company',
reply_to: 'support@yourcompany.com',
cc: ['ops@yourcompany.com'],
bcc: ['audit@yourcompany.com'],
customHeaders: { 'X-Entity-Ref-ID' => 'ORDER-4471' },
provider: 'postmark'
},
whatsapp: { from: '+2348012345678', template_name: 'order_shipped_v3', language: 'en_US' },
voice: {
caller_id: '+2348012340000',
script: 'Your order ORDER 4471 has shipped',
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 "queued #{result['eventId']} on #{result['channel']}"
import Foundation
struct Recipient: Codable {
let subscriberId: String
let phone: String
let email: String
}
struct SmsOverride: Codable {
let sender_id: String
let route: String
}
struct EmailOverride: Codable {
let from: String
let from_name: String
let reply_to: String
let cc: [String]
let bcc: [String]
let customHeaders: [String: String]
let provider: String
}
struct WhatsAppOverride: Codable {
let from: String
let template_name: String
let language: String
}
struct VoiceOverride: Codable {
let caller_id: String
let script: String
let voice_id: String
let language: String
}
struct ProviderOverrides: Codable {
let sms: SmsOverride
let email: EmailOverride
let whatsapp: WhatsAppOverride
let voice: VoiceOverride
}
struct OverrideRequest: Codable {
let name: String
let to: Recipient
let payload: [String: String]
let overrides: ProviderOverrides
}
let request = OverrideRequest(
name: "order-shipped",
to: Recipient(
subscriberId: "user_8f21c",
phone: "+2348012345678",
email: "ada@example.com"
),
payload: ["orderId": "ORDER-4471"],
overrides: ProviderOverrides(
sms: SmsOverride(sender_id: "YourBrand", route: "transactional"),
email: EmailOverride(
from: "billing@yourcompany.com",
from_name: "Your Company",
reply_to: "support@yourcompany.com",
cc: ["ops@yourcompany.com"],
bcc: ["audit@yourcompany.com"],
customHeaders: ["X-Entity-Ref-ID": "ORDER-4471"],
provider: "postmark"
),
whatsapp: WhatsAppOverride(from: "+2348012345678", template_name: "order_shipped_v3", language: "en_US"),
voice: VoiceOverride(
caller_id: "+2348012340000",
script: "Your order ORDER 4471 has shipped",
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("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
}
print("queued: \(String(data: data, encoding: .utf8) ?? "")")
}.resume()
semaphore.wait()
overrides.email.from is read by the email dispatcher and takes effect, as are
overrides.sms.sender_id by the SMS dispatcher and overrides.email.provider
(e.g. postmark, sendgrid, resend, zeptomail, cloudflare), which is moved to the
front of that event's provider order. overrides.whatsapp both makes the
WhatsApp channel eligible and supplies the template name and language, plus the
optional from sender (E.164) naming one of your configured WhatsApp identities.
overrides.voice both makes the Voice channel eligible and supplies the caller
ID, spoken script, TTS voice profile, and language — see the Voice
reference. The removed umbrella overrides.email.sender
and overrides.sms.sender fields are no longer read or written; from /
from_name and sender_id are the only sender spellings on the wire.
overrides.extraParams is a transitional compatibility alias, not the
documented surface. It is a free-form string map that the gateway passes
through to the worker and also uses to flatten the typed overrides for older
workers. It is how the gateway carried the voice override before the typed
VoiceOverride field existed, so the worker still reads email_provider and
the flattened voice keys (voice, voice_script, voice_id, voice_language,
and the ordering flag voice_primary) when the typed field is absent. New
integrations should use the typed overrides.sms, overrides.email,
overrides.whatsapp, and overrides.voice objects;
extraParams remains accepted so existing callers do not break. It is not a
general-purpose routing surface.
Next
- Priorities and routing — how priority changes the queue and the chosen channel
- Failover and circuit breakers
- Delivery receipts — confirming what actually arrived