> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> The CRM+ error response shape, all standard error codes, and how to write defensive client code that survives the API's current inconsistencies.

All CRM+ API errors return JSON with a consistent structure — most of the time. HTTP status codes indicate the general category of failure; the response body provides machine-readable codes and human-readable descriptions. A small number of endpoints still return plain-text error bodies in legacy formats, so robust integrations always inspect `response.status` first before parsing the body.

This page covers the canonical error shape, every standard HTTP status code, and a worked example of defensive client code.

## Target error shape

The canonical error response the CRM+ API is moving toward is:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request body contains invalid values.",
    "details": [
      {
        "field": "giftAmount",
        "code": "MUST_BE_POSITIVE",
        "message": "Gift amount must be greater than zero."
      },
      {
        "field": "giftDate",
        "code": "REQUIRED",
        "message": "Gift date is required."
      }
    ]
  }
}
```

| Field                     | Type     | Description                                                                                        |
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `error.code`              | `string` | Machine-readable error code in `SCREAMING_SNAKE_CASE`. Safe to use in `switch` statements.         |
| `error.message`           | `string` | Human-readable explanation, safe to log. Never contains stack traces or database error text.       |
| `error.details`           | `array`  | Field-level validation errors. Present as an empty array `[]` when no field-specific errors apply. |
| `error.details[].field`   | `string` | The camelCase field name that failed validation.                                                   |
| `error.details[].code`    | `string` | Machine-readable `SCREAMING_SNAKE_CASE` code for this specific field failure.                      |
| `error.details[].message` | `string` | Human-readable description of this specific validation failure.                                    |

<Note>
  The CRM+ API is in the process of migrating to this canonical shape. Some endpoints currently return plain-text error messages or non-standard JSON structures — particularly for `401 Unauthorized` responses, which may return `Authorization has been denied for this request.` as plain text. Write your error handling to inspect `response.status` first, then attempt to parse the body. Do not assume the body is always valid JSON or always matches the canonical shape.
</Note>

<Warning>
  The CRM+ spec does not yet document `401`, `403`, `422`, `429`, or `500` responses on any individual endpoint. These errors can and do occur. Build your integration to handle all standard HTTP error codes defensively, not just the ones explicitly listed in the reference documentation.
</Warning>

***

## Standard error codes

| HTTP Status | Code                  | Meaning                    | Common causes                                                                                                                   |
| ----------- | --------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `400`       | `BAD_REQUEST`         | Malformed request          | Invalid JSON syntax, missing required headers, unparseable request body                                                         |
| `401`       | `UNAUTHENTICATED`     | Authentication failed      | Missing `Authorization` header, expired OAuth token, revoked API Key                                                            |
| `403`       | `FORBIDDEN`           | Authorization failed       | Valid credentials but insufficient permissions for this resource or action                                                      |
| `404`       | `NOT_FOUND`           | Resource not found         | The ID in the path does not match any existing record                                                                           |
| `409`       | `CONFLICT`            | State conflict             | Duplicate record, uniqueness constraint violation                                                                               |
| `422`       | `VALIDATION_FAILED`   | Semantic validation failed | Valid JSON syntax but a field value violates a business rule (e.g., negative gift amount, future gift date on a completed gift) |
| `429`       | `RATE_LIMITED`        | Rate limit exceeded        | More than 1,500 requests in the current hour — see [Rate Limits](/crm/rate-limits)                                              |
| `500`       | `INTERNAL_ERROR`      | Server error               | Unexpected error on the Virtuous side — not caused by the request                                                               |
| `503`       | `SERVICE_UNAVAILABLE` | Service unavailable        | Temporary outage — retry after the period indicated in the `Retry-After` header                                                 |

<Note>
  `404` means the specific resource does not exist. An empty search result on a list endpoint is **not** a `404` — it returns `200` with `list: []` and `total: 0`. Code that treats `404` as "no records matched" will misinterpret real missing-resource errors.
</Note>

***

## Handling errors in code

A production-grade CRM+ client should: inspect the status before parsing the body, fall back gracefully when the body is plain text, branch on status for actionable error types, and respect the `Retry-After` header on `429`.

<CodeGroup>
  ```bash cURL theme={null}
  # Check the HTTP status code and inspect the body on error
  RESPONSE=$(curl -s -o /tmp/response_body.json -w "%{http_code}" \
    -X POST https://api.virtuoussoftware.com/api/Gift \
    -H "Authorization: Bearer YOUR_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"contactId": 4821, "amount": -50}')

  if [ "$RESPONSE" -ge 400 ]; then
    echo "Error $RESPONSE:"
    cat /tmp/response_body.json
  fi
  ```

  ```javascript JavaScript theme={null}
  async function callCrmApi(url, options = {}) {
    const response = await fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${process.env.VIRTUOUS_API_TOKEN}`,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    // Always attempt to parse JSON — but handle plain-text fallback
    let body;
    const contentType = response.headers.get('content-type') || '';
    if (contentType.includes('application/json')) {
      body = await response.json();
    } else {
      body = { error: { code: 'UNKNOWN', message: await response.text() } };
    }

    if (!response.ok) {
      const error = body?.error || {};
      switch (response.status) {
        case 400:
          throw new Error(`Bad request: ${error.message}`);
        case 401:
          throw new Error('Authentication failed — check your API token.');
        case 403:
          throw new Error('Permission denied for this resource.');
        case 404:
          throw new Error(`Resource not found: ${error.message}`);
        case 422: {
          const fieldErrors = (error.details || [])
            .map((d) => `${d.field}: ${d.message}`)
            .join(', ');
          throw new Error(`Validation failed: ${fieldErrors}`);
        }
        case 429: {
          // Respect the Retry-After header — see Rate Limits page
          const retryAfter = response.headers.get('Retry-After') || '60';
          throw new Error(`Rate limited — retry after ${retryAfter}s`);
        }
        default:
          throw new Error(`API error ${response.status}: ${error.message}`);
      }
    }

    return body;
  }
  ```
</CodeGroup>

***

## Validation error details

When the API returns `422 VALIDATION_FAILED`, the `error.details` array contains one entry per field that failed validation. Use these entries to surface specific error messages to your users or to identify the exact field that needs correction.

```json theme={null}
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request body contains invalid values.",
    "details": [
      {
        "field": "amount",
        "code": "MUST_BE_POSITIVE",
        "message": "Gift amount must be greater than zero."
      },
      {
        "field": "giftDate",
        "code": "REQUIRED",
        "message": "Gift date is required."
      }
    ]
  }
}
```

When `error.details` is an empty array (`[]`), the error applies to the request as a whole, not to any specific field.

<Tip>
  Surface `error.details[].message` directly to end users in your integration's UI. The messages are written for human consumption and do not leak internal system details. Use `error.details[].code` for programmatic branching (for example, to retry differently for a `REQUIRED` field versus a `MUST_BE_POSITIVE` value).
</Tip>

***

## Retryable vs. non-retryable errors

Not every error is worth retrying. Categorize errors before deciding whether to retry:

| Category       | Status codes                      | Retry?                               | Notes                                                                                                |
| -------------- | --------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| Transient      | `429`, `500`, `502`, `503`, `504` | Yes — with backoff                   | Respect the `Retry-After` header on `429` and `503`.                                                 |
| Authentication | `401`                             | Only after refreshing the credential | For OAuth, attempt one refresh and one retry. For API Keys, do not retry — the key has been revoked. |
| Authorization  | `403`                             | No                                   | The credential lacks the required permission. Retrying with the same credential will fail again.     |
| Client error   | `400`, `404`, `409`, `422`        | No                                   | The request needs to change. Retrying the identical request will produce the identical error.        |

See [Rate Limits](/crm/rate-limits) for the canonical retry-with-backoff pattern for `429` responses.

***

## Cross-API error handling

If your integration uses both Raise and CRM+, note that the two APIs return different error shapes. CRM+ uses an `error.code` / `error.message` / `error.details[]` envelope. Raise uses an RFC 7807–style `title` / `status` / `detail` envelope with field-level errors in a flat `errors` map.

| Product | Top-level error fields                           | Validation details                                                         |
| ------- | ------------------------------------------------ | -------------------------------------------------------------------------- |
| CRM+    | `error.code`, `error.message`, `error.details[]` | `error.details[].field`, `error.details[].code`, `error.details[].message` |
| Raise   | `title`, `status`, `detail`                      | `errors.{fieldName}[]`                                                     |

Detect which shape you have by checking whether the response root contains `error` (CRM+) or `title` (Raise). See [Raise Error Handling](/raise/error-handling) for the Raise-specific details.

***

## Next steps

<CardGroup cols={2}>
  <Card title="Rate Limits" icon="gauge" href="/crm/rate-limits">
    The retry-with-backoff pattern for handling `429 Too Many Requests` responses.
  </Card>

  <Card title="Pagination and Filtering" icon="list" href="/crm/pagination">
    How to iterate large result sets and how empty-result responses differ from `404`.
  </Card>

  <Card title="Authentication" icon="key" href="/crm/authentication">
    How to fix `401` and `403` responses by checking credentials and permission groups.
  </Card>

  <Card title="Reconcile Failed Syncs" icon="arrows-rotate" href="/crm/workflows/reconcile-failed-syncs">
    Patterns for retrying, deduplicating, and recovering from partial failures in bulk operations.
  </Card>
</CardGroup>
