Vacation TrackerDeveloper Docs
Guides

How to send approved leave to an HRIS or payroll system

Send every approved leave and PTO day from Vacation Tracker to your HRIS or payroll system, including edits and cancellations, with the REST API and webhooks or Zapier.

Payroll runs on the 25th. The day before, someone opens Vacation Tracker, lists the approved leave for the month, and types it into the payroll system by hand. A week earlier, an employee cancelled two days of vacation and came to work instead. The cancellation was in Vacation Tracker, but not in the copy, so payroll paid those two days as vacation and someone has to correct it next month.

Vacation Tracker is where leave is requested, approved and counted, so it is the source of truth for who is off and when. Your HRIS or payroll system only needs the result: every approved leave, and every later change to it. This guide shows how to keep that copy correct automatically, including the edits, cancellations and deletions that are easy to miss.

Short answer: subscribe a webhook to the leave.* events, then load existing approved leave once with GET /v2/leaves. On every event, read the leave again with GET /v2/leaves/{id}: if it is APPROVED, create or update it in your payroll system by its id; otherwise remove it. Run the load again after any downtime.

Give this to your agent

ClaudeOpenAICursorGitHub Copilotor any coding agent
Here's how to send approved leave from Vacation Tracker to an HRIS or payroll system with the Vacation Tracker API v2:
- Vacation Tracker is the source of truth. The other system keeps a copy of approved leave only, keyed by the leave id.
- Use an administrator's key with leaves:read and users:read, and a webhook owned by that administrator, subscribed to the leave.* events.
- Create the webhook first, then load existing approved leave with GET /v2/leaves (startDate and endDate are required). Follow nextToken until it is missing, even when a page is empty.
- On every event: verify the signature, answer 200 within 10 seconds, and skip events whose id you already handled. Then read the leave with GET /v2/leaves/{id}: upsert it if it is APPROVED, remove it otherwise. Remove it on leave.deleted.
- Don't trust event order or type: approved leave can be edited back to OPEN, cancelled or deleted, also after payroll closed.
- Handle events for the same leave one at a time (for example a lock per leave id around the read and the write), including the backfill, so a slow worker can't restore a cancelled leave.
- Match people by employeeId (or email), using expand=user, and map leaveTypeId to the payroll's pay codes.
- A paused webhook does not deliver missed events later. Watch its status, and run the load again for the gap.
- Keep the API key and webhook secret in configuration or secrets, never in code.

Full guide: https://vacationtracker.io/developers/llms.mdx/guides.sync-leave-to-hris.md
API v2 docs: https://vacationtracker.io/developers/llms.txt

Unless I already told you what to build in this session, read the documentation and ask me what to do next.

To send remaining balances as well, see Sync leave balances.

How it works

A sync has two parts:

  • A backfill loads approved leave for a date range. You run it once at the start, and again whenever you need to catch up.
  • Webhooks tell you about every change after that.

When an event arrives, the receiver does not trust the event type or the order events arrive in. It reads the leave's current state and makes your system match it: approved leave is upserted by its id, anything else is removed.

The backfill reads approved leave with GET /v2/leaves and upserts each one by leave id. Every leave.* webhook makes your receiver read the current leave with GET /v2/leaves/{id}: if it is approved it is upserted, otherwise it is removed. Both paths end in the HRIS or payroll system.

Choose a tool

You want toUse
A reliable sync that also loads past leave and survives downtimeREST API v2 and webhooks
Apply your own logic without coding, sending new leave from now on to an HRIS that has a Zapier appZapier
Check a few leaves by hand, for example "who was off in March?"MCP server, by asking your AI assistant

What changes an approved leave

  • Edits. When a user edits their own approved leave, it goes back to OPEN for approval, and you get leave.updated with that status. It comes back as leave.approved, or ends as leave.denied or leave.expired. When an administrator, the user's approver or their location admin makes the edit, the leave stays APPROVED. The receiver in this guide removes the leave while it waits for approval. If your payroll would rather keep the last approved version until the decision, store it and replace it only on the next approved state.
  • Cancellations and deletions of past leave. A leave can be cancelled or deleted after it was taken, for example when someone came to work after all. If the payroll period is already closed, your system has to record a correction.
  • Leave type changes. leave.updated also covers a change of leave type, so map the pay code again on every upsert.

With the REST API

This example builds the sync with the REST API v2 and webhooks.

1. Create an API key and a webhook

In the dashboard, go to Add-ons → Open API and create a key with these scopes (see Authentication):

ScopeWhy
leaves:readRead leaves
users:readMatch people to employees in your system
leave-types:readMap leave types to your pay codes
webhooks:read, webhooks:writeOnly if you create the webhook with the API instead of the dashboard

