API v1
OpenAPI 3.1 ↓Get an API key

PapaHR API

Connect PapaHR to payroll, accounting, your careers site or Zapier. Read and update employees, take the timesheet of a month, add candidates from your own form — and get webhooks the moment something happens.

1Get a keyThe company owner or HR creates it in PapaHR → Connections and API, and ticks the sections it may use.
2Send it as a Bearer tokenAuthorization: Bearer phr_live_… to https://papahr.com/api/v1
3Subscribe to webhooksAdd your https address in PapaHR and get employee.hired the minute it happens.

Authentication

Every request carries the company key in the Authorization header. A key belongs to one company and may use only the sections the owner or HR ticked: each one read or read and change. Salary and personal data (phone, birthday) are separate permissions.

Keys are shown once when created. Keep them on your server, never in a browser or a mobile app. A revoked or expired key answers 401.

The API is part of PapaHR Pro. Sample data of a company is never returned.

cURL
curl https://papahr.com/api/v1/ \
  -H "Authorization: Bearer $PAPAHR_KEY"

Errors & limits

401invalid_keyNo key, a wrong, revoked or expired one.
402plan_requiredThe company is not on Pro.
403forbiddenThe key may not use this section; scope says which one is needed.
404No such record in this company.
409 / 422The data does not fit: a field, an overlap, a limit. errors lists the fields.
429rate_limitedMore than 120 requests a minute for the key; wait Retry-After seconds.

Every request is in the company's request log in PapaHR for 30 days.

403
{
    "error": {
        "code": "forbidden",
        "message": "This key may not read “hiring”. The company owner or HR can create a key that may.",
        "scope": "hiring:read"
    }
}
120requests a minute per key, see X-RateLimit-Remaining
100records per page

Pagination

Lists return up to 100 records and next_cursor. Send it as cursor to get the next page; null means this is the last one.

Dates are YYYY-MM-DD in the company's time zone; moments like updated_at are UTC.

cURL
curl "https://papahr.com/api/v1/employees?cursor=177" \
  -H "Authorization: Bearer $PAPAHR_KEY"
Company

The key and its company

GET/api/v1/

Check the key: the company, the time zone and what the key may do.

curl https://papahr.com/api/v1/ \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "company": {
        "name": "Acme",
        "timezone": "Europe/Kyiv"
    },
    "key": {
        "name": "Accounting — timesheet",
        "scopes": {
            "employees": "read",
            "timesheet": "read"
        },
        "salary": false,
        "personal": false,
        "expires_at": "2027-11-05T10:00:00Z"
    }
}
Employees

List employees

GET/api/v1/employeesemployees:read

The people of the company in the order they were added. Use updated_since to take only what changed since your last sync — this is how payroll or accounting keeps up without downloading everything again.

Query parameters

statusactive | left | allDefault active (includes people on probation).
department_idintegerOnly one department.
updated_sinceISO 8601 date-timeOnly people changed after this moment, leavers too.
cursorintegerFrom next_cursor of the previous page. 100 per page.
ℹ️ With a key that may see the salary each person has "salary": {"amount", "currency", "since"}; with personal data — "personal": {"phone", "birthday"}.
curl https://papahr.com/api/v1/employees \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 77,
            "name": "Oleh Pavlenko",
            "first_name": "Oleh",
            "last_name": "Pavlenko",
            "email": "[email protected]",
            "position": "Sales manager",
            "department": {
                "id": 4,
                "name": "Sales"
            },
            "manager": "Iryna Koval",
            "status": "probation",
            "hired_at": "2026-11-04",
            "probation_ends_at": "2027-02-04",
            "left_on": null,
            "hours_per_day": 8,
            "updated_at": "2026-11-04T08:41:12Z"
        }
    ],
    "next_cursor": null
}
Employees

Get an employee

GET/api/v1/employees/{id}employees:read

One person by the id.

curl https://papahr.com/api/v1/employees/77 \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": {
        "id": 77,
        "name": "Oleh Pavlenko",
        "first_name": "Oleh",
        "last_name": "Pavlenko",
        "email": "[email protected]",
        "position": "Sales manager",
        "department": {
            "id": 4,
            "name": "Sales"
        },
        "manager": "Iryna Koval",
        "status": "probation",
        "hired_at": "2026-11-04",
        "probation_ends_at": "2027-02-04",
        "left_on": null,
        "hours_per_day": 8,
        "updated_at": "2026-11-04T08:41:12Z"
    }
}
Employees

Create an employee

POST/api/v1/employeesemployees:write

Adds a person, as HR does in PapaHR. A new department from the text is created by itself. Sends the employee.hired webhook.

Body (JSON)

namestring, requiredFull name.
emailstring—
positionstring—
departmentstringThe department name, as in PapaHR.
managerstringThe manager's full name.
hired_atdate—
probation_untildate—
statusactive | probation | leftDefault active.
phone, birthdaystring, dateOnly with a key that may see personal data.
ℹ️ Answers 409 employee_limit when the plan's employee limit is reached.
curl -X POST https://papahr.com/api/v1/employees \
  -H "Authorization: Bearer $PAPAHR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Maria Bondar","position":"Sales manager","department":"Sales","hired_at":"2026-11-04"}'
