Authentication
Nervly has two authentication mechanisms, because it has two kinds of caller.
| Who is calling | How they authenticate | Which routes |
|---|---|---|
| Your application | Authorization: Bearer <API key> | Everything under /v1/ except health and webhooks |
| Your providers | Per-provider HMAC signature | POST /v1/webhooks/{provider} |
| Nobody / your load balancer | No auth | GET /v1/health |
Bearer API keys
Send your workspace API key in the Authorization header on every request.
Every example below sends the same authenticated POST /v1/events/trigger:
- 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" }
}'
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({
apiKey: process.env.NERVLY_API_KEY, // nervly_sk_live_...
});
await nervly.events.trigger({
name: 'order-confirmed',
to: { subscriberId: 'sub_4920a', phone: '+2348012345678' },
});
import os
import requests
response = requests.post(
"https://api.nervly.io/v1/events/trigger",
headers={"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}"},
json={
"name": "order-confirmed",
"to": {"subscriberId": "sub_4920a", "phone": "+2348012345678"},
},
timeout=10,
)
if response.status_code == 401:
raise RuntimeError("401 Unauthorized — check your NERVLY_API_KEY")
response.raise_for_status()
print("Accepted:", response.json()["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",
},
}
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()
if resp.StatusCode == http.StatusUnauthorized {
panic("401 Unauthorized — check your NERVLY_API_KEY")
}
if resp.StatusCode >= 300 {
panic(fmt.Sprintf("Nervly returned %d", resp.StatusCode))
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Accepted: %v\n", result["eventId"])
}
<?php
$payload = [
'name' => 'order-confirmed',
'to' => ['subscriberId' => 'sub_4920a', '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'),
'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 === 401) {
throw new RuntimeException('401 Unauthorized — check your NERVLY_API_KEY');
}
if ($status >= 300) {
throw new RuntimeException("Nervly returned {$status}: {$response}");
}
echo 'Accepted: ' . json_decode($response, true)['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" }
});
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.post("https://api.nervly.io/v1/events/trigger")
.bearer_auth(env::var("NERVLY_API_KEY")?)
.json(&payload)
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("401 Unauthorized — check your NERVLY_API_KEY".into());
}
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: {}", 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 AuthenticatedRequest {
public static void main(String[] args) throws Exception {
String payload = """
{ "name": "order-confirmed", "to": { "subscriberId": "sub_4920a", "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("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 401) {
throw new SecurityException("401 Unauthorized — check your NERVLY_API_KEY");
}
if (response.statusCode() >= 300) {
throw new IllegalStateException("Nervly returned " + response.statusCode() + ": " + response.body());
}
System.out.println("Accepted: " + response.body());
}
}
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
var payload = "{ \"name\": \"order-confirmed\", \"to\": { \"subscriberId\": \"sub_4920a\", \"phone\": \"+2348012345678\" } }";
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(payload, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UnauthorizedAccessException("401 Unauthorized — check your NERVLY_API_KEY");
}
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {body}");
}
Console.WriteLine($"Accepted: {body}");
}
}
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' }
}.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)
case response
when Net::HTTPSuccess
puts "Accepted: #{JSON.parse(response.body)['eventId']}"
when Net::HTTPUnauthorized
raise '401 Unauthorized — check your NERVLY_API_KEY'
else
raise "Nervly returned #{response.code}: #{response.body}"
end
import Foundation
struct TriggerRequest: Codable {
let name: String
let to: Recipient
}
struct Recipient: Codable {
let subscriberId: String
let phone: String
}
let payload = TriggerRequest(
name: "order-confirmed",
to: Recipient(subscriberId: "sub_4920a", phone: "+2348012345678")
)
let body = try JSONEncoder().encode(payload)
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 }
if http.statusCode == 401 {
print("401 Unauthorized — check your NERVLY_API_KEY")
return
}
guard (200..<300).contains(http.statusCode) else {
print("Nervly returned \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
return
}
print("Accepted: \(String(data: data, encoding: .utf8) ?? "")")
}.resume()
semaphore.wait()
A missing, malformed, or unrecognised key returns 401 Unauthorized:
{
"error": "UNAUTHORIZED",
"message": "Missing or malformed Authorization Bearer header",
"status_code": 401
}
Live vs. Test API keys
Nervly issues two modes of API keys:
| Key Prefix | Mode | Delivery Behaviour | Cost |
|---|---|---|---|
nervly_sk_live_... | Live | Dispatches to real external providers (SMS, Email, Push, WhatsApp) | Real provider cost |
nervly_sk_test_... | Test | Short-circuits provider execution; emits synthetic nervly_test_{event_id} | Zero |
In Test Mode:
- Zero outbound network calls are made to external providers.
- Messages and timelines are still recorded normally in PostgreSQL and can be inspected in the dashboard.
- Safe for local integration tests, staging environments, and CI pipelines without running up provider bills or sending accidental OTPs to real users.
Key scopes
API keys carry read and/or write scopes:
readlets the key call the diagnostic and read endpoints, and the read-only MCP tools:gateway_status,idempotency_inspect,check_delivery_status,list_templatesandverify_subscriber_channel.writelets the key send —POST /v1/events/trigger,POST /v1/events/bulk, subscriber preference updates and subscriber deletion — and callsend_notificationover MCP.writeimpliesread. When you omit the choice, the key gets both.
A read-only key cannot send notifications. The gateway omits
send_notification from tools/list for such a key and rejects a direct call
to it. See Managing API keys for how to create each
kind.
Security and verification guarantees
- Constant-Time Comparison: The gateway resolves keys using constant-time comparison (
subtle::ConstantTimeEqHMAC-SHA256) to eliminate timing side-channel attacks. - One-Time Reveal: When created in the dashboard, the raw API key is displayed once in a modal. Only a cryptographic hash (
SHA-256) is stored in the database. - Sub-millisecond Key Lookups: Resolved keys are cached briefly to keep high-throughput ingest off the database, keyed by a non-reversible digest of the credential.
- Immediate Revocation: Revoking a key in the dashboard invalidates the cache entry at once. In-flight requests using the revoked key fail immediately with
401.
Webhook signatures
Your providers cannot present your API key, so
POST /v1/webhooks/{provider}
sits outside the bearer-token surface and authenticates each provider on its
own terms:
- Termii: HMAC-SHA256 over the raw request body, hex-encoded in
X-Termii-Signature. - Resend: Svix-signed — HMAC-SHA256 over
{svix-id}.{svix-timestamp}.{body}, base64 insvix-signature. - ZeptoMail: HMAC-SHA256 over the raw request body, base64 in
producer-signature. - Infobip: HMAC-SHA256 over the raw request body, base64 or hex in
X-Hub-Signature. - Twilio: Twilio's own request signature (HMAC-SHA1 over the request URL and its sorted form parameters).
Setup, encodings, replay windows, and copy-pasteable verifiers in ten languages are on Delivery receipts. Billing webhooks from Paystack are a separate surface — see Quotas and billing.
Health checks
GET /v1/health needs no authentication, by design — a load balancer should
not need a secret to decide whether to route traffic. It exposes the running
service version, environment, and broker connectivity:
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
}