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/acmethe slug isacme. 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.comandhttps://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.enabledand 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
languageparameter,en-GBorcy. The careers settings list the languages your organisation offers inavailableLanguages.
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.
| Field | Notes |
|---|---|
companyName | |
logoUrl, headerImageUrl | Optional |
brandColor | Hex colour, for example #6366F1. Optional |
headline | Defaults to Join Our Team |
tagline, intro | |
metaTitle, metaDescription | The page title defaults to Careers |
socialLinks | Object with optional linkedin, twitter and glassdoor addresses |
privacyPolicyUrl | Optional. Use it for your privacy link |
jobAlertsEnabled | Show the job alerts sign-up when true |
registerYourInterest | Object: enabled, pageHeader, formTemplateId |
locationImagesEnabled | When true, jobs can carry a location picture |
availableLanguages | Languages 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.
| Parameter | Default | Notes |
|---|---|---|
page | 1 | Minimum 1 |
limit | 20 | 1 to 100 |
search | Matches the job title only, not the description. Up to 200 characters | |
department | Exact match. Up to 100 characters | |
employmentType | Exact match, one of the values below | |
jobTime | Exact match. Up to 100 characters | |
location | Exact match. Up to 200 characters. Use it for a drop-down built from facets | |
locationSearch | Partial match on location. Up to 200 characters. Use it for a free text box | |
language | en-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:
| Field | Notes |
|---|---|
id | Use it for the detail, form and apply calls |
title | |
department | Optional |
location | |
locationImageUrl | Optional. Present when location pictures are on and the location has one |
employmentType | One of the values above |
salaryMin, salaryMax, salaryCurrency | Optional |
salaryPeriod | Optional. Tells you whether to show per hour, per month and so on |
salaryType | Optional. A descriptor such as Competitive, used in place of a range |
publishedAt | Empty if never published |
closingDate | Optional |
internalReference | Your 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:
| Field | Notes |
|---|---|
description, requirements, benefits | HTML written in the rich text editor. Requirements and benefits are optional |
applicationFormPages | Ordered page names. More than one means a multi-page form |
attachments | Optional. Each has documentId, filename, mimeType and sizeBytes |
applyButtonEnabled | Whether 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.
closingDateis 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
closingDatewith 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.
- Get the application form. This also gives you a token for uploads.
- Upload each file. Each upload returns a document id.
- 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.
| Field | Notes |
|---|---|
fields | The form fields, in order |
pages | Ordered page names |
pageConditions | Rules that show or hide a whole page, in the same style as showWhen below |
sources | Options for How did you hear about us, each with a name and a slug |
gapAnalysis | Employment gap settings, described below |
gapReasonsEnabled, monthYearDatesEnabled | Whether to collect a reason for each gap, and whether history dates are month and year only |
uploadSession | token, expiresAt, maxFileSize and allowedMimeTypes |
saveAndReturnEnabled | Whether to offer save and finish later |
Each field has:
| Field | Notes |
|---|---|
id | Send this back as fieldId. Do not send fieldKey |
fieldKey | Optional name that says what the field means, such as first_name, email, cv or cover_letter |
label, type, required | |
order, page | Order within the page, and the zero-based page number matching pages |
placeholder, helpText | Optional |
options | Choices for drop-down, multi-select and checkbox fields |
showWhen | Optional rule that decides when the field appears |
requiredWhen | Optional rule that makes the field required only in some cases |
repeatable, repeatableGroupId, repeatableGroupLabel | Mark a field as part of a repeating group |
repeatableMin, repeatableMax | Fewest and most entries a repeating group takes |
minChars, maxChars, minWords, maxWords | Optional length limits on text answers |
contentKind, contentUrl, contentCaption, contentBody | Used 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
showWhenrule 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
repeatableGroupIdmake 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 withentryIndex. - Employment gaps. Only look for gaps when
gapAnalysis.enabledis true. Then look backlookBackYearsyears across the dates the candidate enters, flag any gap at least as long as the threshold (thresholdMonthsmonths, orthresholdDaysdays whenthresholdUnitis days), and collect an explanation whererequireExplanationsis true. Send the explanations ingapExplanations, orgapReasonswhere the form uses reasons. - Upload session. The token belongs to this one vacancy and lasts one hour. Read
maxFileSize(in bytes) andallowedMimeTypesfrom 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 itselffieldId: theidof the file fieldsessionToken: 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.
| Field | Required | Notes |
|---|---|---|
fullName | Yes | Up to 200 characters |
email | Yes | Up to 254 characters |
gdprConsent | Yes | Must be true |
responses | Yes | The answers. Can be empty. Up to 500 entries |
phone | No | International format, for example +447700900123 |
coverLetter | No | Up to 10,000 characters |
sessionToken | If any answer carries a documentId | From the form response |
gapExplanations, gapReasons | No | JSON text keyed by date range, formatted startDate_endDate. Up to 10,000 characters each |
source | No | The slug of the option picked from sources. Up to 50 characters |
sourceDetails | No | Free text for an Other source |
channel | No | The job board the candidate came from. Up to 50 characters |
referralLinkId | No | Employee referral link id, if the address carried one |
internalToken | No | Needed to apply for a vacancy that is only on the internal careers page |
Each entry in responses has:
| Field | Notes |
|---|---|
fieldId | Required. The id from step 1 |
value | Text answers, up to 10,000 characters |
selectedOptions | For multi-select and checkbox fields, up to 200 entries |
documentId | For file fields, from step 2 |
entryIndex | Zero-based entry number in a repeating group |
- Phone numbers need the country code.
07700 900123is rejected. Convert it to+447700900123first. This is the most common reason a custom form fails. - Attribution. If your links carry a
srcparameter, send its value aschannel. It takes priority oversource. Asourcethat is not in the sources list returns400 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
| Field | Required | Notes |
|---|---|---|
email | Yes | Up to 254 characters |
gdprConsent | Yes | Must be true |
firstName, lastName, phone | No | Phone in international format |
responses | No | Answers 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
| Field | Required | Notes |
|---|---|---|
firstName, lastName | Yes | |
email | Yes | Up to 254 characters |
criteria | No | Object 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/jobsGET /api/public/careers/{slug}/group/departmentsGET /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}/departmentsGET /api/public/careers/{slug}/internal/{token}/facetsGET /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/jobsGET /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
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Failed validation, or an unknown field was sent. See validationErrors |
| 400 | MISSING_REQUIRED_FIELD | A required form field was empty. The message names it |
| 400 | JOB_CLOSED | The vacancy no longer takes applications |
| 400 | GDPR_CONSENT_REQUIRED | gdprConsent was not true |
| 400 | INVALID_CANDIDATE_SOURCE | source is not one of the values in sources |
| 400 | INVALID_REFERRAL_LINK, REFERRAL_EXPIRED | The referral link is for a different vacancy, or has expired |
| 400 | SESSION_TOKEN_REQUIRED | File answers were sent without sessionToken |
| 400 | INVALID_DOCUMENT_REFERENCE | A documentId does not belong to this session and field. Upload it again |
| 400 | INVALID_FIELD | fieldId on an upload is unknown or is not a file field |
| 400 | INVALID_FILE_TYPE, INVALID_FILE_SIZE | The file type is not accepted, its contents do not match, or it is too large |
| 400 | FILE_REQUIRED | The upload had no file part |
| 400 | INVALID_INPUT | Malformed input, for example gapExplanations that is not valid JSON |
| 401 | UPLOAD_SESSION_EXPIRED, UNAUTHORIZED | The upload token has expired, is invalid, or is for a different vacancy |
| 404 | NOT_FOUND | Company, vacancy or group careers page not found |
| 404 | CAREERS_PAGE_NOT_FOUND | The slug could not be matched during an upload |
| 404 | DOCUMENT_NOT_AVAILABLE | The attachment is not available |
| 409 | DUPLICATE_APPLICATION | This email has already applied for this vacancy |
| 409 | ALREADY_EXISTS | This email has already registered its interest |
| 409 | CANDIDATE_UNAVAILABLE | The candidate record could not be created. Safe to retry |
| 413 | INVALID_FILE_SIZE | Upload rejected for being too large before it was checked |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests. Wait and try again |
| 500 | UPLOAD_FAILED | File storage failed. Safe to retry |
| 500 | INTERNAL_ERROR | Unexpected 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.
| Call | Limit |
|---|---|
| Submit application | 20 per minute and 60 per 15 minutes |
| Save draft | 20 per minute |
| Upload a file | 30 per minute and 60 per five minutes |
| Renew an upload session, and the register interest form | 30 per minute |
| Register interest | 5 per minute |
| Job alerts sign-up | 5 per minute |
| Download an attachment | 5 per minute |
| Everything else | 100 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
fieldKeywherefieldIdis 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
descriptionon 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.