Use an administrator as the owner of the key and the webhook. Both see only the people their owner can see: an approver's webhook gets events only for the departments they approve. A webhook is revoked if its owner loses the access it needs, so pick an owner who will stay an administrator.

Then create a webhook on the Webhooks tab of the same page, or with POST /v2/webhooks, subscribed to these events:

EventWhy it matters
leave.addedA manager added an approved leave, or a request was approved automatically
leave.approvedA request was approved. With multi-level approval, it is sent once, after the last level.
leave.updatedDates, times or leave type changed. The leave may no longer be approved: see What changes an approved leave.
leave.deniedA request was denied, including an edited leave that was sent back for approval
leave.cancelledAn open or approved leave was cancelled, also after it was taken
leave.deletedA manager deleted a leave. data has only id and userId.
leave.expiredA request expired without a decision, including an edited leave that was waiting for re-approval

Store the signing secret: it is shown once.

2. Map people and leave types

Match each Vacation Tracker user to an employee in your system once, and store the pair:

  • employeeId is the best key when you use it. Admins set it on the user's profile page, or with PATCH /v2/users/{id}.
  • email works when both systems use the same work email.

GET /v2/users returns both. To read them together with the leave, add expand=user to the leave requests below.

Map each leaveTypeId to a pay code or absence type in your system with GET /v2/leave-types. Use the ID, not the name: names can change.

3. Backfill approved leave

Start the webhook before the backfill. A change made while the backfill runs then reaches you as an event, and the upsert by id makes the overlap harmless.

GET /v2/leaves returns the leaves that overlap a date range, APPROVED by default:

curl --get "https://api.vacationtracker.io/v2/leaves" \
  --data-urlencode "startDate=2026-01-01" \
  --data-urlencode "endDate=2026-12-31" \
  --data-urlencode "limit=1000" \
  --data-urlencode "expand=user" \
  -H "Authorization: Bearer vt_live_..."

Page with nextToken until it is missing, even when a page is short or empty (see Pagination), and upsert every leave by its id.

4. Handle each event

Every event carries the leave in data, in the same shape the API returns. For each event:

  1. Verify the signature, answer 200 within 10 seconds, and do the rest afterwards.
  2. Skip the event if you have already processed its id. Deliveries are at least once, so the same event can arrive twice.
  3. For leave.deleted, remove the leave.
  4. For any other event, read the leave again with GET /v2/leaves/{id}. If its status is APPROVED, upsert it. Otherwise (or on 404), remove it.

Reading the leave again costs one request per event, and it makes the result correct even when two events for the same leave arrive out of order.

Handle one leave at a time. If two workers process two events for the same leave in parallel, a slow worker that read APPROVED can write after a fast one already removed the cancelled leave, and the absence comes back. Process events for the same leave one after another, for example with a lock per leave id around the read and the write, or by sending all events for one leave to the same worker. Run the backfill through the same path.

const API = 'https://api.vacationtracker.io/v2'
const headers = { Authorization: `Bearer ${process.env.VT_API_KEY}` }

// Called after the signature check and the 200 response.
async function handleEvent(event: { id: string; type: string; data: { id: string } }) {
  if (await processedEvents.has(event.id)) return

  if (event.type === 'leave.deleted') {
    await hris.removeLeave(event.data.id)
  } else if (event.type.startsWith('leave.')) {
    const response = await requestWithRetry(`${API}/leaves/${event.data.id}?expand=user`, { headers })
    if (response.status !== 404 && !response.ok) {
      // Don't remove the leave on a temporary error: fail, so your queue retries the event.
      throw new Error(`Reading leave ${event.data.id} failed with ${response.status}`)
    }
    const leave = response.status === 404 ? null : (await response.json()).data
    if (leave?.status === 'APPROVED') {
      await hris.upsertLeave(leave)
    } else {
      await hris.removeLeave(event.data.id)
    }
  }

  await processedEvents.add(event.id)
}

requestWithRetry is the helper from Rate limits.

import os
import random
import time

import requests

API = "https://api.vacationtracker.io/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['VT_API_KEY']}"}


def get_with_retry(url, params=None, max_attempts=5):
    for attempt in range(max_attempts):
        response = requests.get(url, headers=HEADERS, params=params, timeout=30)
        if response.status_code != 429 or attempt == max_attempts - 1:
            return response
        if response.json().get("error", {}).get("code") == "QUOTA_EXCEEDED":
            return response
        time.sleep(random.uniform(0, min(30, 0.5 * 2**attempt)))


# Called after the signature check and the 200 response.
def handle_event(event):
    if processed_events.has(event["id"]):
        return

    leave_id = event["data"]["id"]
    if event["type"] == "leave.deleted":
        hris.remove_leave(leave_id)
    elif event["type"].startswith("leave."):
        response = get_with_retry(f"{API}/leaves/{leave_id}", params={"expand": "user"})
        if response.status_code != 404:
            # Don't remove the leave on a temporary error: fail, so your queue retries the event.
            response.raise_for_status()
        leave = None if response.status_code == 404 else response.json()["data"]
        if leave and leave["status"] == "APPROVED":
            hris.upsert_leave(leave)
        else:
            hris.remove_leave(leave_id)

    processed_events.add(event["id"])

