Vacation Tracker Docs
REST API v2

Webhooks

Receive signed HTTP notifications when data changes in Vacation Tracker, and verify, retry and deduplicate them correctly.

Webhooks send an HTTP POST to your endpoint when something changes in Vacation Tracker: a leave is approved, a user moves to another location, an API operation finishes, and so on. Every request is signed so you can check that it came from Vacation Tracker.

For the full list of event names and their data shapes, see Webhook events.

Zapier triggers are a separate feature with their own payloads. See Zapier if you use Vacation Tracker through Zapier. This page covers REST API v2 webhooks only.

Ownership and access

A webhook belongs to a user, not to an API key:

  • The user who creates a webhook (in the dashboard or with an API key they own) is its owner.
  • Revoking, rotating or deleting the API key that created a webhook does not affect the webhook. It keeps delivering.
  • If the owner loses privileges (for example, a role change, fewer managed locations or departments, or deactivation), their webhooks are revoked automatically. A revoked webhook shows status: "revoked" with revokedAt and revocationReason, and it can't be resumed. Create a new webhook with an owner who has the required access.
  • A webhook only delivers events its owner may see. When an event happens, Vacation Tracker checks the owner's current role and data reach: an approver's webhook receives leave events only for users in the departments they approve, a location admin's webhook only for their locations, and so on. Events outside that reach are not sent.
  • When you subscribe to events, each event must be allowed by the owner's role. When you create or update a webhook through the API, it must also be covered by the calling key's scopes.

Administrators see every webhook in the workspace. Other users see only their own.

Managing webhooks

In the dashboard

Open the Open API add-on page and go to Webhooks. From there you can add a webhook, pick events, see the delivery log, resend a delivery, send a test event, pause and resume the webhook, and rotate its signing secret.

With the API

The /v2/webhooks endpoints manage the webhooks of the key's owner. Reading needs the webhooks:read scope and changing needs webhooks:write.

EndpointScopeDescription
GET /v2/webhookswebhooks:readList webhooks. Supports limit (default 25, max 100) and nextToken.
POST /v2/webhookswebhooks:writeCreate a webhook. Returns 201 with the signing secret, shown only once. A test event is sent right away.
GET /v2/webhooks/{id}webhooks:readGet one webhook.
PATCH /v2/webhooks/{id}webhooks:writeChange url, events or description.
DELETE /v2/webhooks/{id}webhooks:writeDelete a webhook. Returns 204.
POST /v2/webhooks/{id}/testwebhooks:writeSend a webhook.test event. Returns 202 with { "data": { "deliveryId": "..." } }.
POST /v2/webhooks/{id}/rotate-secretwebhooks:writeCreate a new signing secret. Returns the webhook with the new secret, shown only once.
POST /v2/webhooks/{id}/pausewebhooks:writeStop deliveries.
POST /v2/webhooks/{id}/resumewebhooks:writeRestart deliveries and reset the failure streak.

Create a webhook:

curl https://api.vacationtracker.io/v2/webhooks \
  -H "Authorization: Bearer vt_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/vacation-tracker/webhooks",
    "events": ["leave.approved", "leave.cancelled"],
    "description": "HR system sync"
  }'

The request body accepts:

FieldTypeDescription
urlstringRequired on create. The endpoint that receives deliveries.
eventsstring[]Required on create, at least one. Event names from Webhook events.
descriptionstringOptional, up to 200 characters.

A webhook object has these fields:

FieldTypeDescription
webhookIdstringWebhook ID, starts with wh_
urlstringEndpoint URL
descriptionstringOptional description
eventsstring[]Subscribed event names
statusstringactive, paused or revoked
pausedReasonstringWhen paused: manual, delivery-failures or endpoint-gone
pausedAtstringISO 8601 timestamp of the pause
revokedAtstringISO 8601 timestamp of the automatic revocation
revocationReasonstringWhy the webhook was revoked
ownerIdstringID of the owning user
secretPrefixstringFirst characters of the current signing secret, for identification
lastDeliveryobjectThe latest delivery: deliveryId, eventName, status, responseStatus, at
createdAtstringISO 8601 creation timestamp
updatedAtstringISO 8601 timestamp of the last change
secretstringOnly in the create and rotate-secret responses. Starts with whsec_. Store it safely: it can't be read again.

Delivery request

Each delivery is an HTTP POST with a JSON body.

Headers

