Developers

Build on GrowthPPL.

The REST API (v1), signed webhooks, SCIM 2.0 user provisioning, the attendance punch endpoint and the XML job feeds, written from the code that serves them.

Authentication

Every call except the index carries an API key in the Authorization header, as a Bearer token. An owner or anyone with the permission to manage API keys creates one in Settings › API & webhooks, choosing its scopes. The full key is shown once; GrowthPPL keeps only a hash of it. Keys start sk_live_. There is no test mode: every key reads and writes your real workspace, so give each one only the scopes it needs. Keys made earlier with an sk_test_ prefix keep working, with the same access. Revoke a key there and it stops working at once.

curl "$GROWTHPPL/api/v1/candidates?stage=interview&limit=50" \
  -H "Authorization: Bearer $GROWTHPPL_KEY"

$GROWTHPPL is the address you sign in to GrowthPPL at. Every key belongs to one workspace, and every call sees only that workspace's data.

Scopes

A key can do only what its scopes allow. An endpoint asks for exactly one scope.

ScopeWhat it allows
read:hiringRead jobs and candidates.
write:hiringAdd a candidate to a job.
read:peopleRead the roster's work facts.
attendance:writePost attendance punches from a device or a bridge.
scim:provisionCreate, update and deactivate people from your directory over SCIM.

REST endpoints

JSON over HTTPS. A list answers { data: [...], next_cursor }; one record answers { data: {...} }. Responses are never cached.

Method and pathScopeWhat it does
GET /api/v1No keyThe index: the endpoints, the webhook events and the signature format. Needs no key.
GET /api/v1/jobsread:hiringYour jobs, a page at a time, oldest first.
  • status draft, live, paused or closed.
  • limit Rows per page, 1 to 200. Default 50.
  • cursor The next_cursor from the previous page.
GET /api/v1/jobs/{id}read:hiringOne job. Another workspace's id is not found.
GET /api/v1/candidatesread:hiringCandidates, a page at a time, oldest application first.
  • job_id Only this job's candidates.
  • stage applied, screen, interview, offer or hired.
  • updated_since An ISO 8601 date-time: only candidates changed since then.
  • limit Rows per page, 1 to 200. Default 50.
  • cursor The next_cursor from the previous page.
GET /api/v1/candidates/{id}read:hiringOne candidate. Another workspace's id is not found.
POST /api/v1/candidateswrite:hiringAdd someone to one of your jobs from another system. The same duplicate rule as adding by hand applies, and the key is recorded on the audit trail. Answers 201 with the candidate.
  • name required. Up to 120 characters.
  • email required. An email address.
  • job_id required. A job in this workspace.
  • phone optional. Up to 40 characters.
  • source optional. Where they came from. Default "API".
  • location optional. Up to 120 characters.
GET /api/v1/employeesread:peopleThe roster's work facts, a page at a time, oldest first. Read-only.
  • status Only people with this status.
  • updated_since An ISO 8601 date-time: only people changed since then.
  • limit Rows per page, 1 to 200. Default 50.
  • cursor The next_cursor from the previous page.
{
  "data": [
    {
      "id": "…",
      "name": "Asha Thomas",
      "email": "asha@example.com",
      "phone": "",
      "job_id": "…",
      "stage": "interview",
      "status": "active",
      "source": "Careers page",
      "location": "Kochi",
      "skills": [
        "ICU"
      ],
      "score": 0,
      "applied_at": "2026-10-01T04:30:00.000Z",
      "updated_at": "2026-10-03T09:12:00.000Z"
    }
  ],
  "next_cursor": "eyJ0Ijoi…"
}

What each resource returns

Work facts only: never pay, bank details, ID numbers, interview questions or the reasoning behind a score. Employees are read-only.

Job

FieldWhat it holds
idThe job's id.
titleThe role's title.
departmentIts department.
locationWhere the work is, as the careers page shows it.
work_typeRemote, hybrid or on site.
employment_typeFull time, part time and so on.
statusdraft, live, paused or closed.
visibilityWho can see the posting.
openingsHow many people the role hires, or null.
salarynull, or { min_minor, max_minor, currency, period } in minor units.
closes_onThe closing day, or null.
posted_atISO 8601.
created_atISO 8601.

Candidate

