Skip to main content

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.

ChannelStatusContact field requiredPrimary providerFailover
SMSLiveto.phoneTermiiInfobip
EmailLiveto.emailPostmark / SendGrid (see below)the other one
Push (Android/web)Liveto.deviceTokensFCM
Push (iOS)Liveto.deviceTokensAPNs
WhatsAppLive, opt-into.phone + overrides.whatsappMeta Cloud API
Voice (TTS call)Live, opt-into.phone + overrides.voiceTermii VoiceInfobip 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:

ChannelEligible when
pushto.deviceTokens is non-empty
smsto.phone is set
whatsappto.phone is set and you sent overrides.whatsapp
voiceto.phone is set and you sent overrides.voice
emailto.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:

PriorityOptimises forOrder
CRITICAL, HIGHReachabilityvoice* → smspushwhatsappemail
NORMAL, LOWCostpushemailwhatsappsmsvoice*

* 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.

One channel per event, with fallback

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:

PriorityTried firstTried second
CRITICAL, HIGHPostmarkSendGrid
NORMAL, LOWSendGridPostmark

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:

KeyUsed as
subjectEmail subject line
titlePush notification title, WhatsApp/SMS lead-in
bodyMessage body on every channel
message, textAliases 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 workflow name (payment_receivedPayment Received).
  • No subject → the title.
  • No body → the title followed by the variables you did send, so a payload never silently vanishes.
Unresolved placeholders render as gaps

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 is trimmed to one segment

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