Skip to content
English
  • There are no suggestions because the search field is empty.

Building a custom careers page with the Public Careers API

List your live vacancies, show your application forms and accept applications on your own website by calling the Recruit careers endpoints directly.

List your live vacancies, show your application forms and accept applications on your own website by calling the Recruit careers endpoints directly.

This guide is for the web developers who build or maintain your careers site. It covers the public careers endpoints: no login, no key and no candidate account are needed to call them, so they are safe to use from a public page. If you want to set up the careers page that Recruit hosts for you, see Careers portal settings and branding instead.

Choose an approach first

There are three ways to show your vacancies on your own site.

  • Embed the hosted careers page. Recruit hosts the whole page. You paste an embed snippet into your site, copied from Settings > Company > Careers Page. Branding, filters, the application form, file uploads and the confirmation email all work and keep working when Recruit changes. Most organisations should stop here.
  • Build your own front end. You call the endpoints in this guide and draw everything yourself. You control the markup and styling, but you also own the maintenance. Application forms are edited inside Recruit, so your code has to build each form from the form definition and not from a fixed list of fields.
  • Pull a job feed from your server. Fetch vacancies from your own server, cache them, and link each one to the hosted careers page for the application itself. This needs no cross-origin set-up and has the fewest moving parts.

Before you start

You need three things.

  • Your careers slug. It is the last part of your careers page address. In https://app.hireroadrecruit.com/careers/acme the slug is acme. It appears as {slug} below and does not change.
  • The base address. Every request goes to https://api.hireroadrecruit.com, and every path in this guide starts with /api/.
  • Your website allowed for browser calls. Browsers block calls from other websites unless the site is on Recruit's list of allowed origins. Your site is not on it by default. Send your account manager the exact origins you need, including the scheme, for example https://www.yourcompany.com and https://staging.yourcompany.com. The change is made by HireRoad and has to be scheduled, so ask at the start of the project and not the week you go live.

You do not need your origin allowed if you embed the hosted page, or if you call from your own server.

How responses and errors look

Every JSON response is wrapped. Read what you need from data.

{
  "success": true,
  "data": { }
}

Errors use the same wrapper with success set to false:

{
  "success": false,
  "error": {
    "code": "MISSING_REQUIRED_FIELD",
    "message": "Required field \"Right to work\" is missing",
    "validationErrors": [
      { "field": "email", "message": "email must be an email", "code": "INVALID" }
    ]
  }
}

Branch on error.code. The message wording can change. validationErrors only appears on some errors.

  • Unknown fields are rejected. A query parameter or body field Recruit does not recognise returns 400 VALIDATION_ERROR. Build request bodies field by field and do not forward a whole form state object.
  • Size. A JSON request body can be up to 2 MB. Files go through a separate upload call.
  • Dates. Every date is an ISO 8601 string in UTC, for example 2026-08-14T09:00:00.000Z.
  • Caching. Responses carry no cache headers. If you render on a server, cache the job list for a few minutes. That keeps your page quick without leaving closed vacancies on show for long.
  • Features that differ by organisation. Read fields such as jobAlertsEnabled, applyButtonEnabled, saveAndReturnEnabled, registerYourInterest.enabled and show or hide the matching part of your page. Do not assume they are on.
  • Language. The job list and filter calls accept an optional language parameter, en-GB or cy. The careers settings list the languages your organisation offers in availableLanguages.

Careers page settings and filters

GET /api/public/careers/{slug}

Returns your branding and the display options set in Settings > Company > Careers Page. Call it once when your page loads.

FieldNotes
companyName
logoUrl, headerImageUrlOptional
brandColorHex colour, for example #6366F1. Optional
headlineDefaults to Join Our Team
tagline, intro
metaTitle, metaDescriptionThe page title defaults to Careers
socialLinksObject with optional linkedin, twitter and glassdoor addresses
privacyPolicyUrlOptional. Use it for your privacy link
jobAlertsEnabledShow the job alerts sign-up when true
registerYourInterestObject: enabled, pageHeader, formTemplateId
locationImagesEnabledWhen true, jobs can carry a location picture
availableLanguagesLanguages you can pass as language