get_with_retry retries 429 with exponential backoff and jitter, and stops on QUOTA_EXCEEDED, like the helper in Rate limits.

In both, processedEvents (processed_events) and hris stand for your own storage and your HRIS or payroll client.

A key can send 10 requests per second and 30,000 requests per month. The backfill costs one request per page of up to 1,000 leaves, and the receiver costs one request per event. See Rate limits.

5. Read durations

FieldUse
startDate, endDateThe first and last day, in YYYY-MM-DD. Both are inclusive.
durationWorkingDaysWorking days the leave takes. A fraction for part-day leave, for example 0.5.
durationCalendarDaysCalendar days, weekends included
isFullDayLeavefalse for half-day and hourly leave
startHour, startMinute, endHour, endMinuteThe times of a part-day leave, in the person's time zone. Only when isFullDayLeave is false.

For a payroll that counts hours, use the times of a part-day leave, and the person's working hours for full days. GET /v2/locations/{id}/work-week returns the working hours of a location.

6. Catch up after downtime

Each delivery is retried for about 3 days. If your endpoint keeps failing, the webhook is paused, its owner gets an email, and events from the pause onwards are not delivered later. Watch the webhook's status and lastDelivery (from GET /v2/webhooks/{id}), not only your endpoint.

To recover:

  1. Fix the endpoint, then resume the webhook in the dashboard or with POST /v2/webhooks/{id}/resume.
  2. Run the backfill again over every period that may have changed, for example the open payroll period and the months ahead. Upsert the approved leaves, and remove the leaves you have that are no longer in the APPROVED list.

For a few missed events, the delivery log on the webhook's page in the dashboard lets you resend deliveries from the last 30 days.

The startDate and endDate filters match the dates of the leave, not when it was changed. That is why the catch-up compares whole periods instead of fetching "changes since".

With Zapier

Vacation Tracker's Zapier app sends leave events to any of the 5,000+ apps on Zapier, including HRIS and payroll tools with a Zapier app. Each Zap listens to one trigger, so a sync needs several Zaps:

TriggerAction in your HRIS app
Leave Request ApprovedCreate the absence
New Leave AddedCreate the absence (a manager added an approved leave)
Leave UpdatedUpdate the absence when status is APPROVED, otherwise remove it (use a Paths or Filter step)
Leave CancelledRemove the absence
Leave DeletedRemove the absence

Each payload includes userEmail, leaveTypeName, workingDays and the hours of each day in daysList, which most HRIS apps can map directly. Use the leave id to find the absence again when it is updated, cancelled or deleted. See Triggers and Webhook payloads.

Zapier fits small teams and simple flows. Keep its limits in mind:

  • It sends new events only. There is no backfill of past leave, and nothing to catch up after a Zap was off.
  • A delivery that fails is not retried, so a missed event stays missed.

For a sync that payroll depends on, use the REST API and webhooks.

With an AI assistant (MCP)

To check a few leaves by hand, ask your AI assistant through the MCP server, for example "who was off in March?". It answers questions; it does not keep two systems in step.

Summary

  • Vacation Tracker is the source of truth for leave. Your HRIS or payroll system keeps a copy of approved leave only.
  • Start the webhook first, then backfill with GET /v2/leaves, and upsert every leave by its id.
  • On each event, read the leave again: upsert it if it is APPROVED, remove it otherwise. Remove it on leave.deleted.
  • Edits can send an approved leave back to OPEN, and past leave can be cancelled or deleted after payroll closed.
  • A paused webhook does not deliver missed events later: watch its status, and run the backfill again after downtime.
  • Zapier suits simple flows, but has no backfill and no retries.

Frequently asked questions

How does PTO get into payroll?

Vacation Tracker sends a webhook when leave changes, and your sync writes approved leave into the payroll system, matched to the employee by employeeId or email. Payroll then pays it as PTO like any other entry.

What happens when an employee cancels approved leave?

Vacation Tracker sends leave.cancelled. Your sync reads the leave, sees it is no longer APPROVED, and removes it from payroll. If payroll for that period is already closed, correct it in the next run.

Can an approved leave change after it is approved?

Yes. It can be edited, cancelled or deleted, and when someone edits their own approved leave it goes back to OPEN for approval. That is why the sync reads the current leave on every event instead of trusting the event type.

Do I need to code this, or can I use Zapier?

Zapier works for simple flows with its leave triggers. It cannot load existing leave and has no retries, so use the API when payroll must be exact.

On this page