Own career site integration
Show your open PeopleForce vacancies on your own website, and accept applications there.
This guide shows how to display your open PeopleForce vacancies on your own website. Whether you already have a careers page or are building one, you don't have to use the careers site provided by PeopleForce. The last section covers taking the application on your own site too.
If you represent a job board and want to integrate your product with PeopleRecruit, see Job boards integration instead.
Prerequisites
Use the Careers API (https://app.peopleforce.io/api/careers/v1), which
exposes only non-sensitive vacancy data. You'll need a Career API key — see
Authentication.
Only ever use a Career API key on a public-facing website. A Company API key would expose worker data.
Recommended flow
- Retrieve the list of vacancies from PeopleForce.
- Retrieve filter data (locations and employment types) so candidates can filter.
- Link each vacancy to the PeopleForce careers site to apply.
1. Retrieve the list of vacancies
PeopleForce is the source of truth for your open vacancies.
curl https://app.peopleforce.io/api/careers/v1/vacancies \
-H "X-API-KEY: <your_career_api_key>"This returns the list of open vacancies.
2. Retrieve locations and employment types
Use these to build drop-down filters; both can be used to filter the vacancies list:
GET /api/careers/v1/locationsGET /api/careers/v1/employment_types
3. Link out to apply
Building a robust application form (file upload, field validation, and so on) is
complex, so we strongly recommend directing candidates to apply on the
PeopleForce careers site. Each vacancy in the List vacancies and Get a
vacancy responses includes an apply_url you can link to directly.
If the candidate has to stay on your own domain, see Accept applications on your own site below.
Accept applications on your own site (server-side integration)
To keep the form on your own domain, submit applications through the PeopleForce API instead of linking out.
This is a server-side integration. It needs a Company API key, which grants full access to your account. The key must never reach the browser — not in JavaScript, not in a build artifact, not in a frontend environment variable. Your page posts to your own backend, and your backend calls PeopleForce.
What you need
- A Company API key — Settings → API keys → Generate API key. See Authentication.
- A backend that can keep the key secret and hold an uploaded file long enough to forward it.
- The vacancy
id, which you already have from the Careers API vacancies list. The ids are the same across both APIs.
How it works
Submitting an application takes two calls:
- Create the candidate —
POST /api/public/v3/recruitment/candidates - Attach the candidate to the vacancy —
POST /api/public/v3/recruitment/vacancies/{vacancy_id}/applications
Treat the API reference as authoritative for the full field list and response schema. This guide covers what the reference does not: the order of the calls, the flags that matter, and the failure modes.
Before building the form, fetch the custom fields your recruiters expect — see Custom fields.
Step 1 — Create the candidate
Reference: Create a candidate
POST https://app.peopleforce.io/api/public/v3/recruitment/candidates
X-API-KEY: <your Company API key>
Content-Type: application/json{
"full_name": "Ada Lovelace",
"email": "ada@example.com",
"phone_numbers": ["+44 12 3456 6789"],
"cover_letter": "I am applying for the Senior Engineer role because…",
"location": "London, UK",
"urls": ["https://github.com/ada"],
"source_id": 12,
"consented_at": "2026-08-16T10:32:00Z",
"resume": "data:application/pdf;name=ada-cv.pdf;base64,JVBERi0xLjQKJc..."
}Only full_name is required. A candidate with neither email nor
phone_numbers is unusable to a recruiter, so require at least one contact
method on your own form.
| Field | Notes |
|---|---|
full_name | Required. Max 50 characters. Cannot contain ` ! @ # $ % ^ & * + _ =. |
email | Must be valid, and must not already belong to another candidate — see Handling duplicates. |
phone_numbers | Array of strings. Digits, spaces, (, ), +, ., - only — no letters, no x123 extensions. |
cover_letter | Plain text or HTML. |
resume | Base64 data URI — see Uploading a CV. |
consented_at, future_recruitment_consented_at | ISO 8601 timestamps — see Recording consent. |
source_id | Where the application came from — see Tracking the source. |
location, position, date_of_birth, skills, urls, telegram_username, desired_salary + currency_code, seniority_id, gender_id, tag_ids | Optional. Some are hidden unless the key may see sensitive candidate fields. |
Returns 201 Created. Keep data.id for step 2.
{
"data": {
"id": 84213,
"full_name": "Ada Lovelace",
"email": "ada@example.com",
"resume": true,
"resume_url": "https://…",
"applications": [],
"custom_fields": []
}
}Step 2 — Attach the candidate to the vacancy
Reference: Create a vacancy application
POST https://app.peopleforce.io/api/public/v3/recruitment/vacancies/{vacancy_id}/applications
X-API-KEY: <your Company API key>
Content-Type: application/json{
"applicant_id": 84213,
"perform_automations": true
}Pass perform_automations: true. It defaults to off, and it runs the
vacancy's pipeline automations — including the acknowledgement email to the
candidate, if any are configured. Without it the application lands in the
first pipeline stage and the candidate hears nothing.
applicant_state_id starts the application in a specific pipeline stage instead
of the first. Get the ids from
List pipelines.
The vacancy must be published. A draft, closed, or archived vacancy returns
404.
{
"data": {
"id": 55123,
"applicant": {
"id": 84213,
"full_name": "Ada Lovelace",
"email": "ada@example.com",
"phone_numbers": ["+44 12 3456 6789"]
},
"pipeline_state": { "id": 901, "name": "Applied" },
"created_at": "2026-08-16T10:32:04.000Z",
"updated_at": "2026-08-16T10:32:04.000Z"
}
}Applications created through the API do not notify the vacancy's
collaborators and hiring lead, unlike the PeopleForce-hosted form. The
candidate appears in the pipeline as normal and the vacancy_application_create
webhook still fires — use that
webhook if your team needs an immediate alert.
Uploading a CV
resume takes the file inline as a base64 data URI, in exactly this shape:
data:<mime-type>;name=<filename>;base64,<base64-encoded-bytes>Example: data:application/pdf;name=ada-cv.pdf;base64,JVBERi0xLjQK...
Two things break, and neither says so clearly:
- The
;name=<filename>segment is mandatory. Without it the string is no longer recognised as a file at all — the API reads it as a signed attachment id and fails with422andInvalid resume content, which looks like a corrupt upload rather than a missing filename. - Nothing may sit between the MIME type and
;name=. Acharsetparameter is swallowed into the type, which becomesapplication/pdf;charset=utf-8, matches nothing in the list below, and fails withfile type invalid.
Accepted MIME types:
| Format | MIME type |
|---|---|
application/pdf | |
| Word (.docx) | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| Word (.doc) | application/msword |
| OpenDocument (.odt) | application/vnd.oasis.opendocument.text |
| RTF | application/rtf, text/rtf, application/x-rtf, text/richtext |
The check reads the MIME type and the filename you declared — it does not look
at the bytes — and the two have to agree. data:application/pdf;name=cv.png
fails with file type invalid even though the type is an accepted one, so send
the real type and the real extension.
Enforce the same allowlist and a size limit on your own upload endpoint, so the candidate gets an immediate error instead of a round trip.
PeopleForce parses the uploaded CV and enriches the candidate profile from it.
For extra documents (portfolio, certificates) after the candidate exists, use
POST /api/public/v3/recruitment/candidates/{candidate_id}/documents
— a multipart/form-data upload in an attachment field.
Custom fields
Reference: List candidate fields
Fetch the custom candidate fields once, cache them, and render matching inputs on your form:
GET https://app.peopleforce.io/api/public/v3/recruitment/candidate_fields{
"data": [
{
"id": 41,
"name": "Notice period",
"internal_name": "notice_period",
"type": "ApplicantFields::Select",
"options": [
{ "id": 501, "value": "Immediately" },
{ "id": 502, "value": "1 month" }
]
}
]
}Send them as top-level keys named by internal_name, not nested under a
custom_fields object:
{
"full_name": "Ada Lovelace",
"email": "ada@example.com",
"notice_period": 502
}On /api/public/v3, select and multi-select fields take the option id
(502, or [502, 503]). Other types take their plain value: text as a string,
checkbox as a boolean, date as YYYY-MM-DD.
A field marked required in PeopleForce is enforced here. Omitting it fails the
whole candidate creation with 422, so mirror the requirement on your form.
Recording consent
Hosting the form yourself means you own the GDPR consent checkboxes — PeopleForce cannot render them for you. Pass the timestamps through:
consented_at— consent to process the data for this application.future_recruitment_consented_at— consent to stay on file for future vacancies.
Both are ISO 8601 ("2026-08-16T10:32:00Z"). Send
future_recruitment_consented_at only if that separate box was ticked — it
drives data retention in PeopleForce.
Tracking the source
Reference: List sources
Candidates created through the API have no source unless you set one, which leaves them out of source-effectiveness reporting. Fetch the list once and cache the id:
GET https://app.peopleforce.io/api/public/v3/recruitment/sourcesPick the source for your site, or create one with
POST /api/public/v3/recruitment/sources ({"name": "Company website"}), and
pass its id as source_id on the candidate.
If your career page also lands paid campaigns, keep reading the source_id
query parameter you already use for the hosted form and map it to the matching
PeopleForce source.
Handling duplicates
PeopleForce blocks duplicate candidates. Step 1 returns 422 when:
- the email already belongs to another candidate;
- a phone number already belongs to another candidate, if Detect duplicates by phone number is enabled in your recruiting settings;
- the same CV file is already attached to another candidate.
{
"success": false,
"errors": ["Email already exists for Ada Lovelace"]
}A returning candidate is the normal case. Look them up (List candidates) and reuse them:
GET https://app.peopleforce.io/api/public/v3/recruitment/candidates?email=ada@example.comThe email filter matches on a prefix, so ada@example.co also returns
ada@example.com. Pick the row whose email equals the address exactly — never
just data[0] — then go to step 2 with its id.
If that person has already applied to this vacancy, step 2 returns 422
with "Applicant has already been taken". They are already in the pipeline, so
show "you have already applied to this role" rather than an error.
To refresh their CV and contact details with what they just submitted, call
PUT /api/public/v3/recruitment/candidates/{id}
before step 2.
Looking a candidate up by phone instead? That filter takes a list, so it
needs brackets in the query string: ?phone_numbers[]=%2B441234567890.
Without them the value arrives as a single string and the request fails with
400. This applies to query strings only — in the JSON bodies above, a list
is just a JSON array.
Handling failures
| Status | Meaning | What to do |
|---|---|---|
400 | A parameter has the wrong type | Read errors — each entry names the parameter and the type it expected |
401 | Missing, wrong, or disabled API key | Check the X-API-KEY header. Do not retry. |
403 | Key lacks permission, or its IP allowlist excludes your server | Add your server's egress IP to the key's allowlist. |
404 | Vacancy is not published, or the candidate id is wrong | Refresh your vacancy list. |
422 | Duplicate candidate, already applied, bad file type, or missing required custom field | Resolve as described above. Do not retry unchanged. |
429 | Rate limited | Back off and retry, honouring Retry-After. |
5xx | Temporary server error | Retry with exponential backoff. |
The dangerous state is step 1 succeeded and step 2 failed: a candidate with no application. Do not re-create the candidate on retry — retry step 2 with the id you already hold, and log that id so a failure outliving the request can be reconciled later.
Rate limits
The API follows the usual rate limit rules —
300 requests a minute per API key. Honour Retry-After on a 429 and queue
submissions instead of retrying in a tight loop.
Security checklist
Your form is a publicly writable endpoint into your recruiting database. The protections the hosted form provides are now yours to build:
- Keep the Company API key server-side. Never proxy it to the browser, and never forward whatever the browser sends.
- Add bot protection — reCAPTCHA, Turnstile, or equivalent — verified on your server before the PeopleForce calls.
- Rate-limit per visitor IP on your own endpoint, independently of the PeopleForce limit.
- Validate uploads before forwarding: extension, MIME type, maximum size.
- Never echo PeopleForce error messages verbatim. A duplicate error is HTML and contains the existing candidate's name, linked to their profile, which discloses that they applied to you.
- Restrict the key by IP in PeopleForce settings, to your server's egress addresses.
Complete example
// POST /apply
const PF = "https://app.peopleforce.io/api/public/v3";
const headers = {
"X-API-KEY": process.env.PEOPLEFORCE_API_KEY,
"Content-Type": "application/json",
};
async function findCandidateByEmail(email) {
// Email is optional on the form, and a duplicate can be raised on phone or
// CV instead — so this is reachable with nothing to look up.
if (!email?.trim()) return null;
const res = await fetch(`${PF}/recruitment/candidates?email=${encodeURIComponent(email)}`, { headers });
const body = await res.json();
// The filter is a prefix match, so "ada@example.co" also returns
// "ada@example.com". Take the exact address, never just the first row.
const wanted = email.trim().toLowerCase();
return body.data?.find((c) => c.email?.toLowerCase() === wanted)?.id ?? null;
}
async function createCandidate(form, file) {
const payload = {
full_name: form.fullName,
email: form.email,
phone_numbers: form.phone ? [form.phone] : [],
cover_letter: form.coverLetter,
source_id: Number(process.env.PEOPLEFORCE_SOURCE_ID),
consented_at: form.consentedAt, // when the box was ticked
notice_period: form.noticePeriodOptionId, // custom field, by internal_name
};
if (file) {
payload.resume =
`data:${file.mimetype};name=${file.originalname};base64,${file.buffer.toString("base64")}`;
}
const res = await fetch(`${PF}/recruitment/candidates`, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
if (res.status === 201) return (await res.json()).data.id;
if (res.status === 422) {
const { errors = [] } = await res.json();
// 422 is also a bad file type or a missing required custom field, so only
// a duplicate is safe to recover from. The message is "… already exists
// for <candidate>" for email, phone and CV alike.
if (!errors.some((e) => /already exists/i.test(e))) throw new ValidationError(errors);
const existingId = await findCandidateByEmail(form.email);
if (existingId) return existingId;
// Duplicate phone or CV rather than email — the person exists, but not at
// an address we can resolve them by.
throw new ValidationError(errors);
}
throw new Error(`Candidate creation failed: ${res.status}`);
}
async function createApplication(vacancyId, candidateId) {
const res = await fetch(`${PF}/recruitment/vacancies/${vacancyId}/applications`, {
method: "POST",
headers,
body: JSON.stringify({
applicant_id: candidateId,
perform_automations: true,
}),
});
if (res.status === 422) {
// The only 422 this endpoint returns is the one-application-per-vacancy
// rule — but check, rather than swallowing every validation error as
// "already applied".
const { errors = [] } = await res.json();
if (errors.some((e) => /already been taken/i.test(e))) return { alreadyApplied: true };
throw new ValidationError(errors);
}
if (res.status !== 201) {
throw new Error(`Application failed for candidate ${candidateId}: ${res.status}`);
}
return (await res.json()).data;
}
app.post("/apply", upload.single("resume"), async (req, res) => {
if (!(await verifyCaptcha(req.body.captchaToken))) {
return res.status(400).render("apply", { error: "Please confirm you are not a robot." });
}
let candidateId;
try {
candidateId = await createCandidate(req.body, req.file);
const result = await createApplication(req.body.vacancyId, candidateId);
if (result.alreadyApplied) return res.render("already-applied");
res.render("thank-you");
} catch (err) {
// candidateId set but the application failed is the state worth logging:
// a candidate with no application, to reconcile later.
logger.error({ err, candidateId }, "career application submission failed");
if (err instanceof ValidationError) {
// Your own wording — never PeopleForce's, which is HTML and can name an
// existing candidate.
return res.status(400).render("apply", { error: "Please check your details and try again." });
}
res.status(500).render("apply", { error: "We could not submit your application. Please try again." });
}
});The same calls with curl:
# 1. Create the candidate
curl -X POST https://app.peopleforce.io/api/public/v3/recruitment/candidates \
-H "X-API-KEY: $PEOPLEFORCE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"full_name": "Ada Lovelace",
"email": "ada@example.com",
"phone_numbers": ["+44 12 3456 6789"],
"consented_at": "2026-08-16T10:32:00Z",
"resume": "data:application/pdf;name=ada-cv.pdf;base64,JVBERi0xLjQK..."
}'
# 2. Attach them to the vacancy
curl -X POST https://app.peopleforce.io/api/public/v3/recruitment/vacancies/1234/applications \
-H "X-API-KEY: $PEOPLEFORCE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "applicant_id": 84213, "perform_automations": true }'Which approach should you choose?
Link to apply_url | Your own form + API | |
|---|---|---|
| Effort | A hyperlink | A backend endpoint, file handling, error states |
| API key | Career API key (publishable) | Company API key (server-side only) |
| Candidate stays on your domain | No | Yes |
| Bot protection | Built in | You build it |
| File upload and validation | Built in | You build it |
| GDPR consent UI | Built in, follows your settings | You build it |
| Recruiter notification on new application | Yes | Webhook only |