A set of true or false switches follows, all false unless your team turns them on. Each one means hide: hideHeader, hideBanner, hidePrivacyPolicyLink, hideDepartmentFilter, hideJobTypeFilter, hideJobTimeFilter, hideLocationFilter, hideLocationSearch, hideJobTitleSearch, hideVacancyReference. Four more apply to the job alerts form: jobAlertsHideDepartmentFilter, jobAlertsHideJobTypeFilter, jobAlertsHideJobTimeFilter, jobAlertsHideLocationFilter. Honour them. Your recruitment team sets them expecting the same result on your site as on the hosted page.

GET /api/public/careers/{slug}/departments

Returns a list of department names that have at least one live vacancy, in alphabetical order. Use it to fill a department filter without empty options.

GET /api/public/careers/{slug}/facets

Returns the other filter values found on live vacancies, with blanks removed.

{
  "success": true,
  "data": {
    "jobTypes": ["full_time", "part_time"],
    "jobTimes": ["Days", "Nights"],
    "locations": ["Leeds", "Manchester"]
  }
}

Pass these values back as employmentType, jobTime, location on the job list.

Vacancies

GET /api/public/careers/{slug}/jobs

Returns live, externally published vacancies, newest published first. Internal only roles, hidden roles, and roles past their closing date or posting expiry are left out for you.

ParameterDefaultNotes
page1Minimum 1
limit201 to 100
searchMatches the job title only, not the description. Up to 200 characters
departmentExact match. Up to 100 characters
employmentTypeExact match, one of the values below
jobTimeExact match. Up to 100 characters
locationExact match. Up to 200 characters. Use it for a drop-down built from facets
locationSearchPartial match on location. Up to 200 characters. Use it for a free text box
languageen-GB or cy

Allowed employmentType values: full_time, part_time, contract, temporary, internship, permanent, casual, bank, fixed_term, contractor, self_employed, apprenticeship, voluntary, placement, graduate. Anything else returns 400 VALIDATION_ERROR.

Paging details sit inside data:

{ "jobs": [ ], "total": 42, "page": 1, "limit": 20, "totalPages": 3 }

Each vacancy in jobs has:

FieldNotes
idUse it for the detail, form and apply calls
title
departmentOptional
location
locationImageUrlOptional. Present when location pictures are on and the location has one
employmentTypeOne of the values above
salaryMin, salaryMax, salaryCurrencyOptional
salaryPeriodOptional. Tells you whether to show per hour, per month and so on
salaryTypeOptional. A descriptor such as Competitive, used in place of a range
publishedAtEmpty if never published
closingDateOptional
internalReferenceYour vacancy reference. Optional

Show salary defensively. A vacancy might have a range, a descriptor such as Competitive, or nothing. Check salaryType first, then the minimum and maximum, and show nothing if neither is there.

GET /api/public/careers/acme/jobs?page=1&limit=20&department=Finance
Host: api.hireroadrecruit.com

GET /api/public/careers/{slug}/jobs/{jobId}

Returns everything in the list entry plus:

FieldNotes
description, requirements, benefitsHTML written in the rich text editor. Requirements and benefits are optional
applicationFormPagesOrdered page names. More than one means a multi-page form
attachmentsOptional. Each has documentId, filename, mimeType and sizeBytes
applyButtonEnabledWhether to show an apply button

Clean the HTML before you put it on your page, and style it so headings and lists display properly. On this call location and employmentType can be missing if your organisation hides those details on the vacancy page.

  • closingDate is the effective close: the earlier of the vacancy's own closing date and your careers page posting expiry. It can be earlier than the date your team sees in Recruit.
  • A closed vacancy still returns a normal response, so an old link can land on a page that says the vacancy has closed. Compare closingDate with the current time and show that message. An application to it is refused anyway.

