Channels & providers
A channel is how a notification reaches someone — SMS, email, push. A provider is who carries it. Nerve 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 |
| Live | to.email | Postmark / SendGrid (see below) | the other one | |
| 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. Nerve 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, Nerve 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. Nerve 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 selection depends on priority
Email is the one channel where the provider is chosen by priority rather than by a fixed primary:
| Priority | Tried first | Tried second |
|---|---|---|
CRITICAL, HIGH | Postmark | SendGrid |
NORMAL, LOW | SendGrid | Postmark |
Urgent mail goes to the provider with better deliverability; routine mail goes
to the cheaper one. Set priority with the X-Priority-Override header — see
Priorities and routing.
SMS and Voice always try Termii first, then Infobip, regardless of priority.
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, Nerve 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
Nerve 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
Three channels accept per-request overrides today, on the overrides object of
POST /v1/events/trigger:
{
"overrides": {
"email": {
"sender": "billing@yourcompany.com",
"customHeaders": { "Reply-To": "support@yourcompany.com" }
},
"whatsapp": {
"template_name": "order_shipped_v3",
"language": "en_US"
},
"voice": {
"script": "Your verification code is 4827",
"voice_id": "Ada",
"language": "en-US"
},
"extraParams": { "route": "transactional" }
}
}
overrides.email.sender is read by the email dispatcher and takes effect.
overrides.whatsapp both makes the WhatsApp channel eligible and supplies the
template name and language. overrides.voice both makes the Voice channel
eligible and supplies the spoken script, TTS voice profile, and language — see
the Voice reference. extraParams is a passthrough the
worker reads today for at least email_provider and the voice keys
(voice_primary, voice_script, voice_id, voice_language); it is not a
general-purpose routing surface.
Next
- Priorities and routing — how priority changes both the queue and the provider
- Failover and circuit breakers
- Delivery receipts — confirming what actually arrived