FieldWhat it holds
idThe candidate's id.
nameTheir name.
emailTheir email.
phoneTheir phone, as given.
job_idThe job they are on.
stageapplied, screen, interview, offer or hired.
statusTheir status on the job.
sourceWhere they came from.
locationWhere they are.
skillsA list of skills.
scoreThe suggested overall match against the role's rubric; 0 until scored. A recruiter decides.
applied_atISO 8601.
updated_atISO 8601.

Employee

FieldWhat it holds
idThe employee's id.
codeTheir employee code.
nameTheir name.
work_emailTheir work email.
titleTheir job title.
departmentTheir department.
locationTheir location.
start_dateYYYY-MM-DD, or null.
manager_idTheir manager's employee id, or null.
statusTheir employment status.
legal_entity_idThe company that employs them.
created_atISO 8601.
updated_atISO 8601.

Pagination

Lists come oldest first, so new rows land at the end. Ask for up to 200 rows with limit (50 if you leave it out). When there are more, next_cursor holds a string to send back as cursor; on the last page it is null. Candidates and employees also take updated_since, an ISO 8601 date-time, to fetch only what changed.

Errors

An error answers with an HTTP status and { error: { code, message } }. The code is stable; the message is for a person to read.

{
  "error": {
    "code": "insufficient_scope",
    "message": "This key can't do that. Give it the \"read:hiring\" scope in Settings › API & webhooks."
  }
}
StatusCodeWhen
400invalid_limitlimit isn't a whole number from 1 to 200.
400invalid_cursorThe cursor isn't one this API issued.
400invalid_updated_sinceupdated_since isn't an ISO 8601 date-time.
400invalid_statusA job status other than draft, live, paused or closed.
400invalid_stageA stage other than applied, screen, interview, offer or hired.
400invalid_jsonThe POST body isn't JSON.
401unauthorizedNo key, or one that isn't valid. The same answer for a missing, unknown or revoked key.
403insufficient_scopeThe key doesn't carry the scope this endpoint needs.
404not_foundNo such job or candidate in this workspace.
409duplicateThis person is already on that job.
422invalid_bodyA body field is missing or invalid; the message names it.
422unknown_jobjob_id isn't a job in this workspace.
429rate_limitedToo many requests from this key. Wait for the Retry-After header's seconds.
500server_errorSomething failed on our side. Retry later.

Rate limits

Each key may make 240 REST requests a minute, across every v1 endpoint, and 240 SCIM requests a minute, counted separately. The punch endpoint takes 120 pushes a minute per key. Over the limit, the answer is 429; REST and SCIM add a Retry-After header in seconds.

Webhooks

Add an endpoint in Settings › API & webhooks, choose its events, and copy its signing secret (it starts whsec_). The address must be public and use https. Send test delivers a ping event to that one endpoint.

EventWhendata holds
candidate.appliedSomeone appliescandidate
candidate.stage_changedA candidate moves stagecandidate, stage
candidate.hiredA candidate is hiredcandidate
candidate.rejectedA candidate is turned downcandidate
interview.scheduledAn interview is bookedinterview, candidate
employee.createdAn employee is addedemployee

Every delivery is a POST of one JSON envelope: id, type, created_at and data.

{
  "id": "evt_…",
  "type": "candidate.stage_changed",
  "created_at": "2026-10-03T09:12:00.000Z",
  "data": {
    "candidate": {
      "id": "…",
      "name": "Asha Thomas",
      "email": "asha@example.com",
      "phone": "",
      "stage": "interview",
      "status": "active",
      "source": "Careers page",
      "job_id": "…",
      "job_title": "Staff nurse"
    },
    "stage": "interview"
  }
}
  • candidate id, name, email, phone, stage, status, source, job_id, job_title.
  • interview id, kind, starts_at, duration_min, where, panel, booked_by.
  • employee id, code, name, work_email, title, department, location, start_date, manager_id, status, candidate_id. Work facts only: never pay, bank or ID numbers.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentGrowthPPL-Webhooks/1
GrowthPPL-EventThe event, e.g. candidate.hired (ping for a test).
GrowthPPL-Event-IdThe event's id, evt_…: the same on every retry and every endpoint. De-duplicate on it.
GrowthPPL-DeliveryThis delivery's id: one per endpoint.
GrowthPPL-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Delivery and retries

A 2xx answer counts as delivered. Anything else counts as a failed attempt, including a redirect, which is not followed, and no answer after 10 seconds. A failed delivery is tried again after 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours: 6 attempts in all. After 30 failures in a row, the endpoint is paused and the person who added it is told; resume it in Settings › API & webhooks once it is fixed.

Verify a signature

