Hiring lives in Business Made: HR › Hiring (/hr/hiring). The screen has a Jobs table and an Applicants board, one column per stage. Everything on it comes from the hiring board API (business-made/recruitment/board…, appengine business-made/services/recruitment.service.ts 594–879), which works out the counts, labels and the next steps each job and person allows. Use those routes with call_appengine. They are the screen's own actions.
Hiring here is internal. Opening a job doesn't publish it anywhere: there is no careers page on the site and nothing posts to outside job boards. Applicants are typed in by staff, or created by an automation from a website form (see step 3). Nothing here emails an applicant. Only the hire is emailed: creating their employee (step 6), or accepting their offer (step 5), sends them a login invitation.
The steps of a person's working life after the hire (onboarding journeys, paperwork, accounts, equipment) are in the Business Made docs: Hire, onboard and offboard.
Ask first
- The job: title and employment type (both required). Then department, location, how many people (
openings), pay from/to and per what, the date to stop taking applicants, and a description. - Draft or open? A draft is saved but not counted as an open job. Opening it publishes nothing.
- Each applicant: first name, last name and email (all required), phone, which job, and where they heard about you. Never guess an email.
- For a hire: the start date and pay (hourly rate or salary). Creating the employee needs a rate, and it emails the new hire.
Steps
1. HR › Hiring: the job
POST business-made/recruitment/board/jobs with { data: { … } } (saveJob, 857). On screen: New job, then Save as draft or Open for applicants.
| Field | On screen | Values |
|---|---|---|
title | Job title | Required. |
employmentType | Employment type | Required: full-time, part-time, contract, temporary, intern. The screen starts on part-time. |
department | Department | Free text. The screen suggests the business's departments (bm_department). |
location | Location | A business location's name (slug), or empty for any location. Stored as a list. |
workArrangement | Where the work happens | onsite (default), remote, hybrid. |
openings | People needed | 1 or more (default 1). The job is marked filled when this many are hired. |
closingDate | Stop taking applicants on | YYYY-MM-DD. Shown on the job only. Nothing closes the job on that day. |
salary | Pay from / Pay up to / Per | { min, max, period }. period: hourly (default), weekly, monthly, annually. Currency USD. |
description | About the job | Free text. |
status | (the button) | open opens it now. Anything else saves a draft. |
A missing required field gives 400 JOB_REQUIRED with "Required: employmentType" (tested). The reply is the job as the screen shows it: id, status, pay (e.g. "$16–$19 an hour"), openings, hired, moves and applicants.
To edit, send { id, data: { … } } to the same route. An edit writes every field from the body, so a field you leave out is wiped. Tested: an edit with only title, type and openings emptied the department, description and pay. Read the job first (GET business-made/recruitment/board/jobs/<id>), change what was asked, and send all of it back. An edit never changes the status. Use step 2 for that.
Hands on: the job's id.
Check: open /hr/hiring?posting=<id>. The job drawer shows the title, status, pay, people needed and the status buttons.
2. HR › Hiring: open, hold, fill or close the job
POST business-made/recruitment/board/jobs/<id>/status with { status, reason? } (setJobStatus, 742). On screen: the buttons in the job drawer. Only these moves are allowed (JOB_MOVES, 604):
| From | Moves (button) |
|---|---|
draft | open (Open for applicants), cancelled (Cancel) |
open | on-hold (Put on hold), filled (Mark filled), closed (Close) |
on-hold | open (Reopen), closed (Close) |
filled, closed | open (Reopen) |
cancelled | draft (Back to draft) |
Any other move gives 400 JOB_STATUS_MOVE, e.g. "A Draft job cannot move to Filled" (tested). Every move is added to statusHistory. The first time a draft opens, it is stamped publishedAt. Closing, filling or cancelling stamps closedAt and closedReason (your reason, else the status name).
Closed and cancelled jobs drop out of the job list in Add applicant. Filled jobs stay in it.
Check: the job's status and moves in the reply. The Jobs table's tabs: Current (open, on hold, draft), Filled, Closed, All.
3. HR › Hiring: add the applicant
POST business-made/recruitment/board/applicants (addApplicant, 784). On screen: Add applicant above the board, or Add applicant in a job's drawer, which picks that job for you.
{
"jobPostingId": "<job id, or empty for no job yet>",
"source": "referral",
"notes": "Referred by Sam",
"personalInfo": { "firstName": "…", "lastName": "…", "email": "…", "phone": "…" }
}| Field | Values |
|---|---|
personalInfo.firstName, lastName, email | Required. Missing ones give 400 APPLICANT_REQUIRED, "Required: lastName, email" (tested). |
jobPostingId | A job's id. An id that doesn't exist gives "Job not found". |
source (Heard about us from) | website, linkedin, indeed, glassdoor, referral, agency, career-fair, other (default). |
notes | Free text. |
The applicant starts at stage new, status active, stamped appliedAt. Adding emails nobody.
There is no duplicate check. The same email can be added twice to the same job (tested). Search first: repo_find on bm_applicant with { "data.personalInfo.email": "<email>" }. If the person is already there, open them instead.
From a website form. Nothing creates applicants from the site by itself. Put an application form on a page (Put a form on a website page), then add an automation on its submissions with a create_data step that makes the bm_applicant with the fields above (Automation when a record is added or updated). Put a record_exists step on data.personalInfo.email before it, so the same person isn't added twice. Set stage: "new", status: "active" and appliedAt yourself, because the automation doesn't go through addApplicant.
Hands on: the applicant's id.
Check: open /hr/hiring?applicant=<id>. The drawer shows their name, the job, the stage chips with New lit, and Move to Screening.
4. HR › Hiring: move them through the pipeline
POST business-made/recruitment/board/applicants/<id>/action (applicantAction, 801). The stages, in order (STAGES, 599):
new → screening → interview → assessment → offer → hired
Two closed stages sit beside them: rejected (shown as Not moving forward) and withdrawn (Withdrew).
| Body | On screen | What it does |
|---|---|---|
{ action: "stage", stage, reason? } | Move to … (the next stage) | Moves to any of new…offer. The screen only offers the next one, but the API can skip ahead. |
{ action: "reject", reason? } | Not moving forward | Stage and status rejected. The reason is kept as rejectionReason. |
{ action: "withdraw", reason? } | They withdrew | Stage and status withdrawn. |
{ action: "reopen", reason? } | Bring back | Back to the last open stage they were at. Tested: rejected from Interview came back to Interview. |
{ action: "notes", notes } | Notes, Save notes | Replaces the notes. Send the old text with the new. |
Every move is added to the history with who did it, when and the reason. The errors, all tested:
| Message | When |
|---|---|
| "Pick a pipeline stage (hiring is its own step)" | stage is hired or not one of the five stages. |
| "Bring this person back into the pipeline first" | A stage move on someone rejected, withdrawn or hired. |
| "Already in the pipeline" | reopen on someone still in it. |
| "This person was hired — use offboarding instead" | reject or withdraw on a hire. |
| "Unknown action" | Any other action. |
Moving, rejecting or bringing back emails nobody. Rejected and withdrawn people are hidden from the board until Show people not moving forward is ticked (GET …/board?closed=true).
Check: the reply's stage, next and the last line of history.
Use only the board routes above. The older POST business-made/recruitment/applicants/<id>/stage (and the RecruitmentService.moveApplicantStage AI method) accepts any word as a stage. Its own description suggests screened, interviewed and offered, which aren't stages. Tested: an applicant moved to screened is counted as in the pipeline but shows in no column, so nobody sees them. The older …/applicants/<id>/notes fails with a 500 on anyone whose notes were written by the board (tested).
5. Interviews and offers (API only)
The hiring screen has no interview or offer forms. The records exist, and the API keeps them (recruitment.controller.ts). Use them only when asked. None of them sends anything to the candidate: no email, no calendar invite, no letter.
Interview: POST business-made/recruitment/interviews with { applicantId, jobPostingId, type, scheduledAt, duration, location?, meetingLink?, interviewers: [{ interviewerId, name }] }. type: phone-screen, video, onsite, technical, panel, final. Then …/interviews/<id>/complete ({ overallRating, recommendation, notes }; recommendation: strong-yes, yes, neutral, no, strong-no), …/cancel ({ reason }) or …/reschedule ({ scheduledDate, scheduledTime?, duration? }).
Scheduling overwrites scheduledAt with the moment the record was made (scheduleInterview, 308). Tested: an interview sent for 2 Nov 15:00 came back with today's time. Put the interview time in scheduledDate and scheduledTime as well, and read it from there. For a real meeting with an invite, book it with Calendar appointments and meeting links.
Offer: POST business-made/recruitment/offers with { applicantId, jobPostingId, jobTitle, position: { title, department, location, manager, employmentType, startDate }, compensation: { baseSalary, currency, payFrequency } }. It always starts as draft. Then …/offers/<id>/send (status sent, tested: it sends nothing), …/withdraw ({ reason }), …/decline ({ reason? }) or …/accept. Move the applicant to offer yourself (step 4). The offer doesn't move them.
Stop before …/accept. Accepting an offer makes the hire straight away (appengine journeys/journeys.service.ts 1123): if no employee has the applicant's email, an employee is created from the offer (status inactive, the offer's start date as hire date). Their onboarding journey starts with pre-boarding opened, and the journey creates their login and emails them an invitation. The applicant is not moved to Hired or linked, so finish with step 6's link call using that employee's sk (repo_find on bm_employee with { "data.contactInfo.email": "<email>" }).
6. HR › People: hire them
From Interview, Assessment or Offer, the applicant drawer shows Hire. It opens the New person form, filled in with their name, email and phone, the job's title, department, type and location, today as the hire date, and status active. The pay isn't carried over: type the rate or salary on the pay section. Saving creates the bm_employee (and their bm_payroll_profile), then calls:
POST business-made/recruitment/board/applicants/<id>/hired with { employeeId: "<the bm_employee sk>" } (markHired, 832).
That sets the applicant's stage and status to hired, convertedToEmployeeId and hiredAt. It adds one to the job's filledCount. When the hires reach openings on an open or on-hold job, the job becomes filled (reason Filled). Tested: a one-opening job turned filled on the first hire. A second call for the same person doesn't count them twice.
Stop before creating the employee. Creating a bm_employee with a recent or future start date starts their onboarding journey (onEmployeeCreated, journeys.service.ts 1167). The journey's first task creates their login and emails them an invitation to choose a password, and it gives tasks to their manager, HR and IT. Show the person's name, email, start date and pay, and wait for yes. For the employee's own fields, see the Business Made docs, Add and manage people.
Without the screen: create the employee as HR › People does, then make the link call. employeeId is the record's sk, not its EMP-… code. A missing one gives "Which employee record was created?", and one that isn't an employee gives "Employee not found" (both tested).
Check: the applicant drawer reads Hired on
When it's done
Tell the person: the job and its status, who is at which stage, and who was hired. Open /hr/hiring?posting=<id> for the job, or /hr/hiring?applicant=<id> for one person. For a hire, open their profile (/hr/people/<employee sk>) and HR › Onboarding, where their journey is.
GET business-made/recruitment/board answers "how many in the pipeline" and "who's applied": totals (openJobs, onHold, filled, inPipeline, hired, notMovingForward), each job's applicant counts by stage, and the board's columns. ?jobPostingId=<id> narrows it to one job, ?status=active|open|filled|closed narrows the jobs, and ?closed=true adds rejected and withdrawn people.
What it can't do yet
- No careers page and no job boards. Opening a job publishes nothing.
externalPostingson the job is never sent anywhere. - No applicant emails. Nothing tells an applicant they were received, moved, rejected, interviewed or offered.
- No interview or offer screens. Interviews and offers are API records only, interview times are overwritten when scheduled (step 5), and nothing sends an offer letter or collects a signature.
- Accepting an offer doesn't mark the applicant hired (step 5). Link them by hand.
- No duplicate check on applicants.
- No delete on the hiring screen. A job can be deleted with
DELETE business-made/recruitment/jobs/<id>(it goes to Trash). Applicants have no delete route. Reject or withdraw them instead. - Raising
openingson a filled job doesn't reopen it. Tested: openings 1 → 2 left itfilled. Use Reopen. - The closing date closes nothing. Close the job by hand.
GET business-made/recruitment/metricscounts every applicant ever, and onlyscheduledinterviews andsent/acceptedoffers. Its date range is ignored.
Don'ts
- Don't create the employee, or accept an offer, without a yes. Either one emails the new hire and starts onboarding.
- Don't add an applicant without searching for their email first.
- Don't send a partial job edit. Read the job and send it back whole.
- Don't use the older
…/applicants/<id>/stageor…/notesroutes, or invent stage names. - Don't promise the applicant an email, an interview invite or an offer letter from here. Send those yourself through email or the calendar, after a yes.
- Don't post the job to an outside board or site on the business's behalf.