# Webhooks

Use webhooks when your system needs to react to HollyHR changes without polling.
In the beta API, webhook events are emitted for changes made through the public
API write surface: people, employment, pending time-off records, time-off
categories, time-off settings, working-pattern setup, and org-unit setup.

UI-origin and domain-wide event emission is not part of the beta surface yet.
For example, a change made by a user inside the HollyHR app does not emit a
public API webhook just because the same event type exists for public
API-origin writes.

## Create an endpoint

Create webhooks through the API reference or with:

```bash
curl -X POST "$HOLLYHR_API_BASE_URL/webhooks" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_create_webhook_001" \
  --data '{
    "name": "Production webhook",
    "url": "https://example.com/hollyhr/webhooks",
    "subscribed_events": ["person.created", "time_off.created"]
  }'
```

The API key must include `webhooks:manage` plus the read scopes required for
the subscribed event payloads. For example, `person.created` requires
`people:read`, and `time_off.created` requires `time_off:read`. Endpoint URLs
must be public HTTPS URLs. Localhost, private IP ranges, local domains, URL
credentials, fragments, and private DNS resolutions are rejected.
Org-unit lifecycle events such as `org_unit.created` and `org_unit.archived`
require `org_units:read`.

Create and rotate responses return the plaintext signing secret once. Store it
securely. Later reads only expose `secret_last_four`, and idempotency replays
return `signing_secret: null`.

## Event payload

Each delivery uses a small wrapper:

```json
{
  "id": "evt_...",
  "type": "person.created",
  "api_version": "v1",
  "occurred_at": "2026-06-18T09:00:00.000Z",
  "data": {
    "id": "per_...",
    "resource_type": "person"
  }
}
```

Webhook payloads deliberately exclude compensation, bank details, tax or
government identifiers, dates of birth, home addresses, demographics, document
bytes, and free-text sensitive notes. Fetch elevated personal data through
`GET /people/{personId}/personal` with `people:personal:read` when needed.

## Event families

Beta webhook events are emitted only for successful public API writes. Each
event carries a thin, versioned payload with the event id, type, occurrence
time, resource id, and safe projection data. Fetch current state through REST
when you need more context.

| Family                 | Events                                                                                 | Required endpoint scope | Follow-up read route                        |
| ---------------------- | -------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------- |
| Synthetic test         | `webhook.test`                                                                         | `webhooks:manage`       | None; use the returned signed test payload. |
| People and employment  | `person.created`, `person.updated`, `person.ended`, `person.reactivated`               | `people:read`           | `GET /people/{personId}`                    |
| Time off requests      | `time_off.created`, `time_off.updated`, `time_off.cancelled`                           | `time_off:read`         | `GET /time-off/{timeOffId}`                 |
| Time-off configuration | `time_off_category.created`, `time_off_category.updated`, `time_off_category.archived` | `reference:read`        | `GET /time-off/categories`                  |
| Time-off settings      | `time_off_settings.updated`                                                            | `time_off:read`         | `GET /time-off/settings`                    |
| Organisation units     | `org_unit.created`, `org_unit.updated`, `org_unit.archived`, `org_unit.reactivated`    | `org_units:read`        | `GET /org-units/{orgUnitId}`                |
| Working patterns       | `working_pattern.created`, `working_pattern.updated`, `working_pattern.archived`       | `working_patterns:read` | `GET /working-patterns/{workingPatternId}`  |

Endpoint subscriptions are scope-checked when you create or update a webhook.
For example, a webhook that subscribes to both `person.updated` and
`working_pattern.updated` needs `webhooks:manage`, `people:read`, and
`working_patterns:read`.

## Verify signatures

HollyHR signs the raw JSON request body with HMAC-SHA256 over:

```text
<unix_timestamp>.<raw_json_body>
```

The signature is sent as:

```text
HollyHR-Webhook-Signature: v1=<current-digest>[,v1=<previous-digest>]
```

During the 24-hour secret rotation overlap, HollyHR includes signatures for the
current secret and the still-valid previous secret.

Always verify:

- `HollyHR-Webhook-Signature`
- `HollyHR-Webhook-Timestamp`
- the raw request body, before JSON parsing changes it
- the event id, so duplicate deliveries are ignored safely

## Headers

