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

# Errors

> The error envelope, every status code OnTime returns, and what each error code actually means.

## The envelope

Almost every error is a single-field JSON object:

```json theme={null}
{ "error": "forbidden" }
```

The `error` value is a stable machine-readable code. It is what you should branch on — the HTTP status alone often is not specific enough (several distinct conditions all return `409`).

**Request validation is the exception.** A body or query that fails schema validation returns a different, richer shape — see **400 — validation failed** below.

## Status codes

### 400 — validation failed

The request did not match the endpoint's schema. The response carries the value you sent and the specific issues:

```json theme={null}
{
  "success": false,
  "data": { "date": "25-07-2026" },
  "error": [
    {
      "code": "invalid_string",
      "path": ["date"],
      "message": "Invalid"
    }
  ]
}
```

`error` is an array of issues, each with the `path` to the offending field. `data` echoes what you submitted, which makes this the one error worth logging in full during development.

<Warning>
  `data` contains your request body verbatim. If you log 400 responses in production, redact them — a validation failure on a request carrying sensitive fields will put those fields in your logs.
</Warning>

### 401 — unauthenticated

```json theme={null}
{ "error": "unauthorized" }
```

No credential, a malformed one, an unknown key prefix, a key whose secret does not verify, or a **revoked** key. All four look identical by design — distinguishing them would let an attacker confirm which prefixes exist.

### 403 — forbidden

```json theme={null}
{ "error": "forbidden" }
```

Authenticated, but not allowed. Two distinct causes share this shape:

| Cause          | Meaning                                                                            |
| -------------- | ---------------------------------------------------------------------------------- |
| **Permission** | The credential lacks the permission key the route requires.                        |
| **Scope**      | The credential has the permission but the *subject* employee is outside its scope. |

A third variant appears on approval routes:

```json theme={null}
{ "error": "stage_permission" }
```

The request is at a stage requiring a permission you do not hold — typically the HR step, which needs `hr:approve`.

### 404 — not found

```json theme={null}
{ "error": "not_found" }
```

<Warning>
  **A cross-organization read returns `404`, not `403`.** Tenant isolation is enforced at the database level, so a record in another organization is not visible at all — the row does not exist as far as your query is concerned.

  This is deliberate. Returning "forbidden" would confirm the record exists, which is itself a leak. Do not treat a `404` as proof that an ID is invalid; it may simply not be yours.
</Warning>

A scope check against an employee who does not exist also returns `404` rather than `403`, for the same reason.

### 409 — state conflict

The request is well-formed and permitted, but the resource is not in a state that allows it. This is where the specific code matters most:

| Code               | Meaning                                                                                                             |
| ------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `not_pending`      | The request has already been decided — often because somebody else decided it a moment ago.                         |
| `same_actor`       | You are trying to approve a request you filed or already approved at an earlier stage.                              |
| `bad_stage`        | A payroll cycle stage transition out of order — calculating before inputs are complete, disbursing before approval. |
| `cycle_locked`     | The payroll cycle is disbursed; overrides are refused.                                                              |
| `day_frozen`       | The date belongs to a frozen payroll month. Punches and day-affecting approvals are refused.                        |
| `duplicate_punch`  | The same punch direction within 60 seconds.                                                                         |
| `type_in_use`      | A leave type with balances or requests against it cannot be deleted.                                                |
| `preset_immutable` | The four role presets cannot be edited or deleted.                                                                  |
| `not_signed`       | A Form 16 batch cannot be distributed before it is signed.                                                          |

<Note>
  **`not_pending` is normal under concurrency**, not an error in your integration. When two approvers act simultaneously exactly one wins and the other gets this. Treat it as "already done" rather than retrying.
</Note>

### 429 — rate limited

```json theme={null}
{ "error": "too_many_requests" }
```

600 requests per minute per key on the public API. Standard rate-limit headers accompany the response. Back off and retry.

### 501 — not configured

```json theme={null}
{ "error": "traces_not_configured" }
```

A declared vendor seam with no credentials configured — TRACES Part-A fetch and DSC signing in the Form 16 pipeline. This is an honest refusal rather than a pretence of success.

## Handling errors

<CodeGroup>
  ```typescript TypeScript theme={null}
  async function ontime<T>(path: string): Promise<T> {
    const res = await fetch(`https://app.ontime.hosai.app${path}`, {
      headers: { Authorization: `Bearer ${process.env.ONTIME_API_KEY}` },
    });

    if (res.ok) return res.json() as Promise<T>;

    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("retry-after") ?? 60);
      await new Promise((r) => setTimeout(r, retryAfter * 1000));
      return ontime(path);
    }

    const body = await res.json().catch(() => ({}));
    // 400 uses `{success, data, error[]}`; everything else uses `{error: "code"}`.
    const code = Array.isArray(body.error) ? "validation_failed" : body.error;
    throw new Error(`OnTime ${res.status} ${code ?? "unknown"}`);
  }
  ```

  ```python Python theme={null}
  import os, time, requests

  BASE = "https://app.ontime.hosai.app"

  def ontime(path: str):
      res = requests.get(
          BASE + path,
          headers={"Authorization": f"Bearer {os.environ['ONTIME_API_KEY']}"},
          timeout=30,
      )
      if res.ok:
          return res.json()

      if res.status_code == 429:
          time.sleep(int(res.headers.get("retry-after", 60)))
          return ontime(path)

      body = res.json() if res.content else {}
      err = body.get("error")
      code = "validation_failed" if isinstance(err, list) else err
      raise RuntimeError(f"OnTime {res.status_code} {code or 'unknown'}")
  ```
</CodeGroup>

## Which errors to retry

| Status | Retry?                                                                                                   |
| ------ | -------------------------------------------------------------------------------------------------------- |
| `400`  | **No.** Fix the request.                                                                                 |
| `401`  | **No.** Fix the credential.                                                                              |
| `403`  | **No.** A permission or scope problem does not resolve itself.                                           |
| `404`  | **No.**                                                                                                  |
| `409`  | **Usually no.** The state conflict is real — `not_pending` in particular means the work is already done. |
| `429`  | **Yes**, after backing off.                                                                              |
| `5xx`  | **Yes**, with exponential backoff.                                                                       |
