Own career site integration
A quick guide to help you display your open vacancies on your own website.
This guide details how to display open vacancies from PeopleForce on a company website.
If you are a representative of a job board and would like to integrate your product with PeopleRecruit please refer to Job boards integration
You don't have to use career site provided by PeopleForce . Whether you already have your own website with a career site or plan to build one - we've got you covered 🙌.
Pre-requisites
We have a dedicated Career API for integrating vacancies into an external site, which ensures only non-sensitive vacancy data is available. To use our Career API you will need to create a Career API key which is detailed in the API authentication page.
Warning: Only use a Career API key for public-facing websites to avoid exposing worker data
Business Logic
For the best integration, we suggest the following steps:
- Retrieve the list of vacancies from PeopleForce.
- Retrieve filter data (location & employment type) to enable candidates to filter positions.
- Link to the PeopleForce careers site to apply for a role.
Retrieve the list of vacancies from PeopleForce
PeopleForce is the source of truth for your open vacancies.
To get the list of open vacancies, use https://app.peopleforce.io/api/careers/v1/vacancies
To learn more, check this endpoint in our API documentation: List all vacancies
Retrieve locations and employment type data from PeopleForce
We provide endpoints for retrieving all locations and employment types, allowing you to create drop-down lists for candidates to filter vacancies by. Locations and employment types can be used as filters on the list vacancies endpoint.
Link to career site to apply for a role
Building a form for applying to a vacancy is highly complex (involving file upload, many field validations etc) so. we strongly recommend directing candidates to apply for a vacancy directly on the PeopleForce career site. In the response to the List all vacancies & get a vacancy endpoints we return an apply_url, which can be used to link directly to the vacancy on the PeopleForce career site.
Accept applications on your own site (server-side integration)
To keep the form on your own domain - submit applications through the PeopleForce API instead.
This is a server-side integration. It requires 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.
sequenceDiagram
autonumber
participant C as Candidate browser
participant S as Your backend
participant PF as PeopleForce API
C->>S: POST /apply (form fields + CV file)
Note over C,S: The Company API key never crosses this boundary
S->>S: Verify captcha, check file type and size
S->>PF: POST /recruitment/candidates (X-API-KEY)
alt New candidate
PF-->>S: 201 Created, data.id
else Email already exists
PF-->>S: 422 Email already exists
S->>PF: GET /recruitment/candidates?email=
PF-->>S: 200 OK, existing candidate id
end
S->>PF: POST /recruitment/vacancies/{vacancy_id}/applications (X-API-KEY)
PF-->>S: 201 Created
S-->>C: Thank you page
Pre-requisites
- A Company API key — Settings → API keys → Generate API key. See API authentication.
- A backend that can keep the key secret and buffer an uploaded file long enough to forward it.
- The vacancy
id, which you already have from List all vacancies. The ids are the same across both APIs.
Business logic
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
Each step below links to its reference page. Treat the reference as authoritative for the full field list and response schema; this guide covers only 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": "[email protected]",
"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": "2025-11-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 exist in your account — 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 has permission to see sensitive candidate fields. |
Returns 201 Created. Keep data.id for step 2.
{
"data": {
"id": 84213,
"full_name": "Ada Lovelace",
"email": "[email protected]",
"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 vacancy pipelines.
The vacancy must be published. A draft, closed or archived vacancy returns 404.
201 Created:
{
"data": {
"id": 55123,
"applicant": {
"id": 84213,
"full_name": "Ada Lovelace",
"email": "[email protected]",
"phone_numbers": ["+44 12 3456 6789"]
},
"pipeline_state": { "id": 901, "name": "Applied" },
"created_at": "2025-11-16T10:32:04.000Z",
"updated_at": "2025-11-16T10:32:04.000Z"
}
}Note: 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_createwebhook 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...
Three things break silently:
- The
;name=<filename>segment is mandatory. A plaindata:application/pdf;base64,…is rejected. - Send the base64 as one unbroken string. Do not wrap at 76 characters and do not insert spaces.
- Do not add
charsetor any other parameter between the MIME type and;name=.
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 |
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": "[email protected]",
"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 ("2025-11-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 Unprocessable Content 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/[email protected]Take data[0].id and go to step 2.
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 you can show "you have already applied to this role" rather than an error.
To refresh their CV and contact details with what they just submitted, PUT /api/public/v3/recruitment/candidates/{id} before step 2.
Handling failures
| Status | Meaning | What to do |
|---|---|---|
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 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 rate limit rules. Honour Retry-After on 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. Duplicate errors contain the existing candidate's name, 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) {
const res = await fetch(`${PF}/recruitment/candidates?email=${encodeURIComponent(email)}`, { headers });
const body = await res.json();
return body.data?.[0]?.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();
// A returning candidate is the expected 422, not a failure.
const existingId = await findCandidateByEmail(form.email);
if (existingId) return existingId;
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) {
return { alreadyApplied: true };
}
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." });
}
try {
const candidateId = await createCandidate(req.body, req.file);
await createApplication(req.body.vacancyId, candidateId);
res.render("thank-you");
} catch (err) {
logger.error({ err }, "career application submission failed");
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": "[email protected]",
"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 |
| New PeopleForce form features | Automatic | You integrate them |
Updated 10 days ago