HeaderExampleDescription
Content-Typeapplication/jsonAlways JSON
User-AgentVacationTracker-Webhooks/2Identifies webhook deliveries
VT-Signaturet=1774008000,v1=5f2b...Timestamp and signature(s). See Verifying signatures.
VT-Webhook-Idwh_...The webhook this delivery belongs to
VT-Event-Idevt_3f1c...The event ID, same as id in the body. Use it to deduplicate.
VT-Event-Typeleave.approvedThe event name, same as type in the body
VT-Delivery-Id...The delivery ID. Stays the same across retries of this delivery.
VT-Delivery-Attempt1Attempt number, starting at 1

Body

{
  "id": "evt_3f1c9a7be2d04c5e8a61b0f2c4d7e9a1",
  "type": "leave.approved",
  "apiVersion": "2",
  "createdAt": "2026-03-20T12:00:00.000Z",
  "companyId": "company-1234-5678-abcd",
  "data": {
    "id": "leaveRequest-a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "userId": "user-abcd-1234-5678",
    "approverId": "user-f6a7-b8c9-d0e1",
    "autoApproved": false,
    "durationCalendarDays": 1,
    "durationWorkingDays": 1,
    "startDate": "2026-03-23",
    "endDate": "2026-03-23",
    "isFullDayLeave": true,
    "leaveTypeId": "leaveType-c3d4e5f6-a7b8-9012-cdef-123456789012",
    "locationId": "location-e5f6a7b8-c9d0-1234-efab-345678901234",
    "departmentId": "team-d4e5f6a7-b8c9-0123-defa-234567890123",
    "status": "APPROVED",
    "reason": "",
    "statusReason": null,
    "approvedLevel": "FIRST",
    "createdAt": "2026-03-20T11:58:12.000Z"
  },
  "apiClient": {
    "keyId": "pak_0b7c4e1a-2f3d-4c5b-9a8e-7d6f5e4c3b2a",
    "name": "HR_BOT",
    "ownerId": "user-1111-2222-3333"
  }
}
FieldTypeDescription
idstringEvent ID, starts with evt_. The same event always has the same ID, including on retries.
typestringEvent name, for example leave.approved
apiVersionstringAlways "2"
createdAtstringISO 8601 timestamp when the event was created
companyIdstringCompany ID
dataobjectThe changed resource in the same shape the v2 API returns, read after the change was saved. For operation.completed and operation.failed, this is the operation object from GET /v2/operations/{id}.
apiClientobjectPresent only when the change was made through the API: keyId, name (the key's integration name, absent when the key has none) and ownerId (the key's owner).

Webhooks are sent only for changes that were saved successfully, whether they were made in the dashboard, in Slack or Teams, or through the API.

Test events

A test event has the type webhook.test and no apiClient:

{
  "id": "evt_9d2e4c1b7a6f4e3d8c5b2a1f0e9d8c7b",
  "type": "webhook.test",
  "apiVersion": "2",
  "createdAt": "2026-03-20T12:00:00.000Z",
  "companyId": "company-1234-5678-abcd",
  "data": {
    "webhookId": "wh_5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716",
    "message": "This is a test event from Vacation Tracker."
  }
}

A test event is sent when you create a webhook, and whenever you call POST /v2/webhooks/{id}/test or click Send test event in the dashboard. Test events are delivered to paused webhooks too, are attempted only once, and don't affect the failure streak described below.

Verifying signatures

Always verify the signature before trusting a delivery.

The VT-Signature header has this format:

t=<unix timestamp in seconds>,v1=<signature>[,v1=<signature>]
  • t is the time the delivery attempt was signed, in seconds since the Unix epoch.
  • Each v1 is a lowercase hex HMAC-SHA256 signature.
  • The signed content is the timestamp, a dot, and the raw request body exactly as received: <t>.<body>.
  • The HMAC key is the whole signing secret string, including the whsec_ prefix, as UTF-8 bytes. Don't decode it.

To verify:

  1. Split the header on , and each part on the first =. Read t and every v1 value, and ignore other keys.
  2. Reject the request if t is more than 300 seconds away from your current time. This protects against replayed requests. Vacation Tracker uses 300 seconds as its own tolerance; you can choose a different value.
  3. Compute HMAC-SHA256(secret, "<t>.<raw body>") as lowercase hex.
  4. Accept the request if any v1 value matches, using a constant-time comparison.

Compute the signature over the raw body bytes. Parsing the JSON and serializing it again changes the bytes and breaks the signature.

Secret rotation

When you rotate the secret (POST /v2/webhooks/{id}/rotate-secret or in the dashboard), the previous secret keeps working for 24 hours. During that time, every delivery carries two v1 signatures: the first one made with the new secret, the second with the previous secret. Deploy the new secret to your endpoint within 24 hours. After that, only the new secret signs deliveries.

If your verifier accepts any matching v1 value, rotation needs no downtime.

