Errors
The shape
Every non-2xx response has the same three fields:
{
"error": "RATE_LIMIT_EXCEEDED",
"message": "Client ingestion rate limit exceeded",
"status_code": 429
}
Branch on error. It is a stable machine-readable code. message is prose
for your logs and may change without notice. status_code repeats the HTTP
status for clients that only surface the body.
info/api_schema_contract.md describes a nested envelope —
{ status: "error", error: { code, message, details, request_id } }. The
gateway does not implement that. The flat three-field shape above is what is
actually returned, and this page is generated against the same source as the
API reference. Build against this.
In particular there is no request_id on error responses, and no
details map for field-level validation errors.
The codes
| Status | error | Cause | Retry? |
|---|---|---|---|
400 | BAD_REQUEST | Body failed validation — see the cases below | No. Fix the request. |
401 | UNAUTHORIZED | Missing, malformed or unrecognised API key | No. Fix the key. |
401 | INVALID_SIGNATURE | A provider webhook signature did not verify | n/a — providers, not you |
403 | FORBIDDEN | Workspace is suspended or closed | No. Resolve the account state. |
404 | NOT_FOUND | The requested resource does not exist | No. Fix the path. |
409 | IDEMPOTENCY_CONFLICT | Defined for a key reused with a different payload | No. See note below. |
429 | RATE_LIMIT_EXCEEDED | Per-workspace or per-subscriber limit exceeded | Yes, with backoff |
500 | INTERNAL_SERVER_ERROR | Protobuf encoding, Redis or broker failure | Yes, with the same idempotency key |
503 | SERVICE_UNAVAILABLE | An internal dependency is unreachable, or the route is temporarily disabled | Yes, after the Retry-After |
What produces a 400
| Endpoint | Cause |
|---|---|
POST /v1/events/trigger | The recipient has no contact details for any channel — supply at least one of to.email, to.phone or to.deviceTokens |
POST /v1/events/bulk | An empty events array, or more than 5,000 entries |
PUT /v1/users/{id}/preferences | Neither channels nor categories was supplied |
A batch whose individual events fail validation does not return 400.
The batch returns 200 with those events marked FAILED in the events
array — see Bulk sending.
What is safe to retry
429 — always safe. The rate limiter runs before the idempotency check, so
nothing was enqueued and your Idempotency-Key is still unused. Back off with
jitter and wait at least the number of seconds in the Retry-After header the
gateway sends on the 429 (it is 1 today). See Rate
limits.
500 — safe with an idempotency key. A 500 means the gateway failed
after authenticating you, possibly after publishing to the broker. Without a
key, a retry may duplicate the notification. With one, it cannot. See
Idempotency.
503 — safe with an idempotency key. A 503 means an internal dependency
was unreachable or the route was temporarily disabled. It carries the same
Retry-After: 1 as a 429; wait that long, then retry under the same key.
400, 401, 403 and 404 — never. These are deterministic. Retrying
produces the same result and burns your rate-limit budget.
Network timeout with no response — treat as 500. You cannot tell whether
the event was published. Retry with the same key.
Handling errors in code
The examples below deserialize the flat ErrorResponse into a typed value and
branch on error — never on the prose in message. RATE_LIMIT_EXCEEDED,
INTERNAL_SERVER_ERROR and SERVICE_UNAVAILABLE are retryable; everything else
is terminal.
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
# -sS keeps output quiet; -o /dev/null discards the body; -w prints the status.
status=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST https://api.nervly.io/v1/events/trigger \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order-confirmed",
"to": { "subscriberId": "sub_4920a", "email": "ada@example.com" },
"payload": { "orderId": "ORD-94102" }
}')
case "$status" in
200|202) echo "accepted" ;;
429) echo "rate limited; wait for Retry-After" ;;
500|503) echo "gateway failure; retry with the same Idempotency-Key" ;;
400|401|403|404) echo "deterministic failure $status; do not retry" ;;
*) echo "unexpected failure $status; do not retry" ;;
esac
interface ErrorResponse {
error: string;
message: string;
status_code: number;
}
type Disposition =
| { kind: 'retry'; afterSeconds: number }
| { kind: 'fix'; reason: string }
| { kind: 'fatal'; reason: string };
function parseErrorResponse(raw: string): ErrorResponse {
const value: unknown = JSON.parse(raw);
const candidate = value as Partial<ErrorResponse> | null;
if (
candidate === null ||
typeof candidate.error !== 'string' ||
typeof candidate.message !== 'string' ||
typeof candidate.status_code !== 'number'
) {
throw new Error('not a Nervly ErrorResponse');
}
return candidate as ErrorResponse;
}
function classify(err: ErrorResponse, retryAfterSeconds = 1): Disposition {
switch (err.error) {
case 'RATE_LIMIT_EXCEEDED':
return { kind: 'retry', afterSeconds: retryAfterSeconds };
case 'INTERNAL_SERVER_ERROR':
case 'SERVICE_UNAVAILABLE':
return { kind: 'retry', afterSeconds: 1 };
case 'BAD_REQUEST':
case 'FORBIDDEN':
case 'NOT_FOUND':
return { kind: 'fix', reason: err.message };
case 'UNAUTHORIZED':
case 'INVALID_SIGNATURE':
return { kind: 'fix', reason: 'check the API key or webhook signature' };
case 'IDEMPOTENCY_CONFLICT':
return { kind: 'fatal', reason: 'key reused with a different payload' };
default:
return { kind: 'fatal', reason: `unexpected ${err.error} (${err.status_code})` };
}
}
import json
from dataclasses import dataclass
from enum import Enum
@dataclass(frozen=True)
class ErrorResponse:
error: str
message: str
status_code: int
@classmethod
def from_json(cls, raw: str) -> "ErrorResponse":
value = json.loads(raw)
return cls(
error=value["error"],
message=value["message"],
status_code=value["status_code"],
)
class Disposition(Enum):
RETRY = "retry"
FIX = "fix"
FATAL = "fatal"
def classify(err: ErrorResponse, retry_after: float = 1.0) -> tuple[Disposition, float]:
if err.error == "RATE_LIMIT_EXCEEDED":
return (Disposition.RETRY, retry_after)
if err.error in {"INTERNAL_SERVER_ERROR", "SERVICE_UNAVAILABLE"}:
return (Disposition.RETRY, 1.0)
if err.error in {"BAD_REQUEST", "FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED", "INVALID_SIGNATURE"}:
return (Disposition.FIX, 0.0)
if err.error == "IDEMPOTENCY_CONFLICT":
return (Disposition.FATAL, 0.0)
return (Disposition.FATAL, 0.0)
err = ErrorResponse.from_json(
'{"error":"RATE_LIMIT_EXCEEDED","message":"slow down","status_code":429}'
)
disposition, delay = classify(err)
print(f"{disposition.value} after {delay}s (HTTP {err.status_code})")
package main
import (
"encoding/json"
"fmt"
)
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
StatusCode int `json:"status_code"`
}
type Disposition int
const (
Retry Disposition = iota
Fix
Fatal
)
func classify(err ErrorResponse, retryAfterSeconds float64) (Disposition, float64) {
switch err.Error {
case "RATE_LIMIT_EXCEEDED":
return Retry, retryAfterSeconds
case "INTERNAL_SERVER_ERROR", "SERVICE_UNAVAILABLE":
return Retry, 1
case "BAD_REQUEST", "FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED", "INVALID_SIGNATURE":
return Fix, 0
case "IDEMPOTENCY_CONFLICT":
return Fatal, 0
default:
return Fatal, 0
}
}
func main() {
raw := `{"error":"RATE_LIMIT_EXCEEDED","message":"slow down","status_code":429}`
var err ErrorResponse
if decodeErr := json.Unmarshal([]byte(raw), &err); decodeErr != nil {
panic(decodeErr)
}
disposition, delay := classify(err, 1)
fmt.Printf("%v after %.1fs (HTTP %d)\n", disposition, delay, err.StatusCode)
}
<?php
final class ErrorResponse
{
public function __construct(
public readonly string $error,
public readonly string $message,
public readonly int $statusCode,
) {
}
public static function fromJson(string $raw): self
{
$value = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
return new self(
error: $value['error'],
message: $value['message'],
statusCode: $value['status_code'],
);
}
}
function classify(ErrorResponse $err, float $retryAfterSeconds = 1.0): array
{
return match ($err->error) {
'RATE_LIMIT_EXCEEDED' => ['retry', $retryAfterSeconds],
'INTERNAL_SERVER_ERROR', 'SERVICE_UNAVAILABLE' => ['retry', 1.0],
'BAD_REQUEST', 'FORBIDDEN', 'NOT_FOUND', 'UNAUTHORIZED', 'INVALID_SIGNATURE' => ['fix', 0.0],
'IDEMPOTENCY_CONFLICT' => ['fatal', 0.0],
default => ['fatal', 0.0],
};
}
$err = ErrorResponse::fromJson(
'{"error":"RATE_LIMIT_EXCEEDED","message":"slow down","status_code":429}'
);
[$disposition, $delay] = classify($err);
echo $disposition . ' after ' . $delay . 's (HTTP ' . $err->statusCode . ')' . PHP_EOL;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
pub status_code: u16,
}
#[derive(Debug, PartialEq)]
pub enum Disposition {
Retry,
Fix,
Fatal,
}
pub fn classify(err: &ErrorResponse, retry_after_seconds: f64) -> (Disposition, f64) {
match err.error.as_str() {
"RATE_LIMIT_EXCEEDED" => (Disposition::Retry, retry_after_seconds),
"INTERNAL_SERVER_ERROR" | "SERVICE_UNAVAILABLE" => (Disposition::Retry, 1.0),
"BAD_REQUEST" | "FORBIDDEN" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_SIGNATURE" => (Disposition::Fix, 0.0),
"IDEMPOTENCY_CONFLICT" => (Disposition::Fatal, 0.0),
_ => (Disposition::Fatal, 0.0),
}
}
fn main() -> Result<(), serde_json::Error> {
let raw = r#"{"error":"RATE_LIMIT_EXCEEDED","message":"slow down","status_code":429}"#;
let err: ErrorResponse = serde_json::from_str(raw)?;
let (disposition, delay) = classify(&err, 1.0);
println!("{disposition:?} after {delay:.1}s (HTTP {})", err.status_code);
Ok(())
}
import java.util.regex.Matcher;
import java.util.regex.Pattern;
record ErrorResponse(String error, String message, int statusCode) {
private static final Pattern FIELD = Pattern.compile("\"(error|message)\"\\s*:\\s*\"([^\"]*)\"");
private static final Pattern STATUS = Pattern.compile("\"status_code\"\\s*:\\s*(\\d+)");
static ErrorResponse fromJson(String raw) {
String error = null;
String message = null;
Matcher fields = FIELD.matcher(raw);
while (fields.find()) {
if (fields.group(1).equals("error")) {
error = fields.group(2);
} else {
message = fields.group(2);
}
}
Matcher status = STATUS.matcher(raw);
if (error == null || message == null || !status.find()) {
throw new IllegalArgumentException("not a Nervly ErrorResponse");
}
return new ErrorResponse(error, message, Integer.parseInt(status.group(1)));
}
}
public class ErrorHandling {
enum Disposition { RETRY, FIX, FATAL }
static Disposition classify(ErrorResponse err) {
return switch (err.error()) {
case "RATE_LIMIT_EXCEEDED", "INTERNAL_SERVER_ERROR", "SERVICE_UNAVAILABLE" -> Disposition.RETRY;
case "BAD_REQUEST", "FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED", "INVALID_SIGNATURE" -> Disposition.FIX;
case "IDEMPOTENCY_CONFLICT" -> Disposition.FATAL;
default -> Disposition.FATAL;
};
}
public static void main(String[] args) {
// In production, decode with a JSON library such as Jackson; this stays
// dependency-free so the example compiles with a bare JDK.
ErrorResponse err = ErrorResponse.fromJson(
"{\"error\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"slow down\",\"status_code\":429}");
System.out.println(classify(err) + " on HTTP " + err.statusCode());
}
}
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
public record ErrorResponse(
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("status_code")] int StatusCode);
public enum Disposition { Retry, Fix, Fatal }
public static class ErrorHandling
{
public static Disposition Classify(ErrorResponse err) => err.Error switch
{
"RATE_LIMIT_EXCEEDED" or "INTERNAL_SERVER_ERROR" or "SERVICE_UNAVAILABLE" => Disposition.Retry,
"BAD_REQUEST" or "FORBIDDEN" or "NOT_FOUND" or "UNAUTHORIZED" or "INVALID_SIGNATURE" => Disposition.Fix,
"IDEMPOTENCY_CONFLICT" => Disposition.Fatal,
_ => Disposition.Fatal,
};
public static void Main()
{
var raw = "{\"error\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"slow down\",\"status_code\":429}";
var err = JsonSerializer.Deserialize<ErrorResponse>(raw)!;
Console.WriteLine($"{Classify(err)} on HTTP {err.StatusCode}");
}
}
require 'json'
ErrorResponse = Data.define(:error, :message, :status_code) do
def self.from_json(raw)
value = JSON.parse(raw)
new(
error: value.fetch('error'),
message: value.fetch('message'),
status_code: value.fetch('status_code')
)
end
end
def classify(err, retry_after = 1.0)
case err.error
when 'RATE_LIMIT_EXCEEDED'
[:retry, retry_after]
when 'INTERNAL_SERVER_ERROR', 'SERVICE_UNAVAILABLE'
[:retry, 1.0]
when 'BAD_REQUEST', 'FORBIDDEN', 'NOT_FOUND', 'UNAUTHORIZED', 'INVALID_SIGNATURE'
[:fix, 0.0]
when 'IDEMPOTENCY_CONFLICT'
[:fatal, 0.0]
else
[:fatal, 0.0]
end
end
err = ErrorResponse.from_json(
'{"error":"RATE_LIMIT_EXCEEDED","message":"slow down","status_code":429}'
)
disposition, delay = classify(err)
puts "#{disposition} after #{delay}s (HTTP #{err.status_code})"
import Foundation
struct ErrorResponse: Decodable {
let error: String
let message: String
let statusCode: Int
enum CodingKeys: String, CodingKey {
case error
case message
case statusCode = "status_code"
}
}
enum Disposition: String {
case retry
case fix
case fatal
}
func classify(_ err: ErrorResponse, retryAfter: Double = 1.0) -> (Disposition, Double) {
switch err.error {
case "RATE_LIMIT_EXCEEDED":
return (.retry, retryAfter)
case "INTERNAL_SERVER_ERROR", "SERVICE_UNAVAILABLE":
return (.retry, 1.0)
case "BAD_REQUEST", "FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED", "INVALID_SIGNATURE":
return (.fix, 0.0)
case "IDEMPOTENCY_CONFLICT":
return (.fatal, 0.0)
default:
return (.fatal, 0.0)
}
}
let raw = #"{"error":"RATE_LIMIT_EXCEEDED","message":"slow down","status_code":429}"#
let err = try JSONDecoder().decode(ErrorResponse.self, from: Data(raw.utf8))
let (disposition, delay) = classify(err)
print("\(disposition.rawValue) after \(delay)s (HTTP \(err.statusCode))")
The typed readers reject a body that is not the flat three-field shape, so an
HTML error page or a proxy's own JSON never deserializes into a gateway error.
TypeScript validates each field's type, the Java parser throws on a missing
field, and the Python, PHP, Ruby, Rust and Swift deserializers fail on a missing
key. Go's json.Unmarshal and C#'s JsonSerializer are the exception — they
tolerate missing fields, so valid JSON with the wrong shape becomes an empty
error with status_code 0, while a body that is not JSON at all makes both
throw (the Go snippet surfaces that as a panic). Both classifiers map a missing
or unknown error to a terminal fatal, so a mis-shaped body is never retried.
The switch/match arms cover every code the
gateway emits today; anything else falls through to a terminal fatal.
Codes you will not see
409 IDEMPOTENCY_CONFLICT is part of the contract but the gateway does not
currently compare payloads — a reused key returns the cached response instead
of conflicting. Handle the code for forward compatibility, but do not rely on
it to catch key-reuse bugs. See
Idempotency.
Errors you will not get
Some failures are silent by design:
- An unrecognised
X-Priority-Overridevalue becomesNORMALwith no error. Check thepriorityfield in the response. - An event with no usable contact details is accepted with
202and then dropped by the worker. There is no synchronous validation that a channel is reachable, and no error response. See Channels & providers. - A
payloadwhose variables are never rendered — see the templating note in Channels & providers.
The pattern: the gateway validates shape, not deliverability. Deliverability shows up in delivery receipts.