Tell any tool when your team is off
Calm Leave emits signed webhooks on every PTO event and exposes a REST feed you can poll. One generic contract — use it to light up an OOO badge in Jira, grey out an avatar in Notion, skip a Linear assignee, or warn the roadmap-planner in Product.io.
Push and pull
Real-time signed webhooks on every lifecycle event, plus REST endpoints for backfill, reconciliation, and point-in-time queries.
HMAC-SHA256 signed
Every webhook carries an X-CalmLeave-Signature header computed over timestamp.body with your integration's secret. Replay-protected via timestamp window.
Privacy-first by default
Minimal payload — email, dates, granularity, isPaid. No leave type names, no notes, no attachments. Per-user GDPR opt-out supported.
Overview
How it works
Calm Leave uses a transactional outbox: the moment a leave state changes, we atomically write a WebhookDelivery row and kick off delivery. Failed attempts are retried on backoff. Partners dedupe byX-CalmLeave-Event-Idand apply per-leave ordering via a monotonicsequenceinteger.
┌────────────────┐
│ Calm Leave │ leave.approved
│ state change │ ─────────────────► [ WebhookDelivery row ]
└────────────────┘ (outbox, atomic with DB commit)
│
▼
┌────────────────────────────────────────┐
│ HMAC-signed POST to your webhook URL │
│ User-Agent: CalmLeave-Webhooks/1.0 │
└────────────────────────────────────────┘
│
▼
Jira · Notion · Linear · Product.io · …Four steps
Quick start
- 1
Create an Integration App
A tenant admin on a STUDIO or higher plan navigates to
Admin → Integrations → Create integration, enters a name, your webhook URL (https only), and the events to subscribe to. They receive an API key and signing secret, shown once. - 2
Store the credentials
Keep the API key and signing secret in your secrets manager. If either leaks, the admin can rotate them from the same page. Signing-secret rotation supports a 24-hour overlap so you can roll verifiers without downtime.
- 3
Receive and verify a webhook
Your endpoint must respond with any 2xx within 10 seconds. Before trusting the payload, verify the HMAC signature and reject anything older than 5 minutes.
import crypto from 'crypto'; import type { Request, Response } from 'express'; const MAX_SKEW_SECONDS = 5 * 60; export function verifyCalmLeaveWebhook(req: Request, res: Response, next: () => void) { const rawBody: string = (req as any).rawBody; // capture with express.raw(), not .json() const timestamp = req.header('X-CalmLeave-Timestamp'); const signature = req.header('X-CalmLeave-Signature'); if (!timestamp || !signature) return res.status(401).end(); const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(ageSec) || ageSec > MAX_SKEW_SECONDS) return res.status(401).end(); const expected = 'v1=' + crypto .createHmac('sha256', process.env.CALM_LEAVE_SIGNING_SECRET!) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(signature); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end(); next(); } - 4
Map users and render OOO state
On install, call
GET /usersonce and cache theemail → calmLeaveUserIdmapping. At runtime, update your UI wheneverleave.approved,leave.updated, orleave.cancelledarrives. Reconcile once a day viaGET /leave/currentwithIf-Modified-Since.
Credentials
Authentication
API key
For inbound polling requests from your side
Format clv_live_<token>. Sent on every polling request:
Authorization: Bearer clv_live_…Hashed at rest (bcrypt cost 12). Only the last 4 characters are shown in the admin UI for identification after creation.
Signing secret
For outbound webhooks from our side
Format whsec_<token>. Used to compute the HMAC-SHA256 signature on each webhook. Never sent over the wire by Calm Leave — it lives in your secrets store and ours.
Rotation writes a new secret and keeps the previous one valid for 24 hours. During that window, partners who have both configured can verify with either — deploy your rotation on your own schedule.
Push
Webhooks
Request shape
POST <your-webhookUrl>
Content-Type: application/json
User-Agent: CalmLeave-Webhooks/1.0
X-CalmLeave-Event-Id: 01HGKZ8X… (UUID, stable across retries)
X-CalmLeave-Event-Type: leave.approved
X-CalmLeave-Timestamp: 1714800000 (unix seconds)
X-CalmLeave-Signature: v1=<hex-hmac-sha256 of "timestamp.body">
{
"id": "01HGKZ8X...",
"type": "leave.approved",
"sequence": 1,
"createdAt": "2026-04-24T10:15:00.000Z",
"tenant": {
"id": "clt_abc123",
"name": "Acme Corp",
"timezone": "Asia/Tokyo"
},
"user": {
"id": "clu_xyz789",
"email": "[email protected]",
"name": "Jane Doe",
"timezone": "Asia/Tokyo"
},
"leave": {
"id": "clr_def456",
"status": "APPROVED",
"startDate": "2026-05-01",
"endDate": "2026-05-05",
"startAt": "2026-05-01T00:00:00+09:00",
"endAt": "2026-05-05T23:59:59+09:00",
"granularity": "FULL_DAY",
"durationDays": 5,
"isPaid": true
}
}leave.updated adds previousLeave
When an approved leave shifts dates, partners get both the new and previous state in one event — no need to re-query to diff.
{
"id": "01HGKZX...",
"type": "leave.updated",
"sequence": 2,
"createdAt": "2026-04-25T09:00:00.000Z",
"tenant": { "id": "clt_abc123", "name": "Acme Corp", "timezone": "Asia/Tokyo" },
"user": { "id": "clu_xyz789", "email": "[email protected]", "name": "Jane Doe", "timezone": "Asia/Tokyo" },
"leave": {
"id": "clr_def456", "status": "APPROVED",
"startDate": "2026-05-02", "endDate": "2026-05-06",
"startAt": "2026-05-02T00:00:00+09:00",
"endAt": "2026-05-06T23:59:59+09:00",
"granularity": "FULL_DAY", "durationDays": 5, "isPaid": true
},
"previousLeave": {
"startDate": "2026-05-01", "endDate": "2026-05-05",
"startAt": "2026-05-01T00:00:00+09:00",
"endAt": "2026-05-05T23:59:59+09:00",
"granularity": "FULL_DAY", "durationDays": 5
}
}Verifying signatures
Compute the HMAC over the raw request body bytes as received — re-serializing the JSON will change the signature.
import crypto from 'crypto';
import type { Request, Response } from 'express';
const MAX_SKEW_SECONDS = 5 * 60;
export function verifyCalmLeaveWebhook(req: Request, res: Response, next: () => void) {
const rawBody: string = (req as any).rawBody; // capture with express.raw(), not .json()
const timestamp = req.header('X-CalmLeave-Timestamp');
const signature = req.header('X-CalmLeave-Signature');
if (!timestamp || !signature) return res.status(401).end();
const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSec) || ageSec > MAX_SKEW_SECONDS) return res.status(401).end();
const expected =
'v1=' +
crypto
.createHmac('sha256', process.env.CALM_LEAVE_SIGNING_SECRET!)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end();
next();
}Delivery guarantees
- Idempotency. Retries reuse the same
X-CalmLeave-Event-Id. Partners dedupe on it. - Per-leave ordering. Every event carries a
sequenceinteger that is monotonically increasing perleave.id. Discard events wheresequence ≤ last_seenfor that leave. - Retry policy. 5 attempts at 1 min, 5 min, 30 min, 2 h, 12 h. After 5 failures the delivery is marked permanent and surfaced to the tenant admin; further events still deliver.
- Response SLA. Any 2xx within 10 seconds is a success. Non-2xx or timeout triggers retry.
Reference
Event types
| type | trigger |
|---|---|
leave.submitted | A new leave request enters PENDING. |
leave.approved | The request becomes APPROVED (last approval task completes). |
leave.updated | An APPROVED request's dates or granularity change. Payload carries previousLeave. |
leave.cancelled | An APPROVED request is cancelled. |
leave.rejected | A PENDING request is rejected. |
leave.withdrawn | A PENDING request is withdrawn by the employee. |
integration.disabled | One-shot system event emitted when the tenant loses access (plan downgrade). |
Partners subscribe to the subset they care about. Task-management tools typically need approved, updated, cancelled; HR-adjacent tools often want all six lifecycle events.
Pull
Polling endpoints
Base URL https://calmleave.com/api/v1/integrations/. The tenant is identified by your API key — the same base URL works for every tenant. Auth is Authorization: Bearer ….
| method | path | purpose |
|---|---|---|
GET | /users | Paginated list of active users — id, email, name, timezone. Use for initial email→id mapping. |
GET | /leave/current?at=YYYY-MM-DD | Everyone on APPROVED leave on that date. Supports ETag / If-Modified-Since (→ 304). |
GET | /leave/upcoming?from=YYYY-MM-DD&to=YYYY-MM-DD | Everyone overlapping the range. Primary sprint-planning query. |
GET | /leave/events?since=<iso>&cursor=<opaque>&limit=50 | Chronological event feed for catch-up / backfill. Same shape as webhooks. |
GET | /leave/by-email/{email}?from=…&to=… | All APPROVED leaves for one user in a date range. |
Example
$ curl -H "Authorization: Bearer clv_live_..." \
"https://calmleave.com/api/v1/integrations/leave/current?at=2026-05-02"
HTTP/1.1 200 OK
ETag: W/"a1b2c3d4e5f6..."
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1714800060
Content-Type: application/json
{
"data": [
{
"id": "current:clr_def456",
"type": "leave.approved",
"sequence": 3,
...
}
],
"nextCursor": null,
"hasMore": false
}Pagination
Cursor-based, opaque base64
All list endpoints use ?cursor=<opaque>&limit=<int>. Default limit is 50, max 200. Responses follow:
{ "data": [...], "nextCursor": "eyJ...", "hasMore": true }Rate limits
60 requests / minute per API key
Every response includes:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1714800060
Retry-After: 12 (429 only)Recommended cadence: /leave/current at most once per hour for reconciliation. Use webhooks for realtime.
Conditional requests
/leave/current and /leave/upcoming return an ETag and honor If-None-Match with a 304 Not Modified — daily reconciliation polls with no changes cost you near-zero bandwidth.
By default
Privacy and consent
What we never send in v1
- Leave type name (no distinction between “Sick Leave” and “Vacation”)
- Request notes or descriptions
- Attachments or URLs to them
- Manager or approver identities
- Balance or accrual data
Per-user opt-out
GDPR lawful-basis support
A tenant admin can mark any individual as excluded from integration sync. That user’s leave events never appear in webhooks or polling responses — across all of that tenant’s integrations.
Partners don’t see who is excluded — excluded users simply go silent. We recommend treating an OOO state as stale after 72 hours without a refresh.
Lifecycle
Plan changes and disabled integrations
If a tenant downgrades to a plan that doesn’t include integrations, their Integration Apps are automatically set to DISABLED. Partners find out two ways:
1. One-shot webhook
Sent before the disable takes effect
{
"id": "01HGKZY...",
"type": "integration.disabled",
"createdAt": "2026-04-24T10:15:00.000Z",
"tenant": { "id": "clt_abc123", "name": "Acme" },
"integration": { "id": "cli_jkl321", "name": "Jira — Engineering" },
"reason": "plan_downgraded",
"effectiveAt": "2026-04-24T10:15:00.000Z",
"recoveryHint": "Upgrade to STUDIO or higher to resume deliveries."
}2. Polling returns 403
Structured body so you can surface the state
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "plan_downgraded",
"message": "This integration is paused because the tenant is on a plan that does not include integrations.",
"resumableBy": "tenant_admin_upgrade"
}Integration App records, subscribed events, and secrets are preserved for 30 days after disable — a quick re-upgrade transparently resumes delivery without re-issuing credentials.
Stability
Versioning and forward compatibility
- Breaking changes ship under a new major
User-Agent(CalmLeave-Webhooks/2.0) with a deprecation window. - Additive changes (new optional fields, new event types, new endpoints) may ship within v1 without notice. Partner code must ignore unknown fields and unknown event types.
- Hourly PTO is not currently emitted — it rounds to the enclosing day in v1. When we add it, it will be a new
granularityvalue plus precisestartAt/endAtfields — no breaking change to existing consumers.
Ready to connect?
Create a tenant, invite a few teammates, and head to Admin → Integrations.
Integrations are available on STUDIO and above. All plans include a free trial.