| Header                        | Meaning                                      |
| ----------------------------- | -------------------------------------------- |
| `HollyHR-Webhook-Id`          | Stable event id, for deduplication.          |
| `HollyHR-Webhook-Delivery-Id` | Delivery id for this endpoint/event pair.    |
| `HollyHR-Webhook-Event`       | Event type.                                  |
| `HollyHR-Webhook-Timestamp`   | Unix timestamp included in the signed input. |
| `HollyHR-Webhook-Signature`   | HMAC signatures for current/previous secret. |

## Retries and duplicates

Deliveries are at-least-once. Your endpoint should return a 2xx response only
after it has safely accepted the event. Non-2xx responses, timeouts, `408`,
`429`, and `5xx` responses are retried with bounded backoff. Non-retryable
`4xx` responses become failed deliveries.

Deduplicate by `HollyHR-Webhook-Id` or your own stored delivery id before
performing side effects.

If an active endpoint accumulates 10 retained terminally failed deliveries,
HollyHR automatically disables the endpoint and creates an in-app notification
for tenant system admins. The notification includes the endpoint name, failure
threshold, and latest sanitized failure summary, and links to API settings. It
does not include payload bodies, response bodies, signatures, signing secrets,
or raw delivery payload data.

Fix the receiver, inspect the health and delivery logs, then re-enable the
webhook with `PATCH /webhooks/{webhookId}` before redelivering failed events.
Automatic disablement is audited and does not delete delivery history.

## Delivery logs

Check endpoint health when you need an operational summary before paging
through delivery logs:

```bash
curl "$HOLLYHR_API_BASE_URL/webhooks/{webhookId}/health" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json"
```

The health response includes the endpoint status, derived health status,
delivery counts by status, recent success/failure timestamps, retry timing, and
the latest sanitized failure summary. `failing` means at least one retained
delivery is terminally failed and needs inspection. `recovering` means delivery
is still pending or scheduled for retry. `unknown` means no retained delivery
history exists yet.

Use delivery logs when you need to debug failed or delayed webhook deliveries:

```bash
curl "$HOLLYHR_API_BASE_URL/webhooks/{webhookId}/deliveries?status=retry_scheduled" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json"
```

List responses include delivery status, attempt count, retry timing, last HTTP
status, failure class, and sanitized failure message. Fetch one delivery to see
ordered attempt history:

```bash
curl "$HOLLYHR_API_BASE_URL/webhooks/{webhookId}/deliveries/{deliveryId}" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json"
```

Delivery logs never return payload bodies, request bodies, response bodies,
signatures, or signing secrets. The `resource` field is `null` unless the API
key also has the read scope for the event resource, for example `people:read`
for `person.updated`.

Failure classes are `network`, `http_retryable`, and `http_non_retryable`.
Terminal `delivered` and `failed` delivery logs are retained for 30 days.
Pending and `retry_scheduled` deliveries are not pruned by retention cleanup.
Do not depend on indefinite delivery-log availability.

## Redeliver a failed delivery

If your receiver was down or rejected a delivery, fix the receiver first, then
queue the existing delivery for another attempt:

```bash
curl -X POST "$HOLLYHR_API_BASE_URL/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: idem_redeliver_webhook_001"
```

Redelivery is allowed only when the webhook is active and the delivery is
`failed` or `retry_scheduled`. HollyHR keeps the same event and delivery ids,
preserves prior attempt history, and resets the delivery for the next worker
run. Continue deduplicating by `HollyHR-Webhook-Id`.

## Test delivery

Use the test endpoint to queue a synthetic `webhook.test` event:

```bash
curl -X POST "$HOLLYHR_API_BASE_URL/webhooks/{webhookId}/test" \
  -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_test_webhook_001" \
  --data '{"event_type":"webhook.test"}'
```

The response includes the signed headers and payload shape so you can verify
your receiver before relying on production events.

## API reference

Use the Webhooks section in the API reference for the exact request and response
schemas:

- `GET /webhooks`
- `POST /webhooks`
- `GET /webhooks/{webhookId}`
- `PATCH /webhooks/{webhookId}`
- `DELETE /webhooks/{webhookId}`
- `POST /webhooks/{webhookId}/rotate-secret`
- `POST /webhooks/{webhookId}/test`
- `GET /webhooks/{webhookId}/health`
- `GET /webhooks/{webhookId}/deliveries`
- `GET /webhooks/{webhookId}/deliveries/{deliveryId}`
- `POST /webhooks/{webhookId}/deliveries/{deliveryId}/redeliver`
