# Requests

The API accepts and returns JSON. List endpoints use cursor pagination where a
large result set is possible.

## Pagination

List responses that can be paginated return a `pagination` object:

```json
{
  "data": [],
  "pagination": {
    "next_cursor": "cursor_...",
    "has_more": true,
    "limit": 50
  }
}
```

Pass `next_cursor` back as the `cursor` query parameter to request the next
page. Do not parse cursor values; treat them as opaque strings.
For endpoint-specific loops, filters, and retry behavior, use the
[pagination examples](/pagination-examples).

## Incremental sync

People, time-off, and document metadata support `updated_since` for polling
jobs:

```text
GET /api/v1/people?updated_since=2026-06-16T09:00:00.000Z
```

Use ISO-8601 UTC timestamps and follow pagination until `has_more` is false.

## Idempotency

Write requests require an `Idempotency-Key` header so retries cannot create or
apply the same HR change twice:

```text
Idempotency-Key: idem_20260618_update_ada_title
```

Reusing the same key with the same method, route, path resource, version
precondition, and request body replays the original response. Reusing it with a
different body, person id, or `If-Match` value returns
`idempotency_conflict`.

## Conditional updates

Mutable updates use ETags to avoid lost updates. First read the resource you
are going to update and keep the exact strong `ETag` response header:

```bash
curl -i "$HOLLYHR_API_BASE_URL/people/{person_id}" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json"
```

Then send that exact value back as `If-Match` when patching safe setup fields:

```bash
curl -X PATCH "$HOLLYHR_API_BASE_URL/people/{person_id}" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_20260618_update_ada_title" \
  -H 'If-Match: "etag-from-get-response"' \
  --data '{"job_title":"Principal Engineer"}'
```

The same `If-Match` rule applies to lifecycle actions such as ending or
reactivating a person. Focused employment updates use the employment resource's
own ETag:

```bash
curl -X POST "$HOLLYHR_API_BASE_URL/people/{person_id}/end" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_end_ada_001" \
  -H 'If-Match: "etag-from-get-response"' \
  --data '{"end_date":"2026-07-31"}'
```

```bash
curl -i "$HOLLYHR_API_BASE_URL/people/{person_id}/employment" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json"
```

```bash
curl -X PATCH "$HOLLYHR_API_BASE_URL/people/{person_id}/employment" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_20260618_update_employment" \
  -H 'If-Match: "employment-etag-from-get-response"' \
  --data '{"job_title":"People Lead","start_date":"2026-02-01"}'
```

Employment-history creation is append-style and requires `Idempotency-Key`.
Unsupported free-text reason/notes and compensation fields are rejected:

```bash
curl -X POST "$HOLLYHR_API_BASE_URL/people/{person_id}/employment-history" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_20260618_add_employment_history" \
  --data '{"job_title":"People Lead","start_date":"2026-03-01"}'
```

Missing `If-Match` returns `precondition_required`. A stale or non-exact ETag
returns `precondition_failed`; read the person again, reconcile the current
data, then retry with a new idempotency key.

## Webhooks

Webhook management is available through `/api/v1/webhooks`
for API keys with `webhooks:manage`. Webhook mutations require
`Idempotency-Key`, and create/rotate responses reveal the plaintext signing
secret once.

For setup, signatures, retries, duplicate handling, and testing, use the
[Webhooks guide](/webhooks). For exact request and response schemas, use the
Webhooks section in the [API reference](/reference).

## Errors

Errors use a stable JSON envelope. HollyHR is deliberately keeping this custom
v1 envelope instead of switching to RFC 7807 because every public API error can
carry the same compact shape, stable machine code, and request id:

```json
{
  "error": {
    "code": "permission_denied",
    "message": "Missing required scope.",
    "request_id": "req_..."
  }
}
```

Validation errors may include `details`, an array of field-level issues. The
`message` is human-readable and may become clearer over time; clients should
branch on `error.code`, the HTTP status, and field identifiers in `details`.

Common codes:

| Status | Code                      | Meaning                                       |
| ------ | ------------------------- | --------------------------------------------- |
| 400    | `invalid_request`         | Invalid query, path, cursor, or body shape.   |
| 401    | `authentication_required` | Missing, malformed, unknown, or revoked key.  |
| 403    | `permission_denied`       | Plan entitlement or scope is missing.         |
| 404    | `not_found`               | Resource not found or not visible to org.     |
| 409    | `idempotency_conflict`    | Idempotency key reused for a different write. |
| 412    | `precondition_failed`     | Conditional update used a stale ETag.         |
| 428    | `precondition_required`   | Conditional update omitted `If-Match`.        |
| 429    | `rate_limited`            | Too many requests.                            |

Keep the `request_id` when asking HollyHR support to investigate a failed API
request.

## Rate limits

Rate limits are tenant/key aware. Public API requests are limited before
authentication and again after authentication:

| Bucket                              | Default                                                       |
| ----------------------------------- | ------------------------------------------------------------- |
| Pre-authentication abuse protection | 60 requests per minute per IP and credential fingerprint.     |
| Authenticated route bucket          | 600 requests per minute per organisation, API key, and route. |

Individual endpoints may use lower limits when a request is more expensive or
triggers delivery side effects. Follow the API reference for endpoint-specific
429 responses.

Successful authenticated responses include:

| Header                | Meaning                                                |
| --------------------- | ------------------------------------------------------ |
| `RateLimit-Limit`     | The current authenticated route bucket limit.          |
| `RateLimit-Remaining` | Requests remaining in the current route bucket window. |
| `RateLimit-Reset`     | Seconds until the current route bucket resets.         |

A `429` response includes `Retry-After` when a retry window is available. When
rate-limit metadata is available, the response also includes `RateLimit-Limit`,
`RateLimit-Remaining`, and `RateLimit-Reset`. Back off until the reset window
instead of retrying immediately.
