Skip to content

Custom Integrations: Vacancies (Positions)

This guide is for engineers writing a Flowstate PULL hook that syncs positions from an HRIS into Flowstate as Vacancy records. Read this in conjunction with the Custom Integrations overview and Data Model.

The model in one paragraph

Flowstate has three workforce entities: Employee, Contractor, and Vacancy. Each has team-allocation rows describing how that person/slot is allocated to teams, with FTE and date ranges. A vacancy is a budgeted slot; its team allocations are the budget that lives on the team. A vacancy can be filled by either an Employee or a Contractor. Once the filler's endDate passes, the vacancy is treated as open again — Flowstate's forecast model encodes this date-aware semantic, so cost handoff and re-opening fall out naturally without you having to manage them.

You sync positions by emitting vacancy records from your hook. Flowstate dedups on (organizationId, externalId) and is fully idempotent — re-running the same payload is a no-op.

Filling a position

Set the polymorphic filledBy: { externalId } (or shorthand filledByExternalId) on the vacancy record. Flowstate's sync looks up the externalId against both live_employees and live_contractors in the same organisation and sets the appropriate FK:

MatchOutcome
Exactly one employeeSets filledByLiveEmployeeId
Exactly one contractorSets filledByLiveContractorId
Both an employee and a contractor have this externalIdRejects with an "ambiguous" error
Neither has this externalIdRejects with a "not found" error

To clear the fill — i.e. mark the position as open again — send filledBy: null.

Position lifecycle — worked examples

1. Open position is created

The position has been budgeted but no one is filling it yet.

js
{
  externalId: 'POS-001',
  data: {
    role: 'Staff Engineer',
    targetStartDate: '2026-03-01',
    teamAllocations: [{ teamId: 'TEAM-platform', fte: 1.0, startDate: '2026-03-01' }],
    // No filledBy* — the vacancy is open.
  }
}

Flowstate shows this as an open vacancy on the Platform team's drawer.

2. Position is filled by an employee

js
{
  externalId: 'POS-001',
  data: {
    role: 'Staff Engineer',
    targetStartDate: '2026-03-01',
    teamAllocations: [{ teamId: 'TEAM-platform', fte: 1.0, startDate: '2026-03-01' }],
    filledByExternalId: 'EMP-ada-001',
  }
}

The same vacancy now disappears from the default vacancy list (filled vacancies are hidden unless the user toggles "Show filled"). The forecast cost contribution for this vacancy is suppressed — the employee's salary takes over.

3. Person moves teams — position follows them

Update the same position with its team allocations pointing at the new team:

js
{
  externalId: 'POS-001',
  data: {
    role: 'Staff Engineer',
    teamAllocations: [{ teamId: 'TEAM-growth', fte: 1.0, startDate: '2026-06-01' }],
    filledByExternalId: 'EMP-ada-001',
  }
}

Flowstate's orphan cleanup ends the old team allocation; the new one is created; the filler FK stays.

4. Person moves teams — position stays open for someone else (common case)

The position stays on the original team and re-opens. Just clear the fill:

js
{
  externalId: 'POS-001',
  data: {
    role: 'Staff Engineer',
    teamAllocations: [{ teamId: 'TEAM-platform', fte: 1.0, startDate: '2026-03-01' }],
    filledBy: null,
  }
}

The vacancy reappears in the default list as open.

5. Person and position both end

js
{ externalId: 'POS-001', data: { deletedAt: '2026-08-01' } }

Idempotency and error semantics

  • Records are deduped on (organizationId, sourceSystem, sourceSystemId) first, then (organizationId, externalId). Re-running a payload byte-identical to the last run is a no-op — only lastSyncedAt updates.
  • Nested allocations (teamAllocations[], projectAllocations[]) are declarative: absent → existing rows untouched; [] → all sourceSystem-scoped rows deleted; present with rows → diff + orphan-clean.
  • Per-record transactions. A failure on record N does not roll back records 1..N-1. Per-record errors appear in the execution's errors[] with the externalId and a clear message.
  • The execution log surfaces nested-allocation counts (created / updated / deleted / unchanged) per page so you can validate how much downstream work each sync did.

Performance budget

Approximate budget: ~50 ms per record with one or two nested allocations, sequential. A first sync of 800 positions takes around 40 seconds wall-clock. Use pagination (more: true in the hook return) for larger lists — each page is its own dispatch.

Filling via REST instead of the hook

For ad-hoc fills via the REST API, see POST /vacancies/:id/fill. It accepts fillerType: "employee" | "contractor" and creates the filler atomically alongside the vacancy state change — equivalent to what the hook does declaratively.

Flowstate Documentation