HollyHR Developer Docs
  • HollyHR
  • Sign in
  • Manage API keys
  • Start Here
  • Core API
  • AI and MCP
  • API Reference
  • Recipes
  • Resources
Recipe indexGitHub Actions recipesMCP smoke testSafe MCP leave bookingPeople syncTest SDK from sourceSlack who's awayGoogle Sheets exportReceive webhooks in NodeCreate person + webhookWebhook + payroll referencesPayroll readiness export
Recipes

GitHub Actions recipes

Use these workflows when you want a repeatable scheduled job without running a server. Keep HollyHR tokens in GitHub Actions secrets, run the workflows in a private repository when they emit HR data, and start with read-only scopes.

Recommended repository secrets:

SecretUsed for
HOLLYHR_API_BASE_URLTenant API base URL, for example https://acme.hollyhr.com/api/v1.
HOLLYHR_API_TOKENScoped HollyHR API key. Start with read-only scopes.
HOLLYHR_MCP_URLTenant MCP URL, for example https://acme.hollyhr.com/api/mcp.
SLACK_WEBHOOK_URLSlack incoming webhook for the digest recipe.

SDK first-call smoke

This workflow installs the public SDK and runs the packaged first-call example. Use it as a scheduled confidence check that the key, base URL, network path, and people read scope still work.

Code
name: HollyHR API smoke on: workflow_dispatch: schedule: - cron: "15 8 * * 1-5" jobs: first-call: runs-on: ubuntu-latest steps: - name: Install SDK run: | npm init -y npm install @hollyhr/api-client - name: Run first-call example env: HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }} HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }} run: node node_modules/@hollyhr/api-client/examples/first-call.mjs

Minimum HollyHR scopes:

Code
people:read

Slack who's-away digest

This workflow posts a weekday digest of approved absences for the next seven days. It deliberately posts only dates and names, not absence categories.

Code
name: HollyHR Slack who's away on: workflow_dispatch: schedule: - cron: "30 8 * * 1-5" jobs: digest: runs-on: ubuntu-latest steps: - name: Post Slack digest env: HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }} HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | node <<'EOF' const apiBaseUrl = process.env.HOLLYHR_API_BASE_URL; const apiToken = process.env.HOLLYHR_API_TOKEN; const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL; if (!apiBaseUrl || !apiToken || !slackWebhookUrl) { throw new Error("Missing HOLLYHR_API_BASE_URL, HOLLYHR_API_TOKEN, or SLACK_WEBHOOK_URL"); } async function holly(path) { const response = await fetch(`${apiBaseUrl}${path}`, { headers: { Authorization: `Bearer ${apiToken}`, Accept: "application/json", }, }); if (!response.ok) { throw new Error(`HollyHR API request failed: ${response.status} ${await response.text()}`); } return response.json(); } async function listAll(path) { const items = []; let cursor; do { const separator = path.includes("?") ? "&" : "?"; const page = await holly( `${path}${cursor ? `${separator}cursor=${encodeURIComponent(cursor)}` : ""}`, ); items.push(...page.data); cursor = page.pagination?.next_cursor; } while (cursor); return items; } function isoDate(date) { return date.toISOString().slice(0, 10); } function addDays(date, days) { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; } const today = new Date(); const from = isoDate(today); const to = isoDate(addDays(today, 6)); const [people, timeOff] = await Promise.all([ listAll("/people?limit=100"), listAll(`/time-off?limit=100&status=approved&from=${from}&to=${to}`), ]); const peopleById = new Map(people.map((person) => [person.id, person])); const lines = timeOff.map((entry) => { const person = peopleById.get(entry.person_id); const name = person?.display_name ?? "Someone"; const department = person?.department?.name ? ` (${person.department.name})` : ""; const dates = entry.start_date === entry.end_date ? entry.start_date : `${entry.start_date} to ${entry.end_date}`; return `- ${name}${department}: away, ${dates}`; }); const text = lines.length > 0 ? `Who's away from ${from} to ${to}:\n${lines.join("\n")}` : `Nobody is away from ${from} to ${to}.`; const slackResponse = await fetch(slackWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!slackResponse.ok) { throw new Error(`Slack webhook failed: ${slackResponse.status} ${await slackResponse.text()}`); } EOF

Minimum HollyHR scopes:

Code
people:read time_off:read

Payroll readiness export artifact

The payroll readiness export is read-only but elevated. Run this only in a private repository, keep artifact retention short, and limit access to the people who are allowed to handle payroll handoff material.

Code
name: HollyHR payroll readiness export on: workflow_dispatch: jobs: export: runs-on: ubuntu-latest steps: - name: Download payroll readiness package env: HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }} HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }} run: | mkdir -p out curl -fsS "$HOLLYHR_API_BASE_URL/payroll-readiness-export" \ -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \ -H "Accept: application/json" \ -o out/hollyhr-payroll-readiness-export.json - name: Upload short-lived artifact uses: actions/upload-artifact@v4 with: name: hollyhr-payroll-readiness-export path: out/hollyhr-payroll-readiness-export.json retention-days: 7

Minimum HollyHR scopes:

Code
payroll_exports:read

MCP metadata and transport smoke

This workflow proves that protected-resource metadata is reachable and that the hosted MCP endpoint returns the expected 405 Method Not Allowed for authenticated GET /api/mcp when server-to-client SSE is disabled.

Code
name: HollyHR MCP metadata smoke on: workflow_dispatch: schedule: - cron: "45 8 * * 1-5" jobs: mcp: runs-on: ubuntu-latest steps: - name: Check MCP metadata and GET behavior env: HOLLYHR_MCP_URL: ${{ secrets.HOLLYHR_MCP_URL }} HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }} run: | set -euo pipefail resource_origin="${HOLLYHR_MCP_URL%/api/mcp}" prm_url="$resource_origin/.well-known/oauth-protected-resource/api/mcp" curl -fsS "$prm_url" | tee prm.json node -e 'const prm = JSON.parse(require("node:fs").readFileSync("prm.json", "utf8")); if (!prm.resource || !Array.isArray(prm.scopes_supported)) throw new Error("Invalid PRM");' status="$(curl -sS -o /dev/null -w "%{http_code}" "$HOLLYHR_MCP_URL" \ -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2025-11-25")" test "$status" = "405"

Minimum HollyHR scopes:

Code
organisation:read people:read reference:read time_off:read

For full MCP initialize/tool-call coverage, use MCP smoke test from the HollyHR repo or connect a real MCP host through AI connectors.

Last modified on June 24, 2026
Recipe indexMCP smoke test
On this page
  • SDK first-call smoke
  • Slack who's-away digest
  • Payroll readiness export artifact
  • MCP metadata and transport smoke
YAML
YAML
YAML
YAML