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:

  1. Retrieve the list of vacancies from PeopleForce.
  2. Retrieve filter data (location & employment type) to enable candidates to filter positions.
  3. 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:

  1. Create the candidatePOST /api/public/v3/recruitment/candidates
  2. Attach the candidate to the vacancyPOST /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.

FieldNotes
full_nameRequired. Max 50 characters. Cannot contain ` ! @ # $ % ^ & * + _ =.
emailMust be valid and must not already exist in your account — see Handling duplicates.
phone_numbersArray of strings. Digits, spaces, (, ), +, ., - only — no letters, no x123 extensions.
cover_letterPlain text or HTML.
resumeBase64 data URI — see Uploading a CV.
consented_at, future_recruitment_consented_atISO 8601 timestamps — see Recording consent.
source_idWhere 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_idsOptional. 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_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...

Three things break silently:

  • The ;name=<filename> segment is mandatory. A plain data: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 charset or any other parameter between the MIME type and ;name=.

Accepted MIME types:

FormatMIME type
PDFapplication/pdf
Word (.docx)application/vnd.openxmlformats-officedocument.wordprocessingml.document
Word (.doc)application/msword
OpenDocument (.odt)application/vnd.oasis.opendocument.text
RTFapplication/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/sources

Pick 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

StatusMeaningWhat to do
401Missing, wrong, or disabled API keyCheck the X-API-KEY header. Do not retry.
403Key lacks permission, or its IP allowlist excludes your serverAdd your server's egress IP to the key's allowlist.
404Vacancy is not published, or candidate id is wrongRefresh your vacancy list.
422Duplicate candidate, already applied, bad file type, or missing required custom fieldResolve as described above. Do not retry unchanged.
429Rate limitedBack off and retry, honouring Retry-After.
5xxTemporary server errorRetry 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_urlYour own form + API
EffortA hyperlinkA backend endpoint, file handling, error states
API keyCareer API key (publishable)Company API key (server-side only)
Candidate stays on your domainNoYes
Bot protectionBuilt inYou build it
File upload and validationBuilt inYou build it
GDPR consent UIBuilt in, follows your settingsYou build it
Recruiter notification on new applicationYesWebhook only
New PeopleForce form featuresAutomaticYou integrate them

Did this page help you?