Skip to main content

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.

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.
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).

Concepts

Three different objects affect a user’s availability. Pick the right one for the job:
ObjectWhat it isWhen 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.
FieldTypeNotes
typevacation | out_of_officeRequired.
titlestring (≤255)Required on create.
startDate / endDatedate (YYYY-MM-DD)Required. Inclusive on both ends.
startHalfDay / endHalfDaybooleanDefault false.
startHalfDayCutoff / endHalfDayCutoffHH:MMRequired when the matching half-day flag is true.
notestring (≤1000), nullableFree-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

1
Map HRIS users to Zeeg users
2
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.
3
cURL
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
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
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;
}
4
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.
5
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.
6
Create time-off periods
7
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.
8
Provide exactly one of users or team. Inside users, provide exactly one of emails, slugs, or uuids.
9
Example: Alice is on vacation Mon–Fri next week.
10
cURL
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
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
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();
11
The response is a per-target outcome list. Each entry’s status is created, replaced, or failed — handle all three:
12
{
  "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": []
    }
  ]
}
13
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.
14
Half-day example. Alice leaves at 13:00 on Friday afternoon:
15
{
  "users": { "emails": ["alice.weber@horizondigital.de"] },
  "type": "vacation",
  "title": "Long weekend",
  "startDate": "2026-05-15",
  "endDate": "2026-05-15",
  "startHalfDay": true,
  "startHalfDayCutoff": "13:00"
}
16
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.
17
Update or delete when the HRIS record changes
18
PATCH /time-off/{uuid} partially updates a period. Only supplied fields change; omitted fields are left alone.
19
cURL
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
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
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",
  }),
});
20
DELETE /time-off/{uuid} removes a period:
21
curl -X DELETE "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
22
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.
23
Solve the dedup / idempotency problem
24
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:
25
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.
26
{
  "users": [
    {
      "email": "alice.weber@horizondigital.de",
      "slug": "alice-weber",
      "uuid": "AliceK2QwR9XPv",
      "status": "replaced",
      "period": { "uuid": "01907f10-...new-uuid", "...": "..." },
      "replaced": ["01907f10-...old-uuid"]
    }
  ]
}
27
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:
28
  • 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).
  • 29
    So the canonical pattern is: keep a persistent map (hris_record_idzeeg_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.
    30
    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.
    31
    Reconcile: a worked sync loop
    32
    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.
    33
    # 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]
    
    34
    A few things this loop intentionally handles:
    35
  • 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.
  • 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:
    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 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}. 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 — 1s, 2s, 4s, … capped at 60s, with a small jitter.
    • See 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.
    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.
    Last modified on May 7, 2026