GET /api/public/careers/{slug}/jobs/{jobId}/attachments/{documentId}/download

Returns the file itself, not JSON. Link to this address directly. It allows 5 requests per minute per IP address, and every failure returns the same 404 DOCUMENT_NOT_AVAILABLE, so you cannot tell a wrong id from a withdrawn file.

Taking an application

Applying is three calls, in this order, because each returns something the next one needs.

  1. Get the application form. This also gives you a token for uploads.
  2. Upload each file. Each upload returns a document id.
  3. Submit the answers, including the document ids.

Skipping the first call is the most common mistake. The upload token is only issued there.

Step 1: get the application form

GET /api/public/careers/{slug}/jobs/{jobId}/application-form

Forms are set up in Recruit and differ by vacancy. Build the form from this response and never from a fixed list of fields, or your site breaks the first time someone edits a form.

FieldNotes
fieldsThe form fields, in order
pagesOrdered page names
pageConditionsRules that show or hide a whole page, in the same style as showWhen below
sourcesOptions for How did you hear about us, each with a name and a slug
gapAnalysisEmployment gap settings, described below
gapReasonsEnabled, monthYearDatesEnabledWhether to collect a reason for each gap, and whether history dates are month and year only
uploadSessiontoken, expiresAt, maxFileSize and allowedMimeTypes
saveAndReturnEnabledWhether to offer save and finish later

Each field has:

FieldNotes
idSend this back as fieldId. Do not send fieldKey
fieldKeyOptional name that says what the field means, such as first_name, email, cv or cover_letter
label, type, required
order, pageOrder within the page, and the zero-based page number matching pages
placeholder, helpTextOptional
optionsChoices for drop-down, multi-select and checkbox fields
showWhenOptional rule that decides when the field appears
requiredWhenOptional rule that makes the field required only in some cases
repeatable, repeatableGroupId, repeatableGroupLabelMark a field as part of a repeating group
repeatableMin, repeatableMaxFewest and most entries a repeating group takes
minChars, maxChars, minWords, maxWordsOptional length limits on text answers
contentKind, contentUrl, contentCaption, contentBodyUsed by display blocks. The kind is image, video or text

Field types are text, email, phone, url, textarea, file, dropdown, checkbox, date, multi_select, content, signature, image, heading. The content and heading types are for display only. Show them and send no answer for them.

  • Conditional fields. Check each field's showWhen rule against the answers so far and hide the field when the rule is not met. A hidden required field is not enforced on submission, and answers to hidden fields are thrown away. If you show every field, candidates get stopped by requirements that should not apply to them.
  • Repeating groups. Fields that share a repeatableGroupId make a block the candidate can add to, usually employment or education history. Send one answer per field per entry and number the entries from zero with entryIndex.
  • Employment gaps. Only look for gaps when gapAnalysis.enabled is true. Then look back lookBackYears years across the dates the candidate enters, flag any gap at least as long as the threshold (thresholdMonths months, or thresholdDays days when thresholdUnit is days), and collect an explanation where requireExplanations is true. Send the explanations in gapExplanations, or gapReasons where the form uses reasons.
  • Upload session. The token belongs to this one vacancy and lasts one hour. Read maxFileSize (in bytes) and allowedMimeTypes from the response rather than copying today's values into your code, and check files in the browser before uploading.

Step 2: upload a file

POST /api/public/careers/{slug}/jobs/{jobId}/upload

Send multipart form data, once for each file field. The parts are:

  • file: the file itself
  • fieldId: the id of the file field
  • sessionToken: the token from the form response

The default limits are 10 MB per file, and PDF, Word (.doc and .docx), RTF, plain text, OpenDocument text, PNG and JPEG files. Recruit checks the contents of the file as well as the declared type, so an .exe renamed to .pdf is refused with 400 INVALID_FILE_TYPE.

{
  "documentId": "doc_...",
  "filename": "jane-smith-cv.pdf",
  "size": 184320,
  "mimeType": "application/pdf"
}

