Authentication and API keys
Create scoped v2 API keys, and learn how the role ceiling, expiry, rotation and revocation work.
Every v2 request needs an API key in the Authorization header, as a bearer token. v2 keys start with vt_live_:
curl "https://api.vacationtracker.io/v2/leaves?startDate=2026-10-01&endDate=2026-10-31" \
-H "Authorization: Bearer vt_live_..."A missing or unknown key returns 401 UNAUTHORIZED. A revoked or expired key returns 403 KEY_REVOKED or 403 KEY_EXPIRED. See Errors.
v1 keys (sent in x-api-key) do not work on v2. Create a v2 key instead. See Migrating from v1.
Creating a key
In the dashboard, go to Add-ons → Open API and create a key. Only administrators can create keys today.
- Key name. Up to 64 letters, numbers, spaces,
_,.or-, unique in your workspace. Only administrators see it, in the list of keys and in company logs. - Integration name (optional). Same characters, up to 64. Everyone sees this name: a change made through the key is shown as "Name (through HR_BOT)" in the leave timeline, notifications and company logs, so choose one your colleagues recognize. Without it, changes show only the person's name, and company logs still show the key and its owner.
- What you plan to build (optional). A short description that helps us improve the API.
- Permissions. Start from a template (Read only, Leave management, Workspace setup, or Custom), then set each capability to No access, Read or Read & write. Read & write includes read, and also allows deleting.
- Sensitive actions. Approving and denying requests, and acting on behalf of other users, are separate checkboxes. Turn them on only when the integration needs them.
- Expiration. See Expiration.
The secret is shown once, right after you create the key. Store it in a secret manager. Vacation Tracker keeps only a hash, so a lost secret cannot be recovered: rotate the key to get a new one.
You can change the name, permissions and expiration of an active key later, within your role ceiling.
Scopes
Each capability has a <capability>:read scope and a <capability>:write scope. Write implies read.
| Capability | Scopes | Covers |
|---|---|---|
leaves | leaves:read, leaves:write | Leave requests |
toil | toil:read, toil:write | Time off in lieu requests |
balances | balances:read | Leave balances (read only) |
users | users:read, users:write | Users and their settings |
locations | locations:read, locations:write | Locations and work weeks |
departments | departments:read, departments:write | Departments |
leave-types | leave-types:read, leave-types:write | Leave types |
leave-policies | leave-policies:read, leave-policies:write | Leave policies per location |
holidays | holidays:read, holidays:write | Public holidays per location |
labels | labels:read, labels:write | Labels |
automations | automations:read, automations:write | Automations |
notifications | notifications:read, notifications:write | Notifications and scheduled reports |
webhooks | webhooks:read, webhooks:write | Webhooks |
Four special scopes cover the sensitive actions and the reasons people write on requests. No :write scope implies them:
| Scope | Grants | Also implies |
|---|---|---|
leaves:approve | Approve and deny leave and TOIL requests. | leaves:read, toil:read |
users:act-on-behalf | Send requests as another user with X-On-Behalf-Of. See Acting on behalf of users. | users:read |
leaves:read-reason | Read reason and statusReason: the reason a person wrote when requesting, and the one an approver wrote when deciding. Covers leaves and TOIL requests alike, as leaves:approve does. Without it both come back as null, and every other field is unchanged. Seeing a TOIL request at all still needs toil:read. | leaves:read |
leave-types:read-hidden | See the names of hidden leave types. Without it, a leave type hidden by its leave policy shows as "Hidden Leave Type", like in the dashboard: in the embedded leaveType.name of leaves (with expand=leaveType) and in the leaveTypeName of balances and quotas. leaveTypeId and every amount are kept. | leave-types:read |
A request that needs a scope the key does not have returns 403 INSUFFICIENT_SCOPE, with the missing scopes in details.requiredScopes. Scopes by endpoint lists what every endpoint needs, so you can pick the scopes before you write the integration.
Expanding a related resource
An expand value needs the read scope of the resource it embeds, on top of the scope the endpoint itself requires. Expanding is a shortcut for a second request, so it grants no access a direct read would not:
| Endpoint | expand | Also requires |
|---|---|---|
GET /v2/leaves, GET /v2/leaves/{id} | user | users:read |
approver | users:read | |
leaveType | leave-types:read | |
GET /v2/users, GET /v2/users/{id} | location | locations:read |
department | departments:read | |
holidays | holidays:read |
Without the scope the request fails, and details.expand names the values that caused it:
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"name": "InsufficientScope",
"description": "The API key does not have the scopes this request needs.",
"resolution": "Add the scopes listed in details.requiredScopes to the key in the Vacation Tracker dashboard.",
"requestId": "...",
"details": {
"requiredScopes": ["users:read", "leave-types:read"],
"expand": ["user", "leaveType"]
}
}
}Two ways out: add the listed scopes to the key in the dashboard, or drop those values from expand. The ID of a related resource is always returned, with or without the scope, so a key with only leaves:read can still match leaves to people by userId and approverId.
One case does not fail. expand=user and expand=approver embed a user that itself carries location and department, which you never asked for by name. A key without locations:read or departments:read keeps the request and loses those nested objects:
{
"id": "LR4",
"userId": "u1",
"user": {
"id": "u1",
"name": "Ada Lovelace",
"locationId": "loc-1",
"departmentId": "team-1",
"department": { "id": "team-1", "name": "Engineering", "isDefault": true }
}
}Here the key holds departments:read but not locations:read, so department is embedded and location is absent. locationId is still there.
Webhooks
A webhook carries the same two reasons in its payload, and is held to the scopes of the key that created it. A webhook created by a key without leaves:read-reason delivers leave.* and toil.* events with reason and statusReason as null.
The snapshot is re-taken whenever a key edits the webhook, so whoever points it at a destination is the one it answers to. Rotating a key or changing its scopes does not reach an existing webhook on its own: send any PATCH /v2/webhooks/{id} with that key to refresh it.
Webhooks created in the dashboard have no key behind them, so there are no scopes to follow and the reasons are always included. Their owner already reads reason and statusReason in Vacation Tracker, and which events reach the webhook at all is still decided by that person's role and data reach, so it never delivers a request its owner could not open in the app.
Role ceiling and data reach
A key never has more access than the person who created it. Scopes above the creator's role cannot be selected, and the key never sees more data than its owner can see.
| Owner role | Highest scopes | Data reach |
|---|---|---|
| Administrator | Every scope | The whole workspace |
| Location admin | Approver scopes, plus users:write, locations:read/write, departments:read/write, leave-policies:read/write, holidays:write, notifications:read/write | Users in the locations they manage |
| Approver | User scopes, plus leaves:approve, users:act-on-behalf, leave-types:read-hidden | Users in the departments they approve |
| User | leaves:read/write, leaves:read-reason, toil:read/write, balances:read, users:read, holidays:read, leave-types:read | Only themselves |
The key owner is always in their own data reach, even when their own department or location is not one they approve or manage. Substitute approver assignments do not widen a key. Resources outside the data reach behave as if they do not exist and return 404 RESOURCE_NOT_FOUND.
The data reach depends only on the owner's role, so a key can see less than its owner sees in Vacation Tracker. For example, a user's key reads only their own leaves, even though the user sees colleagues' approved leaves in the calendar. The Visibility add-on does not change the data reach: it is already limited to what the add-on allows.
Expiration
| Option | Key stops working |
|---|---|
| 30 days | 30 days after creation |
| 90 days | 90 days after creation |
| 1 year (default) | 1 year after creation |
| Never | Only when revoked |
An expired key returns KEY_EXPIRED. Create a new key to continue. Choose Never only for integrations you monitor closely.
Rotation
Rotating a key creates a new secret, shown once, and keeps the same key, name and permissions. Choose what happens to the previous secret:
| Option | Previous secret |
|---|---|
| 24 hours (recommended) | Keeps working for 24 hours, so you can deploy the new secret without downtime |
| 7 days | Keeps working for 7 days |
| Stop now | Stops working immediately. Use this when a secret has leaked |
Only one previous secret works at a time: rotating again ends any overlap still running. Revoked keys cannot be rotated.
Revocation
A revoked key stops working within a minute. Keys are revoked in two ways:
- Manually, by an administrator in the dashboard, with an optional note.
- Automatically, when the owner loses privileges the key relies on: they are no longer an administrator, they manage fewer locations or approve fewer departments, or they are deactivated or deleted. The owner's webhooks are revoked in the same way.
A request with a revoked key returns KEY_REVOKED. details.revocationCode says why the key was revoked and details.revocationReason explains it in words:
{
"error": {
"code": "KEY_REVOKED",
"name": "KeyRevoked",
"description": "The API key has been revoked.",
"resolution": "See details.revocationReason for why it was revoked, then ask a Vacation Tracker admin to create a new key.",
"requestId": "...",
"details": { "revocationCode": "...", "revocationReason": "..." }
}
}The same reason is shown on the key in the dashboard. Revoked keys stay in the key list, so administrators can see what happened. Related codes are KEY_OWNER_INACTIVE (the owner was deactivated or deleted) and KEY_SCOPES_EXCEED_ROLE (the owner's role no longer allows the key's scopes).