> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.pivotal.app/llms.txt.
> For full documentation content, see https://docs.pivotal.app/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pivotal.app/_mcp/server.

# Errors

Every non-2xx response from the Pivotal API lands in the same envelope:

```json
{
  "error": {
    "type":    "invalid_request_error",
    "code":    "missing_field",
    "message": "name is required",
    "field":   "name"
  }
}
```

* **`type`** is one of seven values (table below). Branch on this in your client.
* **`code`** is a stable machine-readable string. Safe to use in switch/case logic.
* **`message`** is a sentence intended for a human — log it, surface it in admin tools, but don't string-match against it.
* **`field`** is set only when the error points at a specific request property (validation, unique-violation).

## Error types

| Type                    | HTTP | What it means                                                                   |
| ----------------------- | ---- | ------------------------------------------------------------------------------- |
| `invalid_request_error` | 400  | The request didn't validate. Look at `code` and `field`.                        |
| `authentication_error`  | 401  | API key missing, malformed, or revoked.                                         |
| `permission_error`      | 403  | Key is valid but lacks permission for this resource. (Not used yet — reserved.) |
| `not_found`             | 404  | The resource doesn't exist, or belongs to another workspace.                    |
| `conflict`              | 409  | Unique-constraint violation (e.g. `slug_taken`, `email_taken`).                 |
| `rate_limit_error`      | 429  | You hit the per-key bucket. See [Rate limits](/api/welcome/rate-limits).        |
| `internal_error`        | 500  | Pivotal blew up. Retry with backoff; tell us if it persists.                    |

## Common codes

The set isn't huge. These show up most:

| Code                   | Where | Meaning                                                                                                                                                             |
| ---------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing_field`        | 400   | A required field wasn't sent. `field` tells you which.                                                                                                              |
| `invalid_field`        | 400   | A field is present but failed validation (wrong type, bad format, enum miss).                                                                                       |
| `invalid_request`      | 400   | The body shape is off (malformed JSON, wrong top-level shape).                                                                                                      |
| `missing_api_key`      | 401   | No `Authorization` header.                                                                                                                                          |
| `invalid_api_key`      | 401   | Header present but the key isn't recognized.                                                                                                                        |
| `api_key_revoked`      | 401   | Key existed but was revoked.                                                                                                                                        |
| `customer_not_found`   | 404   | The customer id (display\_id or cuid) doesn't resolve.                                                                                                              |
| `contact_not_found`    | 404   | Same shape for contacts.                                                                                                                                            |
| `onboarding_not_found` | 404   | Same shape for onboardings.                                                                                                                                         |
| `slug_taken`           | 409   | Another customer in this workspace already uses that slug.                                                                                                          |
| `shopify_domain_taken` | 409   | Another customer in this workspace already claims that domain. The error code references the underlying column name; the `field` on this error is `shopify_domain`. |
| `email_taken`          | 409   | Another contact under the same customer already uses that email.                                                                                                    |
| `rate_limited`         | 429   | Bucket empty. Honor `Retry-After`.                                                                                                                                  |
| `internal_error`       | 500   | Generic 500. Retry with backoff.                                                                                                                                    |
| `http_exception`       | 4xx   | Catch-all for unexpected validation failures. Rare.                                                                                                                 |

## How to branch in client code

Match on `type` for control flow, then on `code` if you need precision:

```typescript
const res = await fetch(url, { headers, body });
if (!res.ok) {
  const { error } = await res.json();
  switch (error.type) {
    case "invalid_request_error":
      throw new ValidationError(error.field, error.message);
    case "authentication_error":
      throw new AuthError(error.code);
    case "conflict":
      // e.g. retry with a different slug
      if (error.code === "slug_taken") return suggestNewSlug();
      throw new ConflictError(error.message);
    case "rate_limit_error": {
      const wait = Number(res.headers.get("Retry-After") ?? 1);
      await sleep(wait * 1000);
      return retry();
    }
    case "not_found":
      return null;
    default:
      throw new ApiError(error);
  }
}
```

## What you won't see

* HTML error pages — every error route returns JSON.
* Bare `{ "message": "..." }` — always wrapped in `{ "error": {...} }`.
* Status codes that disagree with the `type` — `404` always pairs with `not_found`, `409` with `conflict`, etc. If you see them out of sync, that's a bug — open a ticket.

## Validation error details

Zod validation errors carry their own `code` (the Zod issue code, e.g. `invalid_type`, `too_small`) and the `field` path. The `message` is taken straight from Zod — readable, but treat it as English copy, not a parseable format.

Example: posting a customer without `name`:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_type",
    "message": "Required",
    "field": "name"
  }
}
```

Posting a non-existent enum value to `status`:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_enum_value",
    "message": "Invalid enum value. Expected 'active' | 'onboarding' | 'at_risk' | 'churned' | 'low_touch', received 'pending'",
    "field": "status"
  }
}
```

## Reporting issues

If you hit a 5xx more than once on a single request, capture the response headers (especially `X-Request-Id` once we ship it) and send them to [help@pivotal.app](mailto:help@pivotal.app). Include the request body if it doesn't contain secrets.