Keep the documentId. The file is not attached to anything until you send it in step 3. Uploading again with the same token and field replaces the earlier file. Files that are never submitted are deleted after 7 days. Uploads are limited to 30 per minute and 60 per five minutes for each IP address.

POST /api/public/careers/acme/jobs/JOB_ID/upload
Host: api.hireroadrecruit.com
Content-Type: multipart/form-data

file: (the file, for example jane-smith-cv.pdf)
fieldId: FIELD_ID
sessionToken: SESSION_TOKEN

If the hour runs out, the upload returns 401 with the code UPLOAD_SESSION_EXPIRED. Fetch the application form again for a fresh token, or renew the session, and retry. Keep what the candidate has typed while you do.

POST /api/public/careers/{slug}/jobs/{jobId}/upload-session/renew

Send { "sessionToken": "..." } to get a new session. Files already uploaded stay attached. A renewed session has a maximum total lifetime, so a candidate who leaves a form open for days will need to start the uploads again. This call is limited to 30 per minute.

Step 3: submit the application

POST /api/public/careers/{slug}/jobs/{jobId}/apply

Send JSON with these fields.

FieldRequiredNotes
fullNameYesUp to 200 characters
emailYesUp to 254 characters
gdprConsentYesMust be true
responsesYesThe answers. Can be empty. Up to 500 entries
phoneNoInternational format, for example +447700900123
coverLetterNoUp to 10,000 characters
sessionTokenIf any answer carries a documentIdFrom the form response
gapExplanations, gapReasonsNoJSON text keyed by date range, formatted startDate_endDate. Up to 10,000 characters each
sourceNoThe slug of the option picked from sources. Up to 50 characters
sourceDetailsNoFree text for an Other source
channelNoThe job board the candidate came from. Up to 50 characters
referralLinkIdNoEmployee referral link id, if the address carried one
internalTokenNoNeeded to apply for a vacancy that is only on the internal careers page

Each entry in responses has:

FieldNotes
fieldIdRequired. The id from step 1
valueText answers, up to 10,000 characters
selectedOptionsFor multi-select and checkbox fields, up to 200 entries
documentIdFor file fields, from step 2
entryIndexZero-based entry number in a repeating group
  • Phone numbers need the country code. 07700 900123 is rejected. Convert it to +447700900123 first. This is the most common reason a custom form fails.
  • Attribution. If your links carry a src parameter, send its value as channel. It takes priority over source. A source that is not in the sources list returns 400 INVALID_CANDIDATE_SOURCE.
  • Confirmation email. Recruit sends the acknowledgement. Do not send your own or the candidate gets two.
  • A 201 response means the application was accepted, not that the candidate passed any screening questions. A candidate who fails a knock-out question still gets a 201 and the application is marked for your recruiters. Show the same confirmation either way.
  • Do not retry blindly. A second submission with the same email returns 409 DUPLICATE_APPLICATION.
POST /api/public/careers/acme/jobs/JOB_ID/apply
Host: api.hireroadrecruit.com
Content-Type: application/json

{
  "fullName": "Jane Smith",
  "email": "jane.smith@example.com",
  "phone": "+447700900123",
  "gdprConsent": true,
  "sessionToken": "SESSION_TOKEN",
  "channel": "indeed",
  "responses": [
    { "fieldId": "FIELD_FIRST_NAME", "value": "Jane" },
    { "fieldId": "FIELD_CV", "documentId": "doc_..." },
    { "fieldId": "FIELD_LANGUAGES", "selectedOptions": ["English", "Welsh"] }
  ]
}

A successful call returns applicationId and a message. The apply call is limited to 20 per minute, and 60 per 15 minutes, for each IP address.

Save and return

Save and return lets a candidate fill in what they can, receive a private link by email and finish later. Offer it only when saveAndReturnEnabled is true in the application form response.

POST /api/public/careers/{slug}/jobs/{jobId}/save-draft

