PeopleForce

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.

  1. Retrieve the list of vacancies from PeopleForce.
  2. Retrieve filter data (locations and employment types) so candidates can filter.
  3. 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/locations
  • GET /api/careers/v1/employment_types

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:

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

FieldNotes
full_nameRequired. Max 50 characters. Cannot contain ` ! @ # $ % ^ & * + _ =.
emailMust be valid, and must not already belong to another candidate — 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 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 with 422 and Invalid resume content, which looks like a corrupt upload rather than a missing filename.
  • Nothing may sit between the MIME type and ;name=. A charset parameter is swallowed into the type, which becomes application/pdf;charset=utf-8, matches nothing in the list below, and fails with file type invalid.

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

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.

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/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 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.com

The 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

StatusMeaningWhat to do
400A parameter has the wrong typeRead errors — each entry names the parameter and the type it expected
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 the 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 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_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

On this page