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"withrevokedAtandrevocationReason, 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.
| Endpoint | Scope | Description |
|---|---|---|
GET /v2/webhooks | webhooks:read | List webhooks. Supports limit (default 25, max 100) and nextToken. |
POST /v2/webhooks | webhooks:write | Create a webhook. Returns 201 with the signing secret, shown only once. A test event is sent right away. |
GET /v2/webhooks/{id} | webhooks:read | Get one webhook. |
PATCH /v2/webhooks/{id} | webhooks:write | Change url, events or description. |
DELETE /v2/webhooks/{id} | webhooks:write | Delete a webhook. Returns 204. |
POST /v2/webhooks/{id}/test | webhooks:write | Send a webhook.test event. Returns 202 with { "data": { "deliveryId": "..." } }. |
POST /v2/webhooks/{id}/rotate-secret | webhooks:write | Create a new signing secret. Returns the webhook with the new secret, shown only once. |
POST /v2/webhooks/{id}/pause | webhooks:write | Stop deliveries. |
POST /v2/webhooks/{id}/resume | webhooks:write | Restart 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:
| Field | Type | Description |
|---|---|---|
url | string | Required on create. The endpoint that receives deliveries. |
events | string[] | Required on create, at least one. Event names from Webhook events. |
description | string | Optional, up to 200 characters. |
A webhook object has these fields:
| Field | Type | Description |
|---|---|---|
webhookId | string | Webhook ID, starts with wh_ |
url | string | Endpoint URL |
description | string | Optional description |
events | string[] | Subscribed event names |
status | string | active, paused or revoked |
pausedReason | string | When paused: manual, delivery-failures or endpoint-gone |
pausedAt | string | ISO 8601 timestamp of the pause |
revokedAt | string | ISO 8601 timestamp of the automatic revocation |
revocationReason | string | Why the webhook was revoked |
ownerId | string | ID of the owning user |
secretPrefix | string | First characters of the current signing secret, for identification |
lastDelivery | object | The latest delivery: deliveryId, eventName, status, responseStatus, at |
createdAt | string | ISO 8601 creation timestamp |
updatedAt | string | ISO 8601 timestamp of the last change |
secret | string | Only 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
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON |
User-Agent | VacationTracker-Webhooks/2 | Identifies webhook deliveries |
VT-Signature | t=1774008000,v1=5f2b... | Timestamp and signature(s). See Verifying signatures. |
VT-Webhook-Id | wh_... | The webhook this delivery belongs to |
VT-Event-Id | evt_3f1c... | The event ID, same as id in the body. Use it to deduplicate. |
VT-Event-Type | leave.approved | The event name, same as type in the body |
VT-Delivery-Id | ... | The delivery ID. Stays the same across retries of this delivery. |
VT-Delivery-Attempt | 1 | Attempt 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"
}
}| Field | Type | Description |
|---|---|---|
id | string | Event ID, starts with evt_. The same event always has the same ID, including on retries. |
type | string | Event name, for example leave.approved |
apiVersion | string | Always "2" |
createdAt | string | ISO 8601 timestamp when the event was created |
companyId | string | Company ID |
data | object | The 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}. |
apiClient | object | Present 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>]tis the time the delivery attempt was signed, in seconds since the Unix epoch.- Each
v1is 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:
- Split the header on
,and each part on the first=. Readtand everyv1value, and ignore other keys. - Reject the request if
tis 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. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")as lowercase hex. - Accept the request if any
v1value 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 "", 200Delivery 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 attempt | What happens |
|---|---|
2xx | Delivered. |
Timeout, network error, 408, 429 or 5xx | Retried. |
410 Gone | Not 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.