The body is the same as the apply call without the gap fields. Only name, email and consent are checked, so unfinished later pages are accepted. The response gives resumeLink and resumeExpiresAt. The candidate also gets the link by email, and it runs out in line with the vacancy's closing date, so tell them when. No application exists until the candidate submits. Save draft is limited to 20 per minute for each IP address.

The link opens the resume page hosted by Recruit, which handles the rest. If you would rather host that step yourself, GET /api/public/resume/{token} returns the vacancy, the form fields and the candidate's answers, and POST /api/public/resume/{token}/submit takes { "responses": [ ] } in the same shape as the apply call.

Register your interest

This is a talent pool sign-up for people who want to hear about future roles. Show it only when registerYourInterest.enabled is true in the careers page settings.

GET /api/public/careers/{slug}/register-interest/form

Returns the form your team chose in Settings > Company > Careers Page, as fields and, where it has file fields, an uploadSession. Files go to POST /api/public/careers/{slug}/register-interest/upload, which takes the same three parts as a job upload. This call is limited to 30 per minute.

POST /api/public/careers/{slug}/register-interest

FieldRequiredNotes
emailYesUp to 254 characters
gdprConsentYesMust be true
firstName, lastName, phoneNoPhone in international format
responsesNoAnswers to the form above, in the same shape as the apply call

The response is a short confirmation message. An email that is already registered returns 409 ALREADY_EXISTS. Show your normal confirmation for that too, so your form never tells a stranger that an address is in your talent pool. Limited to 5 per minute for each IP address.

Job alerts

Candidates can ask for an email when a matching vacancy is published. Show the sign-up only when jobAlertsEnabled is true.

POST /api/public/careers/{slug}/job-alerts

FieldRequiredNotes
firstName, lastNameYes
emailYesUp to 254 characters
criteriaNoObject with departments, jobTypes, jobTimes and locations, each a list of text values. Up to 20 values per list, each up to 100 characters

Fill the choices from the departments and facets calls so candidates only pick values that exist. The response is { "subscribed": true } whether or not the address was already on the list. Confirmation and unsubscribe links are in the emails Recruit sends, so you build nothing for those. Limited to 5 per minute for each IP address.

Group careers feed

If your organisation runs several companies under one group, the group feed returns vacancies from all of them in one list, each marked with the company that owns it. The slug is always the parent company's.

  • GET /api/public/careers/{slug}/group/jobs
  • GET /api/public/careers/{slug}/group/departments
  • GET /api/public/careers/{slug}/group/facets

The jobs call takes the same parameters as the standard list. The response shape is different. data is a plain list, and the paging details are in a meta object beside it:

{
  "success": true,
  "data": [ ],
  "meta": { "total": 42, "page": 1, "limit": 20, "totalPages": 3 }
}

A paging helper written for the standard list needs its own branch for this one. Each vacancy has the usual list fields plus owningCompany, with its slug, name, logoUrl, brandColor. Link each vacancy to the owning company's page, /careers/{owningCompany.slug}/jobs/{id}, because the parent's slug will not find it. Group results do not include locationImageUrl. If the group feed is not set up, all three calls return 404 NOT_FOUND.

Internal careers page

The internal careers page shows vacancies to your own staff, including roles that are never advertised outside. Anyone who holds its private token can see it. The token is created under Internal Careers Page in the careers settings.

  • GET /api/public/careers/{slug}/internal/{token}
  • GET /api/public/careers/{slug}/internal/{token}/departments
  • GET /api/public/careers/{slug}/internal/{token}/facets
  • GET /api/public/careers/{slug}/internal/{token}/jobs

These match their external versions but follow the internal page's own hide switches. For an internal only vacancy, send the token as internalToken on the job detail, application form and download calls, and in the body of the upload renewal, save draft and apply calls. Keep the token on your server, put the internal board behind your intranet login, and never place the token in browser code on a public page. A wrong or switched off token returns the same 404 NOT_FOUND as an unknown company.

Server to server job feed

