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

# Syncing Employee Time-Off

> Push vacations and out-of-office periods from your HRIS (BambooHR, Personio, Workday, a spreadsheet) into Zeeg so bookings never land on someone who's away.

This guide walks you through pushing employee absences — vacations, out-of-office days, sabbaticals — from an HRIS (or any system of record for time off) into Zeeg. Once a period exists in Zeeg, that user becomes unavailable for bookings on the affected days, on every scheduling page they own or share.

<Info>
  The endpoints in this guide live under the **Availability Schedule** API and require a token with `schedules:write` for mutations and `schedules:read` for reads. Mapping HRIS users to Zeeg additionally needs `users:read` (or `admin:full`).
</Info>

## Concepts

Three different objects affect a user's availability. Pick the right one for the job:

| Object                                               | What it is                                                                                                                                 | When to use it                                                          |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| **Time-off period** (`/time-off`)                    | A dated range when a specific user is unavailable — `vacation` or `out_of_office`. Optional half-day cutoffs on the first and/or last day. | This guide. Sync vacations, OOO days, sabbaticals from your HRIS.       |
| **Availability schedule** (`/schedules`)             | A user's recurring weekly working hours, plus per-date overrides (`specialHours`).                                                         | Permanent changes to working hours, e.g. someone moves to a 4-day week. |
| **Holiday subscription** (`/holidays/subscriptions`) | A subscription to a country's or region's public holidays.                                                                                 | Country-wide closures (e.g. all German users blocked on Dec 25).        |

This guide focuses on **time-off periods**. Holiday subscriptions and weekly schedules are covered briefly at the end.

### Time-off shape

A time-off period is **all-day by default** and identified by date, not datetime — there is no `startTime`/`endTime`/`timeZone` on the resource. Half-day flags (`startHalfDay`, `endHalfDay`) with `HH:MM` cutoffs let the first or last day be partial; the cutoff is interpreted in the user's schedule timezone.

| Field                                     | Type                          | Notes                                                           |
| ----------------------------------------- | ----------------------------- | --------------------------------------------------------------- |
| `type`                                    | `vacation` \| `out_of_office` | Required.                                                       |
| `title`                                   | string (≤255)                 | Required on create.                                             |
| `startDate` / `endDate`                   | date (`YYYY-MM-DD`)           | Required. Inclusive on both ends.                               |
| `startHalfDay` / `endHalfDay`             | boolean                       | Default `false`.                                                |
| `startHalfDayCutoff` / `endHalfDayCutoff` | `HH:MM`                       | Required when the matching half-day flag is `true`.             |
| `note`                                    | string (≤1000), nullable      | Free-form. Useful as a forensic tag for the source HRIS record. |

## Prerequisites

* A Zeeg admin or owner account with API access enabled.
* An API token with the following scopes:
  * `users:read` (or `admin:full`) — list workspace users.
  * `schedules:read` — list existing time-off.
  * `schedules:write` — create, update, delete time-off.
* The token owner must have edit rights on each target user's schedules (i.e. be the user themselves, an admin/owner, or the team manager). Targets the token can't edit are reported as `failed` — they don't fail the whole request.

## End-to-end sync flow