● 201 Createdapplication/json
{
    "data": {
        "id": 77,
        "name": "Oleh Pavlenko",
        "first_name": "Oleh",
        "last_name": "Pavlenko",
        "email": "[email protected]",
        "position": "Sales manager",
        "department": {
            "id": 4,
            "name": "Sales"
        },
        "manager": "Iryna Koval",
        "status": "probation",
        "hired_at": "2026-11-04",
        "probation_ends_at": "2027-02-04",
        "left_on": null,
        "hours_per_day": 8,
        "updated_at": "2026-11-04T08:41:12Z"
    }
}
Employees

Update an employee

PATCH/api/v1/employees/{id}employees:write

Changes only the fields you send. status = left with left_on marks a leaver and sends employee.left.

Body (JSON)

any field of CreateSend only what changes.
left_ondateThe last day.
curl -X PATCH https://papahr.com/api/v1/employees/77 \
  -H "Authorization: Bearer $PAPAHR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"position":"Head of sales"}'
● 200 OKapplication/json
{
    "data": {
        "id": 77,
        "name": "Oleh Pavlenko",
        "first_name": "Oleh",
        "last_name": "Pavlenko",
        "email": "[email protected]",
        "position": "Sales manager",
        "department": {
            "id": 4,
            "name": "Sales"
        },
        "manager": "Iryna Koval",
        "status": "probation",
        "hired_at": "2026-11-04",
        "probation_ends_at": "2027-02-04",
        "left_on": null,
        "hours_per_day": 8,
        "updated_at": "2026-11-04T08:41:12Z"
    }
}
Employees

Departments

GET/api/v1/departmentsemployees:read

The departments with the parent, the head and the number of people.

curl https://papahr.com/api/v1/departments \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 4,
            "name": "Sales",
            "parent_id": null,
            "head_employee_id": 12,
            "employees": 9
        }
    ]
}
Time off

Types of time off

GET/api/v1/time-off/typestime_off:read

Vacation, sick leave and the company's own types, with the type_id for requests.

curl https://papahr.com/api/v1/time-off/types \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 2,
            "name": "Vacation",
            "code": "V",
            "kind": "vacation",
            "paid": true,
            "counts_as_work": false,
            "days_per_year": 24,
            "approval": "manager"
        }
    ]
}
Time off

List time off

GET/api/v1/time-offtime_off:read

Requests and absences that overlap the dates.

Query parameters

fromdateDefault 30 days ago.
todateDefault in 90 days.
statuspending | approved | rejected | cancelled | allDefault all.
employee_idinteger—
cursorintegerFrom next_cursor of the previous page. 100 per page.
curl https://papahr.com/api/v1/time-off \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 310,
            "employee": {
                "id": 77,
                "name": "Oleh Pavlenko"
            },
            "type": {
                "id": 2,
                "name": "Vacation",
                "kind": "vacation"
            },
            "starts_on": "2026-12-21",
            "ends_on": "2026-12-25",
            "days": 5,
            "status": "pending",
            "comment": "Family trip",
            "decided_at": null
        }
    ],
    "next_cursor": null
}
Time off

Request time off

POST/api/v1/time-offtime_off:write

Creates a request as if the employee sent it: it goes to approval in PapaHR, or is approved at once if the type needs no approval.

Body (JSON)

employee_idinteger, required—
type_idinteger, requiredFrom Types of time off.
starts_ondate, required—
ends_ondate, required—
commentstring—
ℹ️ Answers 409 overlap when the person already has a request for these dates, 422 no_working_days when there are none.
curl -X POST https://papahr.com/api/v1/time-off \
  -H "Authorization: Bearer $PAPAHR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"employee_id":77,"type_id":2,"starts_on":"2026-12-21","ends_on":"2026-12-25","comment":"Family trip"}'
● 201 Createdapplication/json
{
    "data": {
        "id": 310,
        "employee": {
            "id": 77,
            "name": "Oleh Pavlenko"
        },
        "type": {
            "id": 2,
            "name": "Vacation",
            "kind": "vacation"
        },
        "starts_on": "2026-12-21",
        "ends_on": "2026-12-25",
        "days": 5,
        "status": "pending",
        "comment": "Family trip",
        "decided_at": null
    }
}
Timesheet

Timesheet of a month

GET/api/v1/timesheet/{month}timesheet:read

The timesheet as HR sees it in PapaHR: hours or the absence code for every day, and the totals. month is YYYY-MM.

ℹ️ closed_at is set when HR closed the month for the accounting: then the numbers do not change.
curl https://papahr.com/api/v1/timesheet/2026-11 \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "month": "2026-11",
    "closed_at": null,
    "data": [
        {
            "employee_id": 77,
            "name": "Oleh Pavlenko",
            "days": {
                "2026-11-03": {
                    "value": "8",
                    "kind": "work"
                },
                "2026-11-04": {
                    "value": "V",
                    "kind": "vacation"
                }
            },
            "totals": {
                "worked_days": 20,
                "hours": 160,
                "vacation_days": 1,
                "sick_days": 0,
                "other_days": 0
            }
        }
    ]
}
Timesheet