Node.js

import crypto from 'node:crypto'
import express from 'express'

const TOLERANCE_SECONDS = 300
const secret = process.env.VT_WEBHOOK_SECRET // whsec_...

function verifySignature(header, rawBody, secret) {
  let timestamp
  const signatures = []
  for (const part of header.split(',')) {
    const separator = part.indexOf('=')
    if (separator === -1) return false
    const key = part.slice(0, separator).trim()
    const value = part.slice(separator + 1).trim()
    if (key === 't') {
      if (!/^\d+$/.test(value)) return false
      timestamp = Number(value)
    } else if (key === 'v1') {
      if (!/^[0-9a-f]{64}$/.test(value)) return false
      signatures.push(value)
    }
  }
  if (timestamp === undefined || signatures.length === 0) return false
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`, 'utf8')
    .update(rawBody)
    .digest()

  return signatures.some((signature) => {
    const received = Buffer.from(signature, 'hex')
    return received.length === expected.length && crypto.timingSafeEqual(received, expected)
  })
}

const app = express()

// express.raw keeps the body as a Buffer, exactly as it was signed.
app.post('/vacation-tracker/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifySignature(req.get('VT-Signature') ?? '', req.body, secret)) {
    return res.status(400).send('Invalid signature')
  }
  const event = JSON.parse(req.body.toString('utf8'))
  // Queue the event for processing, then answer quickly.
  res.sendStatus(200)
})

Python

import hashlib
import hmac
import json
import os
import re
import time

from flask import Flask, request

TOLERANCE_SECONDS = 300
SECRET = os.environ["VT_WEBHOOK_SECRET"]  # whsec_...
HEX_SIGNATURE = re.compile(r"[0-9a-f]{64}")


def verify_signature(header: str, raw_body: bytes, secret: str) -> bool:
    timestamp = None
    signatures = []
    for part in header.split(","):
        if "=" not in part:
            return False
        key, value = part.split("=", 1)
        key, value = key.strip(), value.strip()
        if key == "t":
            if not re.fullmatch(r"[0-9]+", value):
                return False
            timestamp = int(value)
        elif key == "v1":
            if not HEX_SIGNATURE.fullmatch(value):
                return False
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    signed_content = f"{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_content, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(signature, expected) for signature in signatures)


app = Flask(__name__)


@app.post("/vacation-tracker/webhooks")
def receive_webhook():
    raw_body = request.get_data()  # raw bytes, exactly as signed
    if not verify_signature(request.headers.get("VT-Signature", ""), raw_body, SECRET):
        return "Invalid signature", 400
    event = json.loads(raw_body)
    # Queue the event for processing, then answer quickly.
    return "", 200

Delivery behavior

Responding

Respond with any 2xx status within 10 seconds. Anything else counts as a failed attempt. Do the real work after you respond, for example by putting the event on a queue.

Redirects are not followed: a 3xx response counts as a failure.

Retries

Result of the attemptWhat happens
2xxDelivered.
Timeout, network error, 408, 429 or 5xxRetried.
410 GoneNot retried. The webhook is paused right away with reason endpoint-gone, and the owner gets an email.
Any other status (other 4xx, 3xx)Not retried. The delivery is marked as failed.

A delivery is attempted up to 11 times over about 3 days. Retries wait 1 minute, 10 minutes, 50 minutes, 3 hours, 6 hours, and then 12 hours five times, so the last attempt happens about 70 hours after the first. The VT-Delivery-Attempt header tells you which attempt you are receiving.

Automatic pause

A webhook starts a failure streak when a delivery fails for good (after its last retry, or on a status that isn't retried). Any successful delivery ends the streak. If a delivery fails for good and the streak started 72 hours or more earlier, the webhook is paused with reason delivery-failures, and the owner gets an email with a link to the webhook in the dashboard. Because every delivery is retried for about 3 days first, an endpoint that is down the whole time is paused after about 6 days.

While a webhook is paused, new events are not delivered to it and are not queued for later. Fix the endpoint, then resume the webhook with POST /v2/webhooks/{id}/resume or in the dashboard. Resuming resets the failure streak.

At-least-once delivery

Deliveries are at least once: the same event can arrive more than once, for example after a retry when your endpoint processed the request but the response was lost. Deduplicate on the event id (also in the VT-Event-Id header): store the IDs you have processed and skip ones you have already seen. Events can also arrive out of order, so use the data in the event, or read the resource from the API, rather than relying on arrival order.

Delivery log

The webhook details page in the dashboard shows the deliveries from the last 30 days, with the attempts and response status of each. From there you can resend a delivery or send a test event.

On this page