Employer self-service

Let approved employers claim companies, manage jobs, and review applicants.

Build an employer portal around board.me.companies. Every method is authenticated and scoped to a company membership; public company data remains under board.companies.

Prerequisites

  • board.context().features.employers is true.
  • A server-owned employer session.
  • A protected account area with cache: 'no-store'.
  • UI states for approved, pending_work_email, awaiting_admin, and rejected memberships.

Claim a company

ts
// src/data/employer.ts
import type { FetchOptions } from '@cavuno/board';
import { board } from '../lib/board';
function session(accessToken: string): FetchOptions {
return {
cache: 'no-store',
headers: { authorization: `Bearer ${accessToken}` },
};
}
export async function claimCompany(
accessToken: string,
companySlug: string,
) {
return board.me.companies.claim(
companySlug,
session(accessToken),
);
}
export async function sendWorkEmail(
accessToken: string,
companySlug: string,
workEmail: string,
) {
return board.me.companies.workEmail.verify(
companySlug,
{ workEmail },
session(accessToken),
);
}

If the membership is pending_work_email, send the verification link. The confirmation method uses the emailed token as authorization and does not require a board-user session:

ts
await board.me.companies.workEmail.confirm(companySlug, { token });

Do not unlock management screens until the returned membership is approved.

Create and publish a job

ts
export async function createEmployerJob(
accessToken: string,
companySlug: string,
) {
const options = session(accessToken);
const draft = await board.me.companies.jobs.create(
companySlug,
{
title: 'Staff Engineer',
description: 'Build reliable systems for our customers.',
applicationUrl: 'https://acme.example/careers/staff-engineer',
employmentType: 'full_time',
remoteOption: 'remote',
remotePermits: [{ type: 'worldwide', value: 'worldwide' }],
},
options,
);
return board.me.companies.jobs.publish(
companySlug,
draft.id,
options,
);
}

New jobs are held drafts. publish works only while an existing paid window permits it. A held draft that needs payment must use jobs.checkout; branch on checkout, published, or invoice_sent and never infer publication from a browser redirect.

Load the applicant pipeline

ts
export async function loadPipeline(
accessToken: string,
companySlug: string,
jobId: string,
) {
return board.me.companies.applicants.list(
companySlug,
{ job: jobId },
session(accessToken),
);
}
export async function moveApplicant(
accessToken: string,
companySlug: string,
applicationId: string,
stageId: string,
) {
const options = session(accessToken);
await board.me.companies.applicants.move(
companySlug,
applicationId,
{ stageId },
options,
);
// Applicant mutations return 204; re-read the pipeline for fresh state.
}

Use the stage order returned by the pipeline. Do not maintain a second ordering model in local storage.

Expected result

An employer sees membership state before any private controls. Approved members can create held drafts, publish or check them out, and read a pipeline whose stages and applicants come from one server response.

Verify the result

  • A pending or rejected membership cannot edit a company or job.
  • Claim confirmation produces the membership state shown in the portal.
  • A created job appears as a draft before publication or checkout.
  • Applicant writes re-read the pipeline after the 204 response.
  • One employer cannot access another company’s private records.

Errors and edge cases

  • Claiming can approve immediately on a domain match or enter a pending state.
  • Work-email links expire after 24 hours.
  • Employer job updates are merge patches; omitted fields remain unchanged.
  • Remote timezone values auto-derive on create when omitted, but do not re-derive on update.
  • Custom stages are capped and protected stages cannot be removed or arbitrarily reordered.

Production cautions

  • Treat applicant data, notes, and signed resume URLs as private.
  • Keep every employer response out of public caches and logs.
  • Use the API’s authorization decision even if the UI already hid a control.
  • Re-read state after void-returning mutations rather than simulating server changes.

Next, add an anonymous purchase path with Job posting and checkout.