Priorities & routing
Priority does three things: it puts the event on a broker lane, it chooses between reachability and cost when picking a channel, and — only for a Postmark-primary email setup — it changes which email provider is tried first.
Setting priority
Send the X-Priority-Override header on
POST /v1/events/trigger. The header
is optional; when it is absent or unrecognised the event resolves to NORMAL.
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
# The header is the only place priority is set — there is no `priority` body field.
curl -X POST https://api.nervly.io/v1/events/trigger \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "X-Priority-Override: CRITICAL" \
-H "Content-Type: application/json" \
-d '{
"name": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" }
}'
const response = await fetch('https://api.nervly.io/v1/events/trigger', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NERVLY_API_KEY}`,
'X-Priority-Override': 'CRITICAL',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'otp-login',
to: { subscriberId: 'user_8f21c', phone: '+2348012345678' },
}),
});
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} at ${result.priority}`);
import os
import requests
response = requests.post(
"https://api.nervly.io/v1/events/trigger",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"X-Priority-Override": "CRITICAL",
"Content-Type": "application/json",
},
json={
"name": "otp-login",
"to": {"subscriberId": "user_8f21c", "phone": "+2348012345678"},
},
timeout=10,
)
response.raise_for_status()
result = response.json()
print(f"queued {result['eventId']} on {result['channel']} at {result['priority']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
payload := map[string]any{
"name": "otp-login",
"to": map[string]string{
"subscriberId": "user_8f21c",
"phone": "+2348012345678",
},
}
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("X-Priority-Override", "CRITICAL")
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 at %v\n", result["eventId"], result["channel"], result["priority"])
}
<?php
$payload = [
'name' => 'otp-login',
'to' => [
'subscriberId' => 'user_8f21c',
'phone' => '+2348012345678',
],
];
$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'),
'X-Priority-Override: CRITICAL',
'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']} at {$result['priority']}\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": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" }
});
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("X-Priority-Override", "CRITICAL")
.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 {} at {}",
result["eventId"], result["channel"], result["priority"]
);
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 TriggerCritical {
public static void main(String[] args) throws Exception {
String payload = """
{
"name": "otp-login",
"to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" }
}
""";
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("X-Priority-Override", "CRITICAL")
.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.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 = "otp-login",
to = new { subscriberId = "user_8f21c", phone = "+2348012345678" },
};
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.Headers.Add("X-Priority-Override", "CRITICAL");
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"]} at {result["priority"]}");
}
}
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['X-Priority-Override'] = 'CRITICAL'
request['Content-Type'] = 'application/json'
request.body = {
name: 'otp-login',
to: { subscriberId: 'user_8f21c', phone: '+2348012345678' }
}.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']} at #{result['priority']}"
import Foundation
struct Recipient: Codable {
let subscriberId: String
let phone: String
}
struct TriggerRequest: Codable {
let name: String
let to: Recipient
}
struct TriggerResponse: Codable {
let eventId: String
let status: String
let priority: String
let channel: String
}
let request = TriggerRequest(
name: "otp-login",
to: Recipient(subscriberId: "user_8f21c", phone: "+2348012345678")
)
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("CRITICAL", forHTTPHeaderField: "X-Priority-Override")
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
}
let result = try? JSONDecoder().decode(TriggerResponse.self, from: data)
print("queued \(result?.eventId ?? "") on \(result?.channel ?? "") at \(result?.priority ?? "")")
}.resume()
semaphore.wait()
| Value | Lane | Use it for |
|---|---|---|
CRITICAL | NOTIFY_CRITICAL | OTPs, security alerts, fraud holds — anything blocking a user |
HIGH | NOTIFY_CRITICAL | Transaction confirmations, failed payments |
NORMAL | NOTIFY_BULK | Default. Receipts, status updates |
LOW | NOTIFY_BULK | Digests, marketing, anything batchable |
The header is case-insensitive. Anything unrecognised silently becomes
NORMAL — there is no validation error for a typo, so check the priority
field in the response if it matters:
{
"eventId": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"status": "QUEUED",
"priority": "CRITICAL",
"channel": "sms",
"timestamp": "2026-08-28T09:15:04.221Z"
}
What priority actually changes
The broker lane and stream. Every accepted event is published to NATS JetStream on:
notify.{class}.{channel}
class is critical when the resolved priority is CRITICAL or HIGH (stream
NOTIFY_CRITICAL) and bulk for NORMAL and LOW (stream NOTIFY_BULK). So a
CRITICAL SMS produces notify.critical.sms, and a NORMAL email produces
notify.bulk.email. The workspace, subscriber and payload travel in the
Protobuf message body, not the subject, so subject cardinality does not grow
with signups and one tenant's digest can never sit in front of another tenant's
OTP.
Bulk always uses the bulk lane
POST /v1/events/bulk ignores X-Priority-Override. Every event in a batch is
published to NOTIFY_BULK on notify.bulk.{channel}, even if you set the header
to CRITICAL. Use
POST /v1/events/trigger when a lane
guarantee matters for a specific event.
The channel. Urgent events take the most reachable channel, routine events
the cheapest — so the same recipient gets SMS for a CRITICAL event and email
for a NORMAL one. This is the single biggest cost lever you have. See
Channels & providers.
The email provider. Priority does not choose the email provider on its own.
The order is resolved per workspace: a workspace with an explicit enabled email
provider order uses that order for every priority, otherwise the worker uses the
deployment's email primary provider. That default is resend, so every
priority tries Resend first, then ZeptoMail. Priority only reorders the
built-in order when the email primary provider is postmark — then
CRITICAL/HIGH tries Postmark → SendGrid and NORMAL/LOW tries SendGrid →
Postmark. A sendgrid primary always yields SendGrid → Postmark, a zeptomail
primary always yields ZeptoMail → Resend, and a cloudflare primary always
yields Cloudflare → Resend → ZeptoMail, regardless of priority.
See Channels &
providers.
Priority does not change SMS provider order, and does not bypass rate
limits or subscriber preferences. A CRITICAL event to a subscriber who has
turned off SMS is still suppressed — see Subscriber
preferences.
The subject's channel is the channel Nervly chose
The gateway and the worker resolve the channel with the same rules, so
notify.bulk.email means the event was routed as email. The trigger
response reports the same value as channel.
If every provider on the chosen channel rejects the message, the worker falls
back to the next eligible channel — the event stays on its original subject
while going out by a different route. For what actually carried a message, read
channel from the delivery receipt, not from
the subject.
The full path of an event
The gateway waits for the broker's ack before returning, so a 202 means the
event is durable. Everything after the ack is asynchronous.
Next
- Rate limits
- Idempotency
- Bulk sending — every bulk event uses the bulk lane