---
title: Error Handling
description: 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:

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#not_found-404",
    "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](#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 |

<Callout type="info">
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.
</Callout>

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

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#bad_request-400",
    "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.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#unauthenticated-401",
    "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.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#forbidden-403",
    "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) |

<Callout type="info">
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.
</Callout>

---

### not_found (404)

The requested resource does not exist.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#not_found-404",
    "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.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#conflict-409",
    "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.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#idempotency_key_reused-409",
    "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 |

<Callout type="info">
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.
</Callout>

---

### validation_failed (422)

Request validation failed. This includes schema validation errors and custom validation rules.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#validation_failed-422",
    "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.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#rate_limited-429",
    "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.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#internal_error-500",
    "code": "internal_error",
    "message": "Internal server error",
    "status": 500,
    "request_id": "req_abc123",
    "details": {}
  }
}
```

<Callout type="warn">
Internal errors never expose stack traces or internal details. If you encounter persistent 500 errors, contact support with the `request_id` for investigation.
</Callout>

---

### service_unavailable (503)

A dependent service is temporarily unavailable.

```json
{
  "error": {
    "type": "https://notiflows.com/docs/api/errors#service_unavailable-503",
    "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:

```typescript
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

```typescript
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

```tsx
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`:

```typescript
// ✅ 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:

```typescript
// ✅ 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:

```typescript
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:

1. Check that you're sending `Accept: application/json` header
2. Verify the API endpoint URL is correct
3. 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:

1. Note the `request_id` from each response
2. Check the [status page](https://status.notiflows.com) for known issues
3. Contact support with the request IDs
