Error Handling
Understanding and handling API errors in Notiflows
Error Handling
All Notiflows API errors follow a consistent JSON structure, making it easy to handle errors programmatically across both the Admin API and User API.
Error Response Format
Every non-2xx response returns a JSON object with the following structure:
{
"error": {
"type": "https://docs.notiflows.com/errors/not_found",
"code": "not_found",
"message": "User not found",
"status": 404,
"request_id": "req_abc123xyz",
"details": {
"resource": "user",
"id": "user_123"
}
}
}Fields
| Field | Type | Description |
|---|---|---|
type | string | URL to documentation for this error type |
code | string | Stable, machine-readable error code (see Error Codes) |
message | string | Human-readable error message |
status | integer | HTTP status code |
request_id | string | null | Correlation ID for debugging (from x-request-id header) |
details | object | Additional context specific to the error type |
The code field is stable and safe to use for programmatic error handling. The message field may change and should only be used for display purposes.
Error Codes
Notiflows uses a small, stable set of top-level error codes. Business-specific context is provided in the details object.
bad_request (400)
Invalid JSON syntax or malformed request parameters.
{
"error": {
"type": "https://docs.notiflows.com/errors/bad_request",
"code": "bad_request",
"message": "Invalid UUID format",
"status": 400,
"request_id": "req_abc123",
"details": {
"reason": "invalid_uuid",
"param": "channel_id"
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
reason | string | Machine-readable reason: invalid_json, invalid_param, invalid_uuid |
param | string | Parameter that caused the error (optional) |
unauthenticated (401)
Missing or invalid authentication credentials.
{
"error": {
"type": "https://docs.notiflows.com/errors/unauthenticated",
"code": "unauthenticated",
"message": "Authentication required",
"status": 401,
"request_id": "req_abc123",
"details": {
"auth": "api_key",
"reason": "missing_api_key"
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
auth | string | Authentication method: api_key, secret_key, user_key_jwt |
reason | string | Specific reason (optional) |
Common reasons:
| Reason | Description |
|---|---|
missing_api_key | x-notiflows-api-key header not provided |
invalid_api_key | API key doesn't match any project |
missing_secret_key | x-notiflows-secret-key header not provided (Admin API) |
invalid_secret_key | Secret key doesn't match the project |
missing_user_key | x-notiflows-user-key header not provided (User API with Security Mode) |
invalid_signature | JWT signature verification failed |
signing_key_not_configured | Security Mode enabled but no signing key generated |
user_not_found | User from JWT doesn't exist in the project |
forbidden (403)
Authenticated but not authorized to access the resource.
{
"error": {
"type": "https://docs.notiflows.com/errors/forbidden",
"code": "forbidden",
"message": "Access denied",
"status": 403,
"request_id": "req_abc123",
"details": {
"resource": "channel",
"id": "ch_abc123",
"required_scope": "channel:write"
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
resource | string | Resource type being accessed |
id | string | Resource ID (optional) |
required_scope | string | Permission scope required (optional) |
The User API uses not_found instead of forbidden when revealing resource existence would leak cross-tenant information. This is a security measure to prevent enumeration attacks.
not_found (404)
The requested resource does not exist.
{
"error": {
"type": "https://docs.notiflows.com/errors/not_found",
"code": "not_found",
"message": "User not found",
"status": 404,
"request_id": "req_abc123",
"details": {
"resource": "user",
"id": "user_123"
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
resource | string | Resource type: user, channel, notiflow, notification, delivery, topic, subscription, feed_entry |
id | string | Resource ID (optional) |
conflict (409)
The request conflicts with the current state of the resource. This includes invalid state transitions, business rule violations, and duplicate resources.
{
"error": {
"type": "https://docs.notiflows.com/errors/conflict",
"code": "conflict",
"message": "Notiflow must be active to run",
"status": 409,
"request_id": "req_abc123",
"details": {
"resource": "notiflow",
"id": "nf_abc123",
"reason": "notiflow_not_active",
"current_status": "draft",
"allowed_statuses": ["active"]
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
resource | string | Resource type |
id | string | Resource ID (optional) |
reason | string | Machine-readable reason code (required) |
current_status | string | Current resource status (optional) |
allowed_statuses | array | List of valid statuses (optional) |
allowed_transitions | array | List of valid state transitions (optional) |
Common conflict reasons:
| Reason | Description |
|---|---|
notiflow_not_active | Attempted to run a notiflow that isn't active |
invalid_transition | Attempted an invalid state change (e.g., setting read to false) |
subscription_already_exists | User is already subscribed to the topic |
provider_not_configured | Channel provider (e.g., Slack) not set up |
provider_revoked | Provider access has been revoked |
idempotency_key_reused (409)
The same idempotency key was used with a different request payload.
{
"error": {
"type": "https://docs.notiflows.com/errors/idempotency_key_reused",
"code": "idempotency_key_reused",
"message": "Idempotency key already used with different payload",
"status": 409,
"request_id": "req_abc123",
"details": {
"idempotency_key": "user_action_123",
"resource": "notiflow_run"
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
idempotency_key | string | The reused idempotency key |
resource | string | Resource type the key was used with |
Idempotency keys are supported on action endpoints like POST /notiflows/:id/run. Using the same key with the same payload is safe and returns the original response.
validation_failed (422)
Request validation failed. This includes Ecto changeset errors and custom validation rules.
{
"error": {
"type": "https://docs.notiflows.com/errors/validation_failed",
"code": "validation_failed",
"message": "Validation failed",
"status": 422,
"request_id": "req_abc123",
"details": {
"fields": [
{
"field": "email",
"reason": "format",
"message": "must be a valid email address"
},
{
"field": "recipients.0.external_id",
"reason": "required",
"message": "can't be blank"
}
]
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
fields | array | List of field-level errors |
Field error structure:
| Field | Type | Description |
|---|---|---|
field | string | Dot-path to the field (e.g., recipients.0.external_id) |
reason | string | Machine-readable validation reason |
message | string | Human-readable error message |
Common validation reasons:
| Reason | Description |
|---|---|
required | Field is required but missing |
format | Field format is invalid |
inclusion | Value not in allowed list |
exclusion | Value in excluded list |
length | String length constraint violated |
number | Numeric constraint violated |
unique | Uniqueness constraint violated |
rate_limited (429)
Too many requests. The response includes a Retry-After header.
{
"error": {
"type": "https://docs.notiflows.com/errors/rate_limited",
"code": "rate_limited",
"message": "Rate limit exceeded",
"status": 429,
"request_id": "req_abc123",
"details": {
"retry_after": 60,
"limit": 100,
"window": "1m"
}
}
}Details fields:
| Field | Type | Description |
|---|---|---|
retry_after | integer | Seconds to wait before retrying |
limit | integer | Request limit (optional) |
window | string | Time window for the limit (optional) |
Response headers:
| Header | Description |
|---|---|
Retry-After | Seconds to wait before retrying |
internal_error (500)
An unexpected server error occurred. These errors are logged and monitored.
{
"error": {
"type": "https://docs.notiflows.com/errors/internal_error",
"code": "internal_error",
"message": "Internal server error",
"status": 500,
"request_id": "req_abc123",
"details": {}
}
}Internal errors never expose stack traces or internal details. If you encounter persistent 500 errors, contact support with the request_id for investigation.
service_unavailable (503)
A dependent service is temporarily unavailable.
{
"error": {
"type": "https://docs.notiflows.com/errors/service_unavailable",
"code": "service_unavailable",
"message": "Service temporarily unavailable",
"status": 503,
"request_id": "req_abc123",
"details": {}
}
}Handling Errors in Code
JavaScript/TypeScript
The Notiflows client SDK provides typed error classes for each error code:
import {
NotiflowsClient,
NotFoundError,
ValidationFailedError,
ConflictError,
UnauthenticatedError,
isApiError
} from '@notiflows/client';
const client = new NotiflowsClient({ apiKey: 'pk_...', userToken: '...' });
try {
await client.feed.markAsRead('entry_123');
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`Entry not found: ${error.resource} ${error.resourceId}`);
} else if (error instanceof ValidationFailedError) {
// Access field-level errors
for (const field of error.fields) {
console.log(`${field.field}: ${field.message} (${field.reason})`);
}
} else if (error instanceof ConflictError) {
// Check specific conflict reasons
if (error.reason === 'invalid_transition') {
console.log('Cannot undo this action');
}
} else if (error instanceof UnauthenticatedError) {
// Redirect to login or refresh token
if (error.reason === 'token_expired') {
await refreshToken();
}
} else if (isApiError(error)) {
// Handle any other API error
console.log(`API error: ${error.code} - ${error.message}`);
} else {
// Network error or other issue
throw error;
}
}Using Type Guards
import { isApiError, isConflictWithReason, isNotFoundResource } from '@notiflows/client';
try {
await client.notiflows.run('nf_123', { recipients: [...] });
} catch (error) {
if (isConflictWithReason(error, 'notiflow_not_active')) {
// Handle inactive notiflow specifically
console.log('Please activate the notiflow first');
} else if (isNotFoundResource(error, 'notiflow')) {
// Handle missing notiflow
console.log('Notiflow does not exist');
}
}React Hook Error Handling
import { useFeed, NotFoundError, UnauthenticatedError } from '@notiflows/react';
function NotificationFeed() {
const { entries, error, isLoading, retry } = useFeed();
if (error) {
if (error instanceof UnauthenticatedError) {
return <LoginPrompt />;
}
return (
<ErrorMessage
message={error.message}
onRetry={retry}
/>
);
}
if (isLoading) {
return <Loading />;
}
return <NotificationList entries={entries} />;
}HTTP Status Code Summary
| Status | Error Code | When Used |
|---|---|---|
| 400 | bad_request | Invalid JSON, malformed parameters, invalid UUIDs |
| 401 | unauthenticated | Missing or invalid authentication |
| 403 | forbidden | Authenticated but not authorized |
| 404 | not_found | Resource doesn't exist |
| 409 | conflict | State conflict, business rule violation |
| 409 | idempotency_key_reused | Idempotency key reused with different payload |
| 422 | validation_failed | Request validation errors |
| 429 | rate_limited | Too many requests |
| 500 | internal_error | Unexpected server error |
| 503 | service_unavailable | Dependent service down |
Best Practices
Use Error Codes, Not Messages
Always match on the code field, never the message:
// ✅ Good - stable error code
if (error.code === 'not_found') { ... }
// ❌ Bad - message may change
if (error.message.includes('not found')) { ... }Use Conflict Reasons for Business Logic
For conflict errors, use the reason field to determine specific handling:
// ✅ Good - specific reason handling
if (error.code === 'conflict' && error.details.reason === 'notiflow_not_active') {
showActivateNotiflowPrompt();
}
// ❌ Bad - fragile message matching
if (error.message.includes('must be active')) { ... }Include Request ID in Support Tickets
When reporting issues, always include the request_id from the error response. This helps us quickly locate the relevant logs.
Implement Exponential Backoff for Rate Limits
When receiving a rate_limited error, respect the Retry-After header:
async function withRetry(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error instanceof RateLimitedError) {
await sleep(error.retryAfter * 1000);
continue;
}
throw error;
}
}
}Troubleshooting
Getting Empty or Malformed Error Responses
If you receive an error response that doesn't match the expected format:
- Check that you're sending
Accept: application/jsonheader - Verify the API endpoint URL is correct
- Check for network proxies that might modify responses
Unexpected 404 Errors
In the User API, 404 may be returned instead of 403 for security:
- The resource might exist but belong to a different tenant
- Verify you're using the correct API key and user token
- Ensure the user has been created via the Admin API first
Persistent 500 Errors
If you encounter repeated internal errors:
- Note the
request_idfrom each response - Check the status page for known issues
- Contact support with the request IDs