Each delivery carries GrowthPPL-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Compute the HMAC-SHA256 of the timestamp, a full stop and the raw body with your endpoint's secret, compare it in constant time, and refuse a timestamp more than 300 seconds from now. In Node:

const crypto = require("node:crypto");

// rawBody: the request body exactly as received, before JSON.parse.
function verifyGrowthPPL(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Read the body as raw text: a body parsed and serialised again won't match the signature.

SCIM 2.0

Your directory can create, update and deactivate people over SCIM 2.0 at $GROWTHPPL/scim/v2, with an API key that has the scim:provision scope as its bearer token. Users only: GrowthPPL doesn't keep groups. Answers use application/scim+json and SCIM's own error shape.

Method and pathScopeWhat it does
GET /scim/v2/ServiceProviderConfigscim:provisionWhat this server supports: PATCH and filters; no bulk, sort, ETags or password changes.
GET /scim/v2/ResourceTypesscim:provisionOne resource type, User, with the enterprise extension.
GET /scim/v2/Schemasscim:provisionThe User attributes GrowthPPL reads and writes.
GET /scim/v2/Usersscim:provisionList people, usually to find one before creating them.
  • filter userName eq "…" or externalId eq "…" (emails.value eq is read as userName).
  • startIndex 1-based. Default 1.
  • count Up to 100. Default 100.
POST /scim/v2/Usersscim:provisionCreate a person on the roster. Answers 201 with a Location header; 409 if the userName or externalId is taken.
GET /scim/v2/Users/{id}scim:provisionRead one person.
PUT /scim/v2/Users/{id}scim:provisionReplace what the directory says about one person.
PATCH /scim/v2/Users/{id}scim:provisionChange one person: add and replace operations, with or without a path.
DELETE /scim/v2/Users/{id}scim:provisionDeactivate: the same as active false. Sign-in is revoked at once, the record is kept and HR is told. Answers 204.
GET /scim/v2/Groupsscim:provisionAlways an empty list: GrowthPPL doesn't keep groups.
POST /scim/v2/Groupsscim:provisionRefused with 501. Turn off group push for this app in your directory.

User attributes

AttributeMeaning
userNameThe person's work email. Required, and unique in the workspace.
externalIdYour directory's id for the person.
namegivenName, familyName and formatted.
displayNameThe name GrowthPPL shows.
titleTheir job title.
activefalse revokes sign-in; true gives back only a sign-in the directory itself took away.
emailsThe work email, as value, type and primary.
departmentFrom the enterprise extension.

A person created over SCIM is an employee record, and takes a seat on your plan; when no seat is free, the create is refused with 403. Deactivating never deletes anyone.

Attendance punches

Method and pathScopeWhat it does
POST /api/attendance/punchesattendance:writePost punches from an attendance device, or a bridge beside it, addressed by each person's device enrolment code. A repeated punch (same person, instant and direction) is ignored, so a retry never double-counts a day.
  • punches required. 1 to 5,000 items.
  • punches[].code required. The enrolment code the device knows the person by, mapped in People Ops › Attendance › Import punches.
  • punches[].at required. ISO 8601 with an offset. The day is worked out on the person's site clock.
  • punches[].kind required. in or out.

The answer lists inserted, duplicates, daysRebuilt, unknownCodes, closed. A punch on a day a paid payroll run has closed is not written; it comes back in closed with the reason. When nothing could be written because no code is mapped or every day is closed, the status is 422. Errors here answer { error: message }.

Job feeds

Method and pathScopeWhat it does
GET /feeds/{slug}/{board}.xmlNo keyYour live, public roles as XML for a job board to fetch: {slug} is your careers page's, and {board} is indeed or linkedin for those boards' formats, or a board the documented generic format is offered for. Settings › Careers page shows each link. Public, with no key; a board you haven't turned on there is a 404.

Only live, publicly visible roles, newest first, up to 500. Each role applies through your own form, and the applicant's source is recorded as the board. Boards may cache a feed for up to 15 minutes.

API changes

v1 is the only version. Changes to it are listed here, newest first.

  • . Keys can no longer be given the write:people or read:analytics scopes, which no endpoint checks. A key that already carries one keeps working.
  • . REST API v1 (jobs, candidates, employees), signed webhooks, SCIM 2.0 Users and XML job feeds.
  • . The punch ingest endpoint for attendance devices.

Everything GrowthPPL connects to

Want to see the API on a real workspace?

We'll walk you through keys, webhooks and SCIM on the demo workspace.