> ## 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 titleSize="h3">
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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>
  </Step>

  <Step title="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>
  </Step>

  <Step title="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.
  </Step>
</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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.