Quickstart
Send one notification, verify it was accepted, and check its delivery status. Walkable by a stranger in under ten minutes.
1. Sign up and get an API key
- Open app.nervly.io and sign up with your email or Google OAuth.
- An initial workspace is created automatically on signup.
- Go to Developers in the sidebar.
- Click Create API Key. Choose Test for sandbox evaluation or Live for real dispatch.
- Copy your key (
nervly_sk_test_...ornervly_sk_live_...).
API keys are hashed with SHA-256 before storage; the plaintext token is never recoverable from the dashboard. Store it in your secret manager.
2. Connect a provider (or use Test Mode)
- Test Mode: If you use a
nervly_sk_test_...key, no external provider configuration is needed! Nervly automatically short-circuits provider dispatch, records normal message timelines, and returns synthetic success without incurring telco charges. - Live Mode: Go to Providers in the dashboard and add your credentials for Termii, Postmark, SendGrid, or any supported provider. Click Test Connection to verify your credentials.
3. Verify gateway reachability
Confirm you can reach the edge gateway without authentication:
curl https://api.nervly.io/v1/health
{
"status": "OK",
"service": "nervly-gateway",
"version": "0.1.0",
"environment": "production",
"deployment": "primary",
"key_mode": "any",
"uptime_seconds": 86400,
"nats_connected": true
}
4. Send a notification
Trigger an event using cURL, the TypeScript/Node.js SDK, or standard HTTP client code in Python, Go, PHP, Rust, Java, C#, Ruby, or Swift:
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
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-confirmed",
"to": {
"subscriberId": "sub_4920a",
"phone": "+2348012345678",
"email": "customer@example.com"
},
"payload": {
"orderId": "ORD-94102",
"amount": "15,000 NGN"
}
}'
Install @nervly/sdk:
npm install @nervly/sdk
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
const result = await nervly.events.trigger({
name: 'order-confirmed',
to: {
subscriberId: 'sub_4920a',
phone: '+2348012345678',
email: 'customer@example.com',
},
payload: {
orderId: 'ORD-94102',
amount: '15,000 NGN',
},
});
console.log(`Accepted with Event ID: ${result.eventId}`);
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-confirmed",
"to": {
"subscriberId": "sub_4920a",
"phone": "+2348012345678",
"email": "customer@example.com",
},
"payload": {
"orderId": "ORD-94102",
"amount": "15,000 NGN",
},
},
timeout=10,
)
response.raise_for_status()
result = response.json()
print(f"Accepted with Event ID: {result['eventId']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
payload := map[string]any{
"name": "order-confirmed",
"to": map[string]string{
"subscriberId": "sub_4920a",
"phone": "+2348012345678",
"email": "customer@example.com",
},
"payload": map[string]string{
"orderId": "ORD-94102",
"amount": "15,000 NGN",
},
}
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()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Printf("Accepted with Event ID: %v\n", result["eventId"])
}
<?php
$apiKey = getenv('NERVLY_API_KEY');
$payload = [
'name' => 'order-confirmed',
'to' => [
'subscriberId' => 'sub_4920a',
'phone' => '+2348012345678',
'email' => 'customer@example.com',
],
'payload' => [
'orderId' => 'ORD-94102',
'amount' => '15,000 NGN',
],
];
$ch = curl_init('https://api.nervly.io/v1/events/trigger');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
echo "Accepted with Event ID: " . $result['eventId'] . "\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-confirmed",
"to": {
"subscriberId": "sub_4920a",
"phone": "+2348012345678",
"email": "customer@example.com"
},
"payload": {
"orderId": "ORD-94102",
"amount": "15,000 NGN"
}
});
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 body: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {body}").into());
}
println!("Accepted with Event ID: {}", body["eventId"]);
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 TriggerEvent {
public static void main(String[] args) throws Exception {
String payload = """
{
"name": "order-confirmed",
"to": {
"subscriberId": "sub_4920a",
"phone": "+2348012345678",
"email": "customer@example.com"
},
"payload": {
"orderId": "ORD-94102",
"amount": "15,000 NGN"
}
}
""";
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("Accepted: " + 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 = "order-confirmed",
to = new
{
subscriberId = "sub_4920a",
phone = "+2348012345678",
email = "customer@example.com",
},
payload = new
{
orderId = "ORD-94102",
amount = "15,000 NGN",
},
};
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($"Accepted with Event ID: {result["eventId"]}");
}
}
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-confirmed',
to: {
subscriberId: 'sub_4920a',
phone: '+2348012345678',
email: 'customer@example.com'
},
payload: {
orderId: 'ORD-94102',
amount: '15,000 NGN'
}
}.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 "Accepted with Event ID: #{result['eventId']}"
import Foundation
struct Recipient: Codable {
let subscriberId: String
let phone: String
let email: String
}
struct TriggerRequest: Codable {
let name: String
let to: Recipient
let payload: [String: String]
}
struct TriggerResponse: Codable {
let eventId: String
let status: String
}
let request = TriggerRequest(
name: "order-confirmed",
to: Recipient(
subscriberId: "sub_4920a",
phone: "+2348012345678",
email: "customer@example.com"
),
payload: ["orderId": "ORD-94102", "amount": "15,000 NGN"]
)
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
}
let result = try? JSONDecoder().decode(TriggerResponse.self, from: data)
print("Accepted with Event ID: \(result?.eventId ?? "")")
}.resume()
semaphore.wait()
Response:
{
"eventId": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"status": "QUEUED",
"channel": "email",
"priority": "NORMAL",
"idempotencyKey": null,
"timestamp": "2026-09-11T21:00:00.000Z"
}
5. Check delivery status
Query the live delivery status of your event:
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
curl https://api.nervly.io/v1/events/evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8 \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY"
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
const receipt = await nervly.events.get('evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8');
console.log(`Status: ${receipt.status}, Channel: ${receipt.channel}`);
import os
import requests
eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
response = requests.get(
f"https://api.nervly.io/v1/events/{eventId}",
headers={"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}"},
timeout=10,
)
response.raise_for_status()
receipt = response.json()
print(f"Status: {receipt['status']}, Channel: {receipt['channel']}")
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
eventId := "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
req, _ := http.NewRequest("GET", "https://api.nervly.io/v1/events/"+eventId, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var receipt map[string]any
json.NewDecoder(resp.Body).Decode(&receipt)
fmt.Printf("Status: %v, Channel: %v\n", receipt["status"], receipt["channel"])
}
<?php
$eventId = 'evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8';
$ch = curl_init("https://api.nervly.io/v1/events/{$eventId}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NERVLY_API_KEY')],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
curl_close($ch);
$receipt = json_decode($response, true);
echo "Status: " . $receipt['status'] . ", Channel: " . $receipt['channel'] . "\n";
use reqwest::Client;
use std::env;
use std::error::Error;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let event_id = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8";
let url = format!("https://api.nervly.io/v1/events/{event_id}");
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
let receipt: serde_json::Value = serde_json::from_str(&raw)?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {receipt}").into());
}
println!("Status: {}, Channel: {}", receipt["status"], receipt["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 CheckStatus {
public static void main(String[] args) throws Exception {
String eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/events/" + eventId))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.GET()
.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("Receipt: " + response.body());
}
}
using System;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
var eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.nervly.io/v1/events/{eventId}");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {body}");
}
var receipt = JsonNode.Parse(body)!;
Console.WriteLine($"Status: {receipt["status"]}, Channel: {receipt["channel"]}");
}
}
require 'json'
require 'net/http'
require 'uri'
event_id = 'evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8'
uri = URI("https://api.nervly.io/v1/events/#{event_id}")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
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
receipt = JSON.parse(response.body)
puts "Status: #{receipt['status']}, Channel: #{receipt['channel']}"
import Foundation
struct MessageReceipt: Codable {
let status: String
let channel: String
}
let eventId = "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/events/\(eventId)")!)
urlRequest.httpMethod = "GET"
urlRequest.timeoutInterval = 10
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
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 receipt = try? JSONDecoder().decode(MessageReceipt.self, from: data)
print("Status: \(receipt?.status ?? ""), Channel: \(receipt?.channel ?? "")")
}.resume()
semaphore.wait()
{
"event_id": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"event_name": "order-confirmed",
"subscriber_id": "sub_4920a",
"status": "DELIVERED",
"channel": "email",
"provider": "resend",
"priority": 3,
"attempts": 1,
"cost_micro_usd": 400,
"test_mode": false,
"variables_keys": ["orderId", "amount"],
"created_at": "2026-09-11T21:00:00.123Z",
"updated_at": "2026-09-11T21:00:01.456Z"
}
Or view the event visually in app.nervly.io/messages to see the delivery timeline.
6. Make the call safe to retry
Add an Idempotency-Key header with a unique business identifier (e.g. order-ORD-94102):
curl -X POST https://api.nervly.io/v1/events/trigger \
--connect-timeout 5 \
--max-time 10 \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Idempotency-Key: order-ORD-94102" \
-H "Content-Type: application/json" \
-d '{ ... }'
If a network timeout occurs and your application retries, Nervly returns the cached response with 200 OK and prevents duplicate customer notifications.
Next steps
- Runnable examples — End-to-end runnable example apps across SDK, API, CLI, and browser
- Authentication — Live vs Test mode details
- Rate limits — Two-tier rate limits and Retry-After
- Subscriber preferences — Managing opt-outs and DND compliance
- Quotas and billing — Subscription management