Skip to main content

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.

If you read the internal schema contract

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​

StatuserrorCauseRetry?
400BAD_REQUESTBody failed validation — see the cases belowNo. Fix the request.
401UNAUTHORIZEDMissing, malformed or unrecognised API keyNo. Fix the key.
401INVALID_SIGNATUREA provider webhook signature did not verifyn/a — providers, not you
403FORBIDDENWorkspace is suspended or closedNo. Resolve the account state.
404NOT_FOUNDThe requested resource does not existNo. Fix the path.
409IDEMPOTENCY_CONFLICTDefined for a key reused with a different payloadNo. See note below.
429RATE_LIMIT_EXCEEDEDPer-workspace or per-subscriber limit exceededYes, with backoff
500INTERNAL_SERVER_ERRORProtobuf encoding, Redis or broker failureYes, with the same idempotency key
503SERVICE_UNAVAILABLEAn internal dependency is unreachable, or the route is temporarily disabledYes, after the Retry-After

What produces a 400​

EndpointCause
POST /v1/events/triggerThe recipient has no contact details for any channel — supply at least one of to.email, to.phone or to.deviceTokens
POST /v1/events/bulkAn empty events array, or more than 5,000 entries
PUT /v1/users/{id}/preferencesNeither 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.

# -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

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-Override value becomes NORMAL with no error. Check the priority field in the response.
  • An event with no usable contact details is accepted with 202 and then dropped by the worker. There is no synchronous validation that a channel is reachable, and no error response. See Channels & providers.
  • A payload whose 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.

Next​