<Steps>
  ### Map HRIS users to Zeeg users

  Time-off endpoints accept any of three identifiers per user: `email`, `slug`, or workspace `uuid`. Email is the most stable join key against most HRIS systems, so cache an email → Zeeg `uuid` map at the start of every sync.

  <CodeGroup>
    ```bash cURL theme={null}
    curl -X GET "https://api.zeeg.me/v2/organizations/users?per_page=100&page=1" \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -H "Accept: application/json"
    ```

    ```python Python theme={null}
    import requests

    def fetch_zeeg_users(token):
        users = {}
        page = 1
        while True:
            resp = requests.get(
                "https://api.zeeg.me/v2/organizations/users",
                params={"per_page": 100, "page": page},
                headers={
                    "Authorization": f"Bearer {token}",
                    "Accept": "application/json",
                },
            )
            resp.raise_for_status()
            body = resp.json()
            for u in body["collection"]:
                if u["isActive"]:
                    users[u["email"].lower()] = u
            if page >= body["pagination"]["totalPages"]:
                break
            page += 1
        return users
    ```

    ```javascript JavaScript theme={null}
    async function fetchZeegUsers(token) {
      const users = {};
      let page = 1;
      while (true) {
        const resp = await fetch(
          `https://api.zeeg.me/v2/organizations/users?per_page=100&page=${page}`,
          {
            headers: {
              Authorization: `Bearer ${token}`,
              Accept: "application/json",
            },
          }
        );
        const body = await resp.json();
        for (const u of body.collection) {
          if (u.isActive) users[u.email.toLowerCase()] = u;
        }
        if (page >= body.pagination.totalPages) break;
        page += 1;
      }
      return users;
    }
    ```
  </CodeGroup>

  <Tip>
    Lowercase emails on both sides of the join — Zeeg stores them as the user typed them. Skip inactive users (`isActive: false`); attempting to write time-off for them returns `failed`.
  </Tip>

  **HRIS users with no Zeeg account.** This will happen — contractors, new hires not yet provisioned, deactivated employees still in the HRIS. Log them and skip; do not try to invite them implicitly.

  ### Create time-off periods

  `POST /time-off` is **bulk-only**. Each request creates the same period for one set of users (up to 10) **or** every active member of one team. There is no single-user create endpoint — to create one period for one person, send a `users.emails` array with one entry.

  Provide **exactly one** of `users` or `team`. Inside `users`, provide **exactly one** of `emails`, `slugs`, or `uuids`.

  **Example: Alice is on vacation Mon–Fri next week.**

  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST "https://api.zeeg.me/v2/time-off" \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json" \
      -d '{
        "users": { "emails": ["alice.weber@horizondigital.de"] },
        "type": "vacation",
        "title": "Spring break",
        "startDate": "2026-05-11",
        "endDate": "2026-05-15",
        "note": "[hris-id:bamboo-12345]"
      }'
    ```

    ```python Python theme={null}
    import requests

    resp = requests.post(
        "https://api.zeeg.me/v2/time-off",
        headers={
            "Authorization": "Bearer YOUR_TOKEN",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
        json={
            "users": {"emails": ["alice.weber@horizondigital.de"]},
            "type": "vacation",
            "title": "Spring break",
            "startDate": "2026-05-11",
            "endDate": "2026-05-15",
            "note": "[hris-id:bamboo-12345]",
        },
    )
    result = resp.json()
    ```

    ```javascript JavaScript theme={null}
    const resp = await fetch("https://api.zeeg.me/v2/time-off", {
      method: "POST",
      headers: {
        Authorization: "Bearer YOUR_TOKEN",
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({
        users: { emails: ["alice.weber@horizondigital.de"] },
        type: "vacation",
        title: "Spring break",
        startDate: "2026-05-11",
        endDate: "2026-05-15",
        note: "[hris-id:bamboo-12345]",
      }),
    });
    const result = await resp.json();
    ```
  </CodeGroup>

  The response is a per-target outcome list. Each entry's `status` is `created`, `replaced`, or `failed` — handle all three:

  ```json theme={null}
  {
    "users": [
      {
        "email": "alice.weber@horizondigital.de",
        "slug": "alice-weber",
        "uuid": "AliceK2QwR9XPv",
        "status": "created",
        "period": {
          "uuid": "01907f10-5b00-7b31-9cf0-1234567890ab",
          "uri": "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab",
          "type": "vacation",
          "title": "Spring break",
          "startDate": "2026-05-11",
          "endDate": "2026-05-15",
          "startHalfDay": false,
          "endHalfDay": false,
          "startHalfDayCutoff": null,
          "endHalfDayCutoff": null,
          "note": "[hris-id:bamboo-12345]"
        },
        "replaced": []
      }
    ]
  }
  ```

  The HTTP status is `200` if at least one entry succeeded; `422` only when **every** target failed. Always inspect each entry's `status` rather than trusting the HTTP code alone.

  **Half-day example.** Alice leaves at 13:00 on Friday afternoon:

  ```json theme={null}
  {
    "users": { "emails": ["alice.weber@horizondigital.de"] },
    "type": "vacation",
    "title": "Long weekend",
    "startDate": "2026-05-15",
    "endDate": "2026-05-15",
    "startHalfDay": true,
    "startHalfDayCutoff": "13:00"
  }
  ```

  The cutoff is interpreted against the user's schedule, not the caller's timezone — verify against your users' configured timezones if half-day precision matters.

  ### Update or delete when the HRIS record changes

  `PATCH /time-off/{uuid}` partially updates a period. Only supplied fields change; omitted fields are left alone.

  <CodeGroup>
    ```bash cURL theme={null}
    curl -X PATCH "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab" \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json" \
      -d '{
        "startDate": "2026-05-12",
        "endDate": "2026-05-16"
      }'
    ```

    ```python Python theme={null}
    import requests

    resp = requests.patch(
        f"https://api.zeeg.me/v2/time-off/{uuid}",
        headers={
            "Authorization": "Bearer YOUR_TOKEN",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
        json={"startDate": "2026-05-12", "endDate": "2026-05-16"},
    )
    ```

    ```javascript JavaScript theme={null}
    const resp = await fetch(`https://api.zeeg.me/v2/time-off/${uuid}`, {
      method: "PATCH",
      headers: {
        Authorization: "Bearer YOUR_TOKEN",
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({
        startDate: "2026-05-12",
        endDate: "2026-05-16",
      }),
    });
    ```
  </CodeGroup>

  `DELETE /time-off/{uuid}` removes a period:

  ```bash theme={null}
  curl -X DELETE "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -H "Accept: application/json"
  ```

  <Warning>
    **PATCH rejects overlap.** If the new `startDate`/`endDate` would overlap another time-off period the same user already owns, PATCH returns `422` with `This time-off period overlaps with an existing one.` This is the opposite of POST — see the next step.
  </Warning>

  ### Solve the dedup / idempotency problem

  The `/time-off` resource has no native external-id lookup, so re-running a sync needs care to avoid duplicates. Use the two mechanisms Zeeg gives you:

  **1. Built-in overlap-replace on POST.** When you `POST /time-off` for a user and the new range overlaps an existing period, Zeeg deletes the old period(s), creates the new one, and returns `status: "replaced"` with the deleted UUIDs in `replaced[]`. This makes re-posting the same vacation safe-by-default — the second call replaces the first instead of duplicating.

  ```json theme={null}
  {
    "users": [
      {
        "email": "alice.weber@horizondigital.de",
        "slug": "alice-weber",
        "uuid": "AliceK2QwR9XPv",
        "status": "replaced",
        "period": { "uuid": "01907f10-...new-uuid", "...": "..." },
        "replaced": ["01907f10-...old-uuid"]
      }
    ]
  }
  ```

  **2. An external-id → Zeeg-uuid map on your side.** Overlap-replace handles re-syncs of the same vacation, but it doesn't help when:

  * The HRIS shifts a vacation to a non-overlapping range (old: May 11–15, new: May 18–22). POST creates a second period instead of moving the first. You need the original UUID to PATCH or DELETE.
  * The HRIS deletes a vacation. Zeeg doesn't know about the source-of-truth deletion; you must DELETE explicitly.
  * You want to detect drift between HRIS and Zeeg (e.g. someone manually created a Zeeg-side period that has no HRIS counterpart).

  So the canonical pattern is: **keep a persistent map** (`hris_record_id` → `zeeg_period_uuid`, plus the user identifier and the last-seen range) on the integration side. The map is your primary lookup. The `note` field is a fine **forensic** breadcrumb (e.g. `[hris-id:bamboo-12345]`) — it shows up in the dashboard and helps humans audit — but `note` is not query-filterable, so it's a complement to the map, not a substitute.

  <Tip>
    When you want to *move* a period to a non-overlapping range, choose between POST and PATCH:

    * **PATCH** if you have the UUID and the new range doesn't overlap any other of that user's periods. PATCH is cheaper and preserves the same UUID downstream.
    * **POST** if you don't have the UUID, or the new range overlaps another existing period you'd actually like to absorb.
  </Tip>

  ### Reconcile: a worked sync loop

  Putting it together. The sync runs against three sources of state — your HRIS, your local map, and Zeeg — and produces three sets of operations: create, update, delete.

  ```python Python theme={null}
  # Pseudocode for an HRIS → Zeeg time-off reconciliation pass.
  # Assumes you have:
  #   hris_records:  list of dicts with {id, employee_email, type, start, end, title}
  #   local_map:     dict mapping hris_id -> {zeeg_uuid, email, start, end, type, title}
  #   zeeg_users:    dict mapping email.lower() -> Zeeg user (from step 1)

  import requests

  API = "https://api.zeeg.me/v2"
  HEADERS = {
      "Authorization": "Bearer YOUR_TOKEN",
      "Content-Type": "application/json",
      "Accept": "application/json",
  }

  def upsert(record, zeeg_user):
      """Create a new period (overlap-replace handles re-runs)."""
      body = {
          "users": {"emails": [zeeg_user["email"]]},
          "type": record["type"],            # "vacation" or "out_of_office"
          "title": record["title"],
          "startDate": record["start"],
          "endDate": record["end"],
          "note": f"[hris-id:{record['id']}]",
      }
      resp = requests.post(f"{API}/time-off", headers=HEADERS, json=body)
      payload = resp.json()
      entry = payload["users"][0]
      if entry["status"] in ("created", "replaced"):
          return entry["period"]["uuid"]
      raise RuntimeError(f"Failed for {record['id']}: {entry.get('reason')}")

  def patch(zeeg_uuid, fields):
      resp = requests.patch(f"{API}/time-off/{zeeg_uuid}", headers=HEADERS, json=fields)
      resp.raise_for_status()

  def delete(zeeg_uuid):
      resp = requests.delete(f"{API}/time-off/{zeeg_uuid}", headers=HEADERS)
      if resp.status_code == 404:
          return  # already gone, that's fine
      resp.raise_for_status()


  seen_hris_ids = set()

  for record in hris_records:
      seen_hris_ids.add(record["id"])
      zeeg_user = zeeg_users.get(record["employee_email"].lower())
      if not zeeg_user:
          log_skip(record, reason="no Zeeg account")
          continue

      cached = local_map.get(record["id"])
      if cached is None:
          # New HRIS record -> POST (overlap-replace handles incidental dupes).
          new_uuid = upsert(record, zeeg_user)
          local_map[record["id"]] = {
              "zeeg_uuid": new_uuid,
              "email": zeeg_user["email"],
              "start": record["start"],
              "end": record["end"],
              "type": record["type"],
              "title": record["title"],
          }
      elif (cached["start"], cached["end"], cached["type"], cached["title"]) != (
          record["start"], record["end"], record["type"], record["title"]
      ):
          # Existing record changed -> PATCH if range is safe, else POST + replace.
          try:
              patch(cached["zeeg_uuid"], {
                  "type": record["type"],
                  "title": record["title"],
                  "startDate": record["start"],
                  "endDate": record["end"],
              })
          except requests.HTTPError as e:
              # 422 on overlap -> fall back to POST, which replaces conflicts.
              if e.response.status_code == 422:
                  new_uuid = upsert(record, zeeg_user)
                  local_map[record["id"]]["zeeg_uuid"] = new_uuid
              else:
                  raise
          local_map[record["id"]].update({
              "start": record["start"], "end": record["end"],
              "type": record["type"], "title": record["title"],
          })

  # Anything in the map that the HRIS didn't return -> deleted upstream.
  for hris_id in list(local_map):
      if hris_id not in seen_hris_ids:
          delete(local_map[hris_id]["zeeg_uuid"])
          del local_map[hris_id]
  ```

  A few things this loop intentionally handles:

  * **Idempotency on first run.** If your map is empty but Zeeg already has matching periods, the POST overlap-replace logic absorbs them — you don't end up with double-booked time-off.
  * **Range moves.** PATCH first (preserves the UUID), POST as fallback when PATCH hits a `422` overlap.
  * **Upstream deletions.** Anything in the map but not in this HRIS pull is treated as deleted.
  * **Failed entries.** `upsert` raises on `failed` status (often: token can't edit that user's schedules) so you can log and continue.
</Steps>

## Adjacent topics

### Public holidays

For country- or region-wide closures (Christmas, Bavarian regional holidays, US Thanksgiving), use **holiday subscriptions** instead of creating time-off for every user every year:

```bash theme={null}
GET  /holidays/categories                  # list supported countries + regions
GET  /holidays/preview?categoryKey=DE      # preview which dates would be blocked
GET  /holidays/subscriptions?email=...     # list a user's current subscriptions
POST /holidays/subscriptions               # subscribe a user (or team) to a category
```

Subscriptions auto-roll forward each year — you subscribe once and the holidays keep coming. See the [Holidays API reference](/api/holidays/list-holiday-categories) for the full surface.

### Recurring weekly availability

For permanent working-hour changes (someone moves to a 4-day week, or starts an hour later on Wednesdays), update the user's **availability schedule** with `PATCH /schedules/{uuid}`. Use `weeklyHours` for the recurring pattern and `specialHours` for date-specific overrides. See [`PATCH /schedules/{uuid}`](/api/availability/update-availability-schedule-by-uuid).

Don't use `specialHours` to model vacations — that's what `/time-off` is for. `specialHours` is for *altering* the working day on a date (e.g. "I'm only available 09:00–11:00 on this date"); time-off is for *removing* it.

## Rate limits and error handling

* The bulk POST endpoint accepts up to **10 users per request** or **one team of up to 200 active members**. Chunk larger HRIS pulls into multiple requests.
* For `429 Too Many Requests` responses, follow the [exponential backoff guidance](/rate-limits) — 1s, 2s, 4s, … capped at 60s, with a small jitter.
* See [Errors](/errors) for the standard error envelope. Common time-off-specific cases:
  * `200` with `status: "failed"` — token can't edit that user's schedules. Don't retry; surface to a human.
  * `422 No updatable fields were provided` on PATCH — the request body was empty.
  * `422 This time-off period overlaps with an existing one` on PATCH — fall back to POST or pick a non-overlapping range.
  * `404 Time-off period not found` on PATCH/DELETE — the UUID was already deleted, or the token can't see it. Treat DELETE 404 as success; treat PATCH 404 as a stale map entry to be cleaned up.

<Tip>
  Run the sync on a schedule (hourly or every 15 minutes is typical), not in response to every HRIS webhook. Reconciliation is naturally idempotent and absorbs missed events; per-event delivery isn't worth the complexity for time-off.
</Tip>
