Skip to main content

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.

ChannelStatusContact field requiredPrimary providerFailover
SMSLiveto.phoneTermiiInfobip → Sendchamp
EmailLiveto.emailResend (default), or Cloudflare Email when selectedZeptoMail (Resend then ZeptoMail under a Cloudflare primary)
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. 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:

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, 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:

PriorityOptimises forOrder
CRITICAL, HIGHReachabilityvoice* → sms → push → whatsapp → email
NORMAL, LOWCostpush → 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.

One channel per event, with fallback

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:

  1. 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.
  2. The deployment's EMAIL_PRIMARY_PROVIDER — used when no workspace order is set. Its default is resend, which yields Resend → ZeptoMail.
  3. 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 providerCRITICAL, HIGHNORMAL, LOW
postmarkPostmark → SendGridSendGrid → Postmark
sendgridSendGrid → PostmarkSendGrid → Postmark
zeptomail / zeptoZeptoMail → ResendZeptoMail → Resend
resend (default)Resend → ZeptoMailResend → ZeptoMail
cloudflareCloudflare → Resend → ZeptoMailCloudflare → 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=cloudflare on a deployment that holds the platform's CLOUDFLARE_EMAIL_API_TOKEN and CLOUDFLARE_EMAIL_ACCOUNT_ID. The order is cloudflare → 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 form account_id:api_token. The account id is the Cloudflare account id (32 hex characters); the token needs the Email Sending: Edit permission.
Cloudflare credentials are account-scoped

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:

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_received → Payment 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​

Four channels accept typed per-request overrides today, on the overrides object of POST /v1/events/trigger:

  • overrides.email.from and overrides.email.from_name — the From address and display name for this event. Email also accepts overrides.email.reply_to, overrides.email.cc, overrides.email.bcc, overrides.email.customHeaders, and overrides.email.provider, which prefers or forces an email provider for this event.
  • overrides.sms.sender_id and the optional overrides.sms.route — the Sender ID and the carrier routing hint for this event.
  • overrides.whatsapp.template_name and overrides.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 with SENDER_NOT_ALLOWED.
  • overrides.voice.caller_id, overrides.voice.script, overrides.voice.voice_id, and overrides.voice.language — supplying the voice object 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.

Senders are validated against your configured identities

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.

# 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"
}
}
}'

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​