Clock-in marks

GET/api/v1/clocktimesheet:read

The marks of “Start the day”: start, break, back, end. At most 62 days at once.

Query parameters

fromdateDefault 6 days ago.
todateDefault today.
employee_idinteger—
cursorintegerFrom next_cursor of the previous page. 100 per page.
curl https://papahr.com/api/v1/clock \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 5012,
            "employee_id": 77,
            "date": "2026-11-05",
            "kind": "in",
            "at": "2026-11-05T07:02:00Z",
            "source": "web"
        }
    ],
    "next_cursor": null
}
Hiring

Vacancies

GET/api/v1/vacancieshiring:read

Vacancies with the stages and the public page to apply.

Query parameters

statusdraft | open | closed | allDefault open.
curl https://papahr.com/api/v1/vacancies \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 18,
            "title": "Sales manager",
            "department": "Sales",
            "location": "Kyiv",
            "status": "open",
            "openings": 2,
            "deadline": "2026-12-15",
            "stages": [
                {
                    "key": "new",
                    "name": "New"
                },
                {
                    "key": "interview",
                    "name": "Interview"
                }
            ],
            "candidates": 41,
            "apply_url": "https://papahr.com/jobs/…"
        }
    ]
}
Hiring

Candidates of a vacancy

GET/api/v1/vacancies/{id}/candidateshiring:read

The candidates with the stage and the source.

Query parameters

cursorintegerFrom next_cursor of the previous page. 100 per page.
curl https://papahr.com/api/v1/vacancies/18/candidates \
  -H "Authorization: Bearer $PAPAHR_KEY"
● 200 OKapplication/json
{
    "data": [
        {
            "id": 905,
            "vacancy_id": 18,
            "name": "Maria Bondar",
            "email": "[email protected]",
            "phone": "+380501234567",
            "source": "page",
            "stage": "new",
            "rejected": false,
            "created_at": "2026-11-05T09:15:03Z",
            "url": "https://papahr.com/hiring/18"
        }
    ],
    "next_cursor": null
}
Hiring

Add a candidate

POST/api/v1/vacancies/{id}/candidateshiring:write

For the form on your own site: the person lands at the first stage and HR is notified. Sends candidate.created.

Body (JSON)

namestring, required—
emailstring—
phonestring—
notestringA cover letter or answers of your form.
profile_urlurlLinkedIn or a CV link.
sourcepage | linkedin | work_ua | robota_ua | djinni | dou | telegram | referral | otherDefault page.
curl -X POST https://papahr.com/api/v1/vacancies/18/candidates \
  -H "Authorization: Bearer $PAPAHR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Maria Bondar","email":"[email protected]"}'
● 201 Createdapplication/json
{
    "data": {
        "id": 905,
        "vacancy_id": 18,
        "name": "Maria Bondar",
        "email": "[email protected]",
        "phone": "+380501234567",
        "source": "page",
        "stage": "new",
        "rejected": false,
        "created_at": "2026-11-05T09:15:03Z",
        "url": "https://papahr.com/hiring/18"
    }
}

Webhooks

HR adds your https address in PapaHR → Connections and API and chooses the events. PapaHR sends a POST with JSON right after the event. Answer any 2xx within 10 seconds; otherwise PapaHR tries again in 5 and 30 minutes, 2, 6 and 15 hours — six attempts in all. Redirects are not followed.

Events

  • employee.hired — A person was added.
  • employee.updated — The name, email, position, department, manager, status or dates changed; "changed" lists the fields.
  • employee.left — The status became left.
  • time_off.approved — A request was approved, or created approved.
  • clock.day_ended — Someone pressed “End the day”: the worked and break minutes.
  • candidate.created — A new candidate in any vacancy.
  • ping — The “Check” button in PapaHR.

Event payloads carry no salary and no personal data. To get more, call the API with the id from the event. The same event may come twice: use id to skip repeats.

POST your addressX-PapaHR-Event: employee.hired
{
    "id": "evt_8Kq2…",
    "event": "employee.hired",
    "created_at": "2026-11-04T08:41:12Z",
    "data": {
        "id": 77,
        "name": "Oleh Pavlenko",
        "email": "[email protected]",
        "position": "Sales manager",
        "department": "Sales",
        "manager": "Iryna Koval",
        "status": "probation",
        "hired_at": "2026-11-04",
        "left_on": null
    }
}

Verify a signature

Every webhook has the header X-PapaHR-Signature: t=<unix time>,v1=<hex>. v1 is HMAC-SHA256 of t + "." + raw body with the address's signing secret (PapaHR → Connections and API → the address → “Signing secret”).

Compare in constant time and reject events older than 5 minutes.

import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const mac = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}