Servers can use a key instead of a slug.

  • GET /api/v1/jobs
  • GET /api/v1/jobs/{jobId}
  • GET /api/v1/register-your-interest

Send the key in an X-Api-Key header. The key identifies your organisation, so there is no slug in the address. The jobs call takes the same parameters and returns the same shape as the public list, without internal only vacancies.

  • Only System Admin users can create, list or revoke keys. Keys begin nrk_. The full key is shown once when it is created and cannot be shown again, so store it in a secret manager straight away. Ask your account manager if you need help creating one.
  • A key can only read these three calls. It cannot see candidates or applications and cannot change anything.
  • A key does not expire. It works until it is revoked, so treat it as a long-lived password.
  • Use it from a server only. Never put it in browser code or a mobile app.

For most sites the public calls above, made from your server, do the same job with nothing to keep secret.

Error codes

StatusCodeMeaning
400VALIDATION_ERRORFailed validation, or an unknown field was sent. See validationErrors
400MISSING_REQUIRED_FIELDA required form field was empty. The message names it
400JOB_CLOSEDThe vacancy no longer takes applications
400GDPR_CONSENT_REQUIREDgdprConsent was not true
400INVALID_CANDIDATE_SOURCEsource is not one of the values in sources
400INVALID_REFERRAL_LINK, REFERRAL_EXPIREDThe referral link is for a different vacancy, or has expired
400SESSION_TOKEN_REQUIREDFile answers were sent without sessionToken
400INVALID_DOCUMENT_REFERENCEA documentId does not belong to this session and field. Upload it again
400INVALID_FIELDfieldId on an upload is unknown or is not a file field
400INVALID_FILE_TYPE, INVALID_FILE_SIZEThe file type is not accepted, its contents do not match, or it is too large
400FILE_REQUIREDThe upload had no file part
400INVALID_INPUTMalformed input, for example gapExplanations that is not valid JSON
401UPLOAD_SESSION_EXPIRED, UNAUTHORIZEDThe upload token has expired, is invalid, or is for a different vacancy
404NOT_FOUNDCompany, vacancy or group careers page not found
404CAREERS_PAGE_NOT_FOUNDThe slug could not be matched during an upload
404DOCUMENT_NOT_AVAILABLEThe attachment is not available
409DUPLICATE_APPLICATIONThis email has already applied for this vacancy
409ALREADY_EXISTSThis email has already registered its interest
409CANDIDATE_UNAVAILABLEThe candidate record could not be created. Safe to retry
413INVALID_FILE_SIZEUpload rejected for being too large before it was checked
429RATE_LIMIT_EXCEEDEDToo many requests. Wait and try again
500UPLOAD_FAILEDFile storage failed. Safe to retry
500INTERNAL_ERRORUnexpected error

Rate limits

Limits apply to each IP address. Everything shares a general limit of 100 requests per minute, and some calls have a tighter one.

CallLimit
Submit application20 per minute and 60 per 15 minutes
Save draft20 per minute
Upload a file30 per minute and 60 per five minutes
Renew an upload session, and the register interest form30 per minute
Register interest5 per minute
Job alerts sign-up5 per minute
Download an attachment5 per minute
Everything else100 per minute

Going over a limit returns 429 RATE_LIMIT_EXCEEDED. If you render on a server, all your visitors share one IP address as far as Recruit can tell, so cache the job list and do not fetch it for every page view. Candidates on a shared office network can also reach the application limit together, so show a clear wait a moment message and not a general error.

Common mistakes

  • Reading from the top level of the response and not from data.
  • Expecting the same paging shape from the group feed.
  • Sending fieldKey where fieldId is needed.
  • Showing every field without checking showWhen, so candidates are blocked by questions that should be hidden.
  • Sending a phone number without its country code.
  • Sending your own confirmation email.
  • Treating a closed vacancy as an error and not as a closed page.
  • Putting HTML from description on the page without cleaning it.
  • Leaving the allowed origins request until the week of go live.

For integration questions, origin requests or help with a key, contact your account manager.