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.
Developers
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.
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.
A key can do only what its scopes allow. An endpoint asks for exactly one scope.
| Scope | What it allows |
|---|---|
read:hiring | Read jobs and candidates. |
write:hiring | Add a candidate to a job. |
read:people | Read the roster's work facts. |
attendance:write | Post attendance punches from a device or a bridge. |
scim:provision | Create, update and deactivate people from your directory over SCIM. |
JSON over HTTPS. A list answers { data: [...], next_cursor }; one record answers { data: {...} }. Responses are never cached.
| Method and path | Scope | What it does |
|---|---|---|
GET /api/v1 | No key | The index: the endpoints, the webhook events and the signature format. Needs no key. |
GET /api/v1/jobs | read:hiring | Your jobs, a page at a time, oldest first.
|
GET /api/v1/jobs/{id} | read:hiring | One job. Another workspace's id is not found. |
GET /api/v1/candidates | read:hiring | Candidates, a page at a time, oldest application first.
|
GET /api/v1/candidates/{id} | read:hiring | One candidate. Another workspace's id is not found. |
POST /api/v1/candidates | write:hiring | Add 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.
|
GET /api/v1/employees | read:people | The roster's work facts, a page at a time, oldest first. Read-only.
|
{
"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…"
}Work facts only: never pay, bank details, ID numbers, interview questions or the reasoning behind a score. Employees are read-only.
| Field | What it holds |
|---|---|
id | The job's id. |
title | The role's title. |
department | Its department. |
location | Where the work is, as the careers page shows it. |
work_type | Remote, hybrid or on site. |
employment_type | Full time, part time and so on. |
status | draft, live, paused or closed. |
visibility | Who can see the posting. |
openings | How many people the role hires, or null. |
salary | null, or { min_minor, max_minor, currency, period } in minor units. |
closes_on | The closing day, or null. |
posted_at | ISO 8601. |
created_at | ISO 8601. |
| Field | What it holds |
|---|---|
id | The candidate's id. |
name | Their name. |
email | Their email. |
phone | Their phone, as given. |
job_id | The job they are on. |
stage | applied, screen, interview, offer or hired. |
status | Their status on the job. |
source | Where they came from. |
location | Where they are. |
skills | A list of skills. |
score | The suggested overall match against the role's rubric; 0 until scored. A recruiter decides. |
applied_at | ISO 8601. |
updated_at | ISO 8601. |
| Field | What it holds |
|---|---|
id | The employee's id. |
code | Their employee code. |
name | Their name. |
work_email | Their work email. |
title | Their job title. |
department | Their department. |
location | Their location. |
start_date | YYYY-MM-DD, or null. |
manager_id | Their manager's employee id, or null. |
status | Their employment status. |
legal_entity_id | The company that employs them. |
created_at | ISO 8601. |
updated_at | ISO 8601. |
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.
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."
}
}| Status | Code | When |
|---|---|---|
| 400 | invalid_limit | limit isn't a whole number from 1 to 200. |
| 400 | invalid_cursor | The cursor isn't one this API issued. |
| 400 | invalid_updated_since | updated_since isn't an ISO 8601 date-time. |
| 400 | invalid_status | A job status other than draft, live, paused or closed. |
| 400 | invalid_stage | A stage other than applied, screen, interview, offer or hired. |
| 400 | invalid_json | The POST body isn't JSON. |
| 401 | unauthorized | No key, or one that isn't valid. The same answer for a missing, unknown or revoked key. |
| 403 | insufficient_scope | The key doesn't carry the scope this endpoint needs. |
| 404 | not_found | No such job or candidate in this workspace. |
| 409 | duplicate | This person is already on that job. |
| 422 | invalid_body | A body field is missing or invalid; the message names it. |
| 422 | unknown_job | job_id isn't a job in this workspace. |
| 429 | rate_limited | Too many requests from this key. Wait for the Retry-After header's seconds. |
| 500 | server_error | Something failed on our side. Retry later. |
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.
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.
| Event | When | data holds |
|---|---|---|
candidate.applied | Someone applies | candidate |
candidate.stage_changed | A candidate moves stage | candidate, stage |
candidate.hired | A candidate is hired | candidate |
candidate.rejected | A candidate is turned down | candidate |
interview.scheduled | An interview is booked | interview, candidate |
employee.created | An employee is added | employee |
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"
}
}| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | GrowthPPL-Webhooks/1 |
GrowthPPL-Event | The event, e.g. candidate.hired (ping for a test). |
GrowthPPL-Event-Id | The event's id, evt_…: the same on every retry and every endpoint. De-duplicate on it. |
GrowthPPL-Delivery | This delivery's id: one per endpoint. |
GrowthPPL-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
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.
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.
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 path | Scope | What it does |
|---|---|---|
GET /scim/v2/ServiceProviderConfig | scim:provision | What this server supports: PATCH and filters; no bulk, sort, ETags or password changes. |
GET /scim/v2/ResourceTypes | scim:provision | One resource type, User, with the enterprise extension. |
GET /scim/v2/Schemas | scim:provision | The User attributes GrowthPPL reads and writes. |
GET /scim/v2/Users | scim:provision | List people, usually to find one before creating them.
|
POST /scim/v2/Users | scim:provision | Create 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:provision | Read one person. |
PUT /scim/v2/Users/{id} | scim:provision | Replace what the directory says about one person. |
PATCH /scim/v2/Users/{id} | scim:provision | Change one person: add and replace operations, with or without a path. |
DELETE /scim/v2/Users/{id} | scim:provision | Deactivate: the same as active false. Sign-in is revoked at once, the record is kept and HR is told. Answers 204. |
GET /scim/v2/Groups | scim:provision | Always an empty list: GrowthPPL doesn't keep groups. |
POST /scim/v2/Groups | scim:provision | Refused with 501. Turn off group push for this app in your directory. |
| Attribute | Meaning |
|---|---|
userName | The person's work email. Required, and unique in the workspace. |
externalId | Your directory's id for the person. |
name | givenName, familyName and formatted. |
displayName | The name GrowthPPL shows. |
title | Their job title. |
active | false revokes sign-in; true gives back only a sign-in the directory itself took away. |
emails | The work email, as value, type and primary. |
department | From 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.
| Method and path | Scope | What it does |
|---|---|---|
POST /api/attendance/punches | attendance:write | Post 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.
|
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 }.
| Method and path | Scope | What it does |
|---|---|---|
GET /feeds/{slug}/{board}.xml | No key | Your 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.
v1 is the only version. Changes to it are listed here, newest first.
We'll walk you through keys, webhooks and SCIM on the demo workspace.