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.
Authorization: Bearer phr_live_… to https://papahr.com/api/v1employee.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 https://papahr.com/api/v1/ \
-H "Authorization: Bearer $PAPAHR_KEY"Errors & limits
| 401invalid_key | No key, a wrong, revoked or expired one. |
| 402plan_required | The company is not on Pro. |
| 403forbidden | The key may not use this section; scope says which one is needed. |
| 404 | No such record in this company. |
| 409 / 422 | The data does not fit: a field, an overlap, a limit. errors lists the fields. |
| 429rate_limited | More 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.
{
"error": {
"code": "forbidden",
"message": "This key may not read “hiring”. The company owner or HR can create a key that may.",
"scope": "hiring:read"
}
}X-RateLimit-RemainingPagination
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 "https://papahr.com/api/v1/employees?cursor=177" \
-H "Authorization: Bearer $PAPAHR_KEY"The key and its company
/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"
const res = await fetch("https://papahr.com/api/v1/", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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"
}
}List employees
/api/v1/employeesemployees:readThe 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 | all | Default active (includes people on probation). |
| department_idinteger | Only one department. |
| updated_sinceISO 8601 date-time | Only people changed after this moment, leavers too. |
| cursorinteger | From next_cursor of the previous page. 100 per page. |
curl https://papahr.com/api/v1/employees \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/employees", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/employees",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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
}Get an employee
/api/v1/employees/{id}employees:readOne person by the id.
curl https://papahr.com/api/v1/employees/77 \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/employees/77", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/employees/77",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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"
}
}Create an employee
/api/v1/employeesemployees:writeAdds 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, required | Full name. |
| emailstring | — |
| positionstring | — |
| departmentstring | The department name, as in PapaHR. |
| managerstring | The manager's full name. |
| hired_atdate | — |
| probation_untildate | — |
| statusactive | probation | left | Default active. |
| phone, birthdaystring, date | Only with a key that may see personal data. |
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"}'
const res = await fetch("https://papahr.com/api/v1/employees", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"name":"Maria Bondar","position":"Sales manager","department":"Sales","hired_at":"2026-11-04"}),
});
const json = await res.json();
import os, requests
r = requests.post(
"https://papahr.com/api/v1/employees",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
json={"name":"Maria Bondar","position":"Sales manager","department":"Sales","hired_at":"2026-11-04"},
)
data = r.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"
}
}Update an employee
/api/v1/employees/{id}employees:writeChanges only the fields you send. status = left with left_on marks a leaver and sends employee.left.
Body (JSON)
| any field of Create | Send only what changes. |
| left_ondate | The 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"}'
const res = await fetch("https://papahr.com/api/v1/employees/77", {
method: "PATCH",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"position":"Head of sales"}),
});
const json = await res.json();
import os, requests
r = requests.patch(
"https://papahr.com/api/v1/employees/77",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
json={"position":"Head of sales"},
)
data = r.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"
}
}Departments
/api/v1/departmentsemployees:readThe departments with the parent, the head and the number of people.
curl https://papahr.com/api/v1/departments \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/departments", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/departments",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.json(){
"data": [
{
"id": 4,
"name": "Sales",
"parent_id": null,
"head_employee_id": 12,
"employees": 9
}
]
}Types of time off
/api/v1/time-off/typestime_off:readVacation, 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"
const res = await fetch("https://papahr.com/api/v1/time-off/types", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/time-off/types",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.json(){
"data": [
{
"id": 2,
"name": "Vacation",
"code": "V",
"kind": "vacation",
"paid": true,
"counts_as_work": false,
"days_per_year": 24,
"approval": "manager"
}
]
}List time off
/api/v1/time-offtime_off:readRequests and absences that overlap the dates.
Query parameters
| fromdate | Default 30 days ago. |
| todate | Default in 90 days. |
| statuspending | approved | rejected | cancelled | all | Default all. |
| employee_idinteger | — |
| cursorinteger | From next_cursor of the previous page. 100 per page. |
curl https://papahr.com/api/v1/time-off \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/time-off", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/time-off",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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
}Request time off
/api/v1/time-offtime_off:writeCreates 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, required | From Types of time off. |
| starts_ondate, required | — |
| ends_ondate, required | — |
| commentstring | — |
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"}'
const res = await fetch("https://papahr.com/api/v1/time-off", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"employee_id":77,"type_id":2,"starts_on":"2026-12-21","ends_on":"2026-12-25","comment":"Family trip"}),
});
const json = await res.json();
import os, requests
r = requests.post(
"https://papahr.com/api/v1/time-off",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
json={"employee_id":77,"type_id":2,"starts_on":"2026-12-21","ends_on":"2026-12-25","comment":"Family trip"},
)
data = r.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 of a month
/api/v1/timesheet/{month}timesheet:readThe timesheet as HR sees it in PapaHR: hours or the absence code for every day, and the totals. month is YYYY-MM.
curl https://papahr.com/api/v1/timesheet/2026-11 \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/timesheet/2026-11", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/timesheet/2026-11",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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
}
}
]
}Clock-in marks
/api/v1/clocktimesheet:readThe marks of “Start the day”: start, break, back, end. At most 62 days at once.
Query parameters
| fromdate | Default 6 days ago. |
| todate | Default today. |
| employee_idinteger | — |
| cursorinteger | From next_cursor of the previous page. 100 per page. |
curl https://papahr.com/api/v1/clock \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/clock", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/clock",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.json(){
"data": [
{
"id": 5012,
"employee_id": 77,
"date": "2026-11-05",
"kind": "in",
"at": "2026-11-05T07:02:00Z",
"source": "web"
}
],
"next_cursor": null
}Vacancies
/api/v1/vacancieshiring:readVacancies with the stages and the public page to apply.
Query parameters
| statusdraft | open | closed | all | Default open. |
curl https://papahr.com/api/v1/vacancies \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/vacancies", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/vacancies",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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/…"
}
]
}Candidates of a vacancy
/api/v1/vacancies/{id}/candidateshiring:readThe candidates with the stage and the source.
Query parameters
| cursorinteger | From next_cursor of the previous page. 100 per page. |
curl https://papahr.com/api/v1/vacancies/18/candidates \
-H "Authorization: Bearer $PAPAHR_KEY"
const res = await fetch("https://papahr.com/api/v1/vacancies/18/candidates", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}` },
});
const json = await res.json();
import os, requests
r = requests.get(
"https://papahr.com/api/v1/vacancies/18/candidates",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
)
data = r.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
}Add a candidate
/api/v1/vacancies/{id}/candidateshiring:writeFor 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 | — |
| notestring | A cover letter or answers of your form. |
| profile_urlurl | LinkedIn or a CV link. |
| sourcepage | linkedin | work_ua | robota_ua | djinni | dou | telegram | referral | other | Default 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]"}'
const res = await fetch("https://papahr.com/api/v1/vacancies/18/candidates", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PAPAHR_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"name":"Maria Bondar","email":"[email protected]"}),
});
const json = await res.json();
import os, requests
r = requests.post(
"https://papahr.com/api/v1/vacancies/18/candidates",
headers={"Authorization": f"Bearer {os.environ['PAPAHR_KEY']}"},
json={"name":"Maria Bondar","email":"[email protected]"},
)
data = r.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.
{
"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));
}
function verify(string $raw, string $header, string $secret): bool {
parse_str(str_replace(",", "&", $header), $p);
$mac = hash_hmac("sha256", $p["t"] . "." . $raw, $secret);
return abs(time() - (int) $p["t"]) < 300 && hash_equals($mac, $p["v1"] ?? "");
}
import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str) -> bool:
p = dict(x.split("=", 1) for x in header.split(","))
mac = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw, hashlib.sha256).hexdigest()
return abs(time.time() - int(p["t"])) < 300 and hmac.compare_digest(mac, p.get("v1", ""))