Idempotency
A notification you send twice is worse than one you send late. Send an
Idempotency-Key and Nervly guarantees that a replay returns the original
response instead of sending again.
Without a key, every call sends
This is the default. If your HTTP client times out after the gateway has already published the event, your retry produces a second notification. There is no server-side deduplication on payload content.
With a key
Send the Idempotency-Key header with your unique business identifier. Each
example below generates an RFC 4122 version 4 UUID and attaches it to a
POST /v1/events/trigger:
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
IDEMPOTENCY_KEY="$(uuidgen | tr 'A-Z' 'a-z')" # RFC 4122 version 4
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: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "payment-received", "to": { "subscriberId": "user_8f21c", "phone": "+2348012345678" } }'
import { randomUUID } from 'node:crypto';
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
// RFC 4122 version 4 UUID — generate it once and reuse it on every retry.
const idempotencyKey = randomUUID();
await nervly.events.trigger(
{
name: 'payment-received',
to: { subscriberId: 'user_8f21c', phone: '+2348012345678' },
},
{ idempotencyKey },
);
import os
import uuid
import requests
idempotency_key = str(uuid.uuid4()) # RFC 4122 version 4
response = requests.post(
"https://api.nervly.io/v1/events/trigger",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"Idempotency-Key": idempotency_key,
},
json={
"name": "payment-received",
"to": {"subscriberId": "user_8f21c", "phone": "+2348012345678"},
},
timeout=10,
)
response.raise_for_status()
print(f"Key {idempotency_key} -> {response.status_code}")
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// newUUIDv4 returns an RFC 4122 version 4 UUID using crypto/rand.
func newUUIDv4() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
func main() {
idempotencyKey := newUUIDv4()
payload := map[string]any{
"name": "payment-received",
"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("Idempotency-Key", idempotencyKey)
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))
}
fmt.Printf("Key %s -> %d\n", idempotencyKey, resp.StatusCode)
}
<?php
// RFC 4122 version 4 UUID from a cryptographically secure source.
function uuidv4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); // version 4
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); // variant 10
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
);
}
$idempotencyKey = uuidv4();
$payload = [
'name' => 'payment-received',
'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'),
'Idempotency-Key: ' . $idempotencyKey,
'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 >= 300) {
throw new RuntimeException("Nervly returned {$status}: {$response}");
}
echo "Key {$idempotencyKey} -> {$status}\n";
use reqwest::Client;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let idempotency_key = Uuid::new_v4().to_string(); // RFC 4122 version 4
let payload = json!({
"name": "payment-received",
"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")
.bearer_auth(env::var("NERVLY_API_KEY")?)
.header("Idempotency-Key", &idempotency_key)
.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!("Key {idempotency_key} -> {status}");
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.UUID;
public class IdempotentTrigger {
public static void main(String[] args) throws Exception {
String idempotencyKey = UUID.randomUUID().toString(); // RFC 4122 version 4
String payload = """
{ "name": "payment-received", "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("Idempotency-Key", idempotencyKey)
.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("Key " + idempotencyKey + " -> " + response.statusCode());
}
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
var idempotencyKey = Guid.NewGuid().ToString(); // RFC 4122 version 4
var payload = new
{
name = "payment-received",
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("Idempotency-Key", idempotencyKey);
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}");
}
Console.WriteLine($"Key {idempotencyKey} -> {(int)response.StatusCode}");
}
}
require 'json'
require 'net/http'
require 'securerandom'
require 'uri'
idempotency_key = SecureRandom.uuid # RFC 4122 version 4
uri = URI('https://api.nervly.io/v1/events/trigger')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['Idempotency-Key'] = idempotency_key
request['Content-Type'] = 'application/json'
request.body = {
name: 'payment-received',
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
puts "Key #{idempotency_key} -> #{response.code}"
import Foundation
struct Recipient: Codable {
let subscriberId: String
let phone: String
}
struct TriggerRequest: Codable {
let name: String
let to: Recipient
}
let idempotencyKey = UUID().uuidString.lowercased() // RFC 4122 version 4
let payload = TriggerRequest(
name: "payment-received",
to: Recipient(subscriberId: "user_8f21c", 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(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
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
}
print("Key \(idempotencyKey) -> \(http.statusCode)")
}.resume()
semaphore.wait()
| Call | Status | Body |
|---|---|---|
| First | 202 Accepted | fresh eventId, status: "QUEUED" |
| Replay with same key | 200 OK | the original body, byte for byte |
The status code is how you tell them apart. The replayed body still says
"status": "QUEUED" and still carries the original eventId and
timestamp — that is the point. Your code sees one event, not two.
Choosing a key
Derive the key from your own domain, so it is the same across every retry of the same logical action:
// Good — stable across retries, unique per real event
const key = `invoice-${invoice.id}-paid`;
const key = `order-${order.id}-shipped-attempt-${order.shipmentCount}`;
// Useless — a new key every attempt defeats the whole mechanism
const key = crypto.randomUUID();
If you cannot derive one, generate the UUID once, store it with the job, and reuse it on every retry of that job.
The retention window
Keys are held in Redis with a TTL set by the gateway's
IDEMPOTENCY_TTL_SECONDS, 24 hours by default.
info/api_schema_contract.md documents a 48-hour window. The gateway's
compiled default is 24 hours (IDEMPOTENCY_TTL_SECONDS=86400). The running
configuration wins — confirm the deployed value before relying on a window
longer than 24 hours.
After the TTL expires, the key is forgotten and a request using it is treated as new. Size your retry windows inside the TTL.
What happens when Redis is unavailable
Idempotency degrades open, not closed. A Redis failure during the lookup is indistinguishable from a cache miss: the request proceeds and the notification is sent. A Redis failure during the write is swallowed, so the key is not recorded and a later replay will send again.
This is a deliberate trade-off — a Redis outage degrades duplicate protection rather than taking ingestion down. It also means idempotency is not a substitute for your own deduplication when duplicates are genuinely unacceptable. For money-movement notifications, keep a sent-flag in your own database.
Conflicts
The error contract defines 409 IDEMPOTENCY_CONFLICT for a key reused with a
different payload. The gateway does not currently compare payloads — a reused
key returns the cached response regardless of what body you send. So reusing a
key for a genuinely different notification silently drops the second one
rather than erroring.
Keep keys unique per logical notification and this cannot bite you.
Next
- Rate limits — the other reason a retry can fail
- Errors — what is safe to retry
- Trigger a notification