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

# Errors

> HTTP status codes, error codes, and how to handle failures in your integration.

The Vendaze API uses standard HTTP status codes and returns a consistent error format on every failure.

## Error format

```json theme={null}
{
  "error": {
    "code": "not_found",
    "message": "Person not found.",
    "request_id": "550e8400-e29b-41d4-a716-446655440000"
  }
}
```

| Field        | Description                                                          |
| ------------ | -------------------------------------------------------------------- |
| `code`       | Machine-readable error code. Use in error handling logic.            |
| `message`    | Human-readable description for developers. Do not show to end users. |
| `request_id` | Unique request ID. Include when contacting support.                  |

<Note>
  A 2xx response never contains `error`. A non-2xx response always contains `error`. The
  `request_id` is also returned in the `X-Request-ID` response header on every request, including
  successful ones.
</Note>

## HTTP status codes

| Status | Meaning                                                                           |
| ------ | --------------------------------------------------------------------------------- |
| `200`  | Success. Resource read or updated.                                                |
| `201`  | Created successfully.                                                             |
| `204`  | No content. Resource deleted. Empty body.                                         |
| `400`  | Malformed request or missing required parameter.                                  |
| `401`  | Unauthenticated. Token missing, invalid, or expired.                              |
| `403`  | Forbidden. Valid token but insufficient scope or denied operation.                |
| `404`  | Not found. Resource does not exist, was deleted, or belongs to another workspace. |
| `409`  | Conflict. The request cannot be completed due to a conflict with existing data.   |
| `422`  | Validation failed. See the `fields` object.                                       |
| `429`  | Rate limit exceeded.                                                              |
| `500`  | Internal error. Something went wrong on the server.                               |

## Error code reference

<Accordion title="unauthorized (401)">
  Returned in three situations:

  * **Missing or invalid header:** `"Missing or invalid Authorization header."` the `Authorization: Bearer <token>` header is absent or malformed.
  * **Invalid token:** `"Invalid or malformed token."` the token signature failed verification or the token is not a valid JWT.
  * **Missing claims:** `"Token is missing required claims."` the token is structurally valid but is missing required internal claims (`workspace_id`, `client_id`, or `vendaze_scopes`). This should not happen with tokens issued by the Vendaze OAuth flow.
</Accordion>

<Accordion title="token_expired (401)">
  `"Access token expired. Refresh using the refresh token."`

  The access token has expired. Access tokens are valid for 1 hour. Use your `refresh_token` to
  obtain a new one via `POST /oauth/token` with `grant_type=refresh_token`. If the refresh token has
  also expired, the user must re-authorize.
</Accordion>

<Accordion title="insufficient_scope (403)">
  `"This endpoint requires the '{scope}' scope."`

  The token does not have the required scope for this endpoint. The `{scope}` placeholder in the
  message is the exact scope that is missing (for example, `"This endpoint requires the
      'people:write' scope."`). The user authorized your app with a set of scopes that does not include
  what this operation requires. You need to request a new authorization with the correct scopes.
</Accordion>

<Accordion title="forbidden (403)">
  The token is valid and has the right scope, but the operation was denied by a database-level
  permission check. The message varies by context:

  * **Create:** `"You do not have permission to create this {entity}. Check that all fields are within your access level."`
  * **Update:** `"You do not have permission to update this {entity} or one of the provided values is not allowed for your access level."`
  * **Delete:** `"You do not have permission to delete this {entity}."`

  Common cause: setting `owner_user_id` to a user other than yourself when your role does not allow
  it.
</Accordion>

<Accordion title="not_found (404)">
  `"{Entity} not found."` for example, `"Person not found."` or `"Deal not found."`

  The requested resource does not exist, has been soft-deleted, or belongs to a different workspace.
  The API does not distinguish between these cases to avoid leaking information about other
  workspaces.
</Accordion>

<Accordion title="conflict (409)">
  `"A record with this value already exists."`

  The request conflicts with existing data. The response also includes a `fields` object identifying
  the conflicting field. Common cause: attempting to register an app with an email that is already
  in use.
</Accordion>

<Accordion title="validation_error (422)">
  `"Validation failed."`

  Input failed validation. The response includes a `fields` object mapping each invalid field to a
  description of the problem. Fix the fields listed and retry. See [Validation errors](#validation-errors) for the full response shape.
</Accordion>

<Accordion title="bad_request (400)">
  Returned in two situations:

  * **Malformed body:** `"Request body must be valid JSON."` the request body could not be parsed as JSON.
  * **Empty update:** `"No fields provided for update."` a `PATCH` request was sent with no fields to modify.
</Accordion>

<Accordion title="rate_limit_exceeded (429)">
  `"Rate limit exceeded. Try again in {N} seconds."`

  Too many requests. The `{N}` in the message is the exact number of seconds to wait. The same
  value is also in the `Retry-After` response header. See [Rate Limits](/en/guides/rate-limits) for
  the full limits by method and endpoint type.
</Accordion>

<Accordion title="internal_error (500)">
  Returned in two situations:

  * **Unexpected server error:** `"An unexpected error occurred."` something failed on the server. Retry with exponential backoff. If the error persists, contact support with the `request_id`.
  * **Partial update:** `"{Entity} updated but associations failed. Retry the associations."` the main record was saved but the association update (tags, lists, deals, or custom fields) failed. The record itself is consistent. Retry only the association fields.
</Accordion>

## OAuth error codes

These codes are returned exclusively by the OAuth endpoints (`/oauth/authorize`, `/oauth/token`, `/oauth/revoke`, `/v1/auth/register-app`, `/v1/auth/rotate-app`).

<Accordion title="invalid_client (401)">
  `"Invalid client credentials."` the `client_id` or `client_secret` is incorrect. Verify your
  credentials. If you suspect the `client_secret` has been compromised, rotate it via `POST
      /v1/auth/rotate-app`.

  Also returned as `401` from `/oauth/authorize` when the `client_id` does not exist or the app is
  not active, and from `/v1/auth/rotate-app` when the email sent does not match the one registered
  with the app.
</Accordion>

<Accordion title="invalid_grant (400)">
  `"The provided authorization grant is invalid, expired, or does not match."`

  The authorization code is invalid, expired, or has already been used. Authorization codes expire
  in 10 minutes and are single-use. Also returned when a `refresh_token` is invalid or expired. In
  either case, the user must go through the full authorization flow again.
</Accordion>

<Accordion title="invalid_request (400)">
  A required OAuth parameter is missing or invalid. The message identifies the specific parameter.
  Examples:

  * `"Missing required parameter: client_id."`
  * `"Missing required parameter: redirect_uri."`
  * `"redirect_uri does not match any registered URI."`
  * `"Missing required parameter: token."` (revoke)
  * `"token must be a valid access_token. refresh_tokens are not accepted."` (revoke)
</Accordion>

<Accordion title="unsupported_grant_type (400)">
  `"grant_type must be \"authorization_code\" or \"refresh_token\"."`

  The `grant_type` field sent to `/oauth/token` is not one of the two supported values.
</Accordion>

<Accordion title="unsupported_response_type (400)">
  `"response_type must be \"code\"."`

  The `response_type` parameter sent to `/oauth/authorize` is not `"code"`.
</Accordion>

## Validation errors

When validation fails, the response includes a `fields` object mapping each field to its problem:

```json theme={null}
{
  "error": {
    "code": "validation_error",
    "message": "Validation failed.",
    "fields": {
      "full_name": "String must contain at most 70 character(s).",
      "forecast_date": "Invalid ISO 8601 date format."
    },
    "request_id": "uuid"
  }
}
```

## The request\_id field

Every request, successful or not, gets a unique `request_id`. It appears in:

* The `error.request_id` field on error responses
* The `X-Request-ID` response header on all responses

When contacting support about an issue, always include the `request_id`. It allows the support team to locate the exact request in the logs and diagnose what happened.

## Handling errors in production

### Recoverable vs non-recoverable errors

Not all errors should be retried. Retrying a non-recoverable error wastes resources and delays surfacing the real problem.

**Recoverable errors** are transient. Retry with backoff:

| Status | Code                  | Strategy                                                               |
| ------ | --------------------- | ---------------------------------------------------------------------- |
| `429`  | `rate_limit_exceeded` | Wait the number of seconds in `Retry-After`, then retry.               |
| `500`  | `internal_error`      | Retry with exponential backoff (1s, 2s, 4s). Give up after 3 attempts. |

**Non-recoverable errors** indicate a problem with the request or credentials. Do not retry automatically:

| Status | Code                     | What to do                                                                            |
| ------ | ------------------------ | ------------------------------------------------------------------------------------- |
| `400`  | `bad_request`            | Fix the request. The body is malformed or the update has no fields.                   |
| `400`  | `invalid_grant`          | The code or refresh token is invalid. Redirect the user through the OAuth flow.       |
| `400`  | `invalid_request`        | A required OAuth parameter is missing or invalid. Fix the request.                    |
| `400`  | `unsupported_grant_type` | Use `authorization_code` or `refresh_token`.                                          |
| `401`  | `token_expired`          | Refresh the access token, then retry the original request.                            |
| `401`  | `unauthorized`           | Check that the token is present and correctly formatted.                              |
| `401`  | `invalid_client`         | Check your `client_id` and `client_secret`. Do not retry automatically.               |
| `403`  | `insufficient_scope`     | Request a new authorization with the required scopes.                                 |
| `403`  | `forbidden`              | Fix the request. The operation or field values are not allowed for your access level. |
| `404`  | `not_found`              | The resource does not exist. Do not retry.                                            |
| `409`  | `conflict`               | Resolve the conflict before retrying.                                                 |
| `422`  | `validation_error`       | Fix the fields listed in `fields`. Do not retry with the same payload.                |

### Recommended error handling pattern

```javascript theme={null}
async function apiCall(url, options) {
  const res = await fetch(url, options);

  if (!res.ok) {
    const { error } = await res.json();

    switch (error.code) {
      case 'token_expired':
        // Refresh the token and retry once
        await refreshAccessToken();
        return apiCall(url, options);

      case 'invalid_grant':
        // Refresh token is invalid or expired. User must re-authorize.
        throw new Error('Authorization expired. User must re-authorize.');

      case 'invalid_client':
        // Credentials are wrong. Do not retry.
        throw new Error('Invalid client credentials. Check client_id and client_secret.');

      case 'rate_limit_exceeded': {
        const retryAfter = parseInt(res.headers.get('Retry-After') ?? '60');
        await new Promise((r) => setTimeout(r, retryAfter * 1000));
        return apiCall(url, options);
      }

      case 'not_found':
        return null;

      case 'internal_error':
        // Log the request_id for support, then surface to the caller
        console.error(`Server error. request_id: ${error.request_id}`);
        throw new Error(`Server error (request_id: ${error.request_id})`);

      default:
        throw new Error(`${error.code}: ${error.message} (request_id: ${error.request_id})`);
    }
  }

  return res.json();
}
```
