Integrations API · v1

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. 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. 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. 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. 4

    Map users and render OOO state

    On install, call GET /users once and cache the email → calmLeaveUserId mapping. At runtime, update your UI whenever leave.approved, leave.updated, or leave.cancelled arrives. Reconcile once a day via GET /leave/current with If-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.

Node.js / TypeScript
Python
Go
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 sequence integer that is monotonically increasing per leave.id. Discard events where sequence ≤ last_seen for 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

typetrigger
leave.submittedA new leave request enters PENDING.
leave.approvedThe request becomes APPROVED (last approval task completes).
leave.updatedAn APPROVED request's dates or granularity change. Payload carries previousLeave.
leave.cancelledAn APPROVED request is cancelled.
leave.rejectedA PENDING request is rejected.
leave.withdrawnA PENDING request is withdrawn by the employee.
integration.disabledOne-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 ….

methodpathpurpose
GET/usersPaginated list of active users — id, email, name, timezone. Use for initial email→id mapping.
GET/leave/current?at=YYYY-MM-DDEveryone on APPROVED leave on that date. Supports ETag / If-Modified-Since (→ 304).
GET/leave/upcoming?from=YYYY-MM-DD&to=YYYY-MM-DDEveryone overlapping the range. Primary sprint-planning query.
GET/leave/events?since=<iso>&cursor=<opaque>&limit=50Chronological 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 granularity value plus precise startAt/endAt fields — 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.