Skip to content

Write hooks

A hook is JavaScript that defines async function run(ctx). Flowstate calls run with a context object.

Runtime

  • The code must define a function named run. Without one, the run fails with Hook code must define a function called "run".
  • Each invocation starts clean. A PULL page or a PUSH event can't see variables from an earlier one. Carry state between pages in ctx.meta and between runs in ctx.kv.
  • Only standard JavaScript built-ins and ctx are available. See Globals.
  • An uncaught error, or going over a limit, fails the run. The error message and the stack frames from your code, with line numbers matching the editor, are recorded.
  • Scheduled runs, Trigger Now and PUSH runs use the saved code. Test (Dry Run) uses the code in the editor.

PULL hooks

A PULL hook fetches records from another system and returns them. Flowstate creates, updates or deletes Flowstate records to match.

Return value

js
async function run(ctx) {
  return {
    records: [
      {
        externalId: 'E-1001',
        data: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' },
      },
    ],
  };
}
FieldTypeDescription
recordsarrayRequired. The records on this page. Any other value is treated as no records.
records[].externalIdstringRequired. A non-empty string identifying the record in the other system. A number is rejected, so convert IDs with String().
records[].dataobjectFields for the hook's entity type. See Data model.
morebooleantrue to fetch another page. Any other value ends the run.
metaobjectPassed to the next page as ctx.meta.

How records are applied

  • Each record is applied on its own. A failed record doesn't stop the others. It's logged as Record <externalId>: <message> and counted under Failed, and the run still completes.
  • Records are matched on externalId. See Matching.
  • Every record returned is applied on every run. Existing employees, contractors, vacancies, projects and teams count as updated each time. Functional records that already match are skipped, and the log says how many.
  • Non-fatal problems, such as an unrecognised vacancy status or a functional record that points at something not yet synced, are logged as warnings.
  • Changes a PULL hook makes to employees, contractors, vacancies, projects, teams and assignments don't send webhooks or trigger PUSH hooks. Changes to the functional structure do both.

Pagination

Return more: true and a meta object to fetch another page. Flowstate calls run again straight away with ctx.meta set to that object.

js
async function run(ctx) {
  const query = { limit: '100' };
  if (ctx.meta?.cursor) query.after = ctx.meta.cursor;

  const resp = await ctx.http.get('https://hr.example.com/api/v1/employees', {
    headers: { Authorization: `Bearer ${ctx.secrets.API_KEY}` },
    query,
  });
  if (resp.status !== 200) throw new Error(`HR API returned ${resp.status}`);

  return {
    records: resp.body.data.map((emp) => ({
      externalId: String(emp.id),
      data: {
        firstName: emp.first_name,
        lastName: emp.last_name,
        email: emp.email,
        startDate: emp.hire_date,
        endDate: emp.termination_date ?? undefined,
      },
    })),
    more: resp.body.pagination.has_more === true,
    meta: { cursor: resp.body.pagination.next_cursor },
  };
}
RuleDetail
Pages per run100. After that the run stops with a warning and completes.
Per-page allowanceEach page has its own 30-second execution time and 50 HTTP requests.
Partial failureRecords are applied after each page. If a later page fails, earlier pages stay applied and the run is marked failed.

Schedule

IntervalWhen it runs
Every 15 minutes, 30 minutes, hour, 6 hours or 12 hoursOnce that long has passed since the hook last ran
DailyOnce a day, during the Preferred hour (00:0023:00) in the organisation's time zone

A schedule runs only while the hook and its integration are both switched on. Trigger Now runs the saved code straight away.

Dry runs

Test (Dry Run) runs the code in the editor without writing to Flowstate.

  • The Preview tab lists the records that would be created, updated and removed, and the teams, projects and job roles that would be created.
  • ctx.http requests are real, and ctx.kv writes are kept. Dry runs and real runs share the same store.
  • Functional records aren't checked against editing rights, date rules or seat capacity, so a record that previews cleanly can still fail in a real run.

Example: projects with custom attributes

js
async function run(ctx) {
  const resp = await ctx.http.get('https://pm.example.com/api/projects', {
    headers: { 'X-Api-Key': ctx.secrets.PM_API_KEY },
  });
  if (resp.status !== 200) throw new Error(`PM API returned ${resp.status}`);

  return {
    records: resp.body.projects.map((project) => ({
      externalId: String(project.id),
      data: {
        name: project.title,
        description: project.summary,
        projectCode: project.code,
        startDate: project.start,
        endDate: project.end,
        priority: project.priority_level,
        customAttributes: {
          department: project.department_name,
          budget_approved: project.budget_status === 'approved' ? 'Yes' : 'No',
        },
      },
    })),
  };
}

Attribute keys that don't match a definition are ignored. See Custom attributes.

Example: team hierarchy

js
async function run(ctx) {
  const resp = await ctx.http.get('https://hr.example.com/api/teams', {
    headers: { Authorization: `Bearer ${ctx.secrets.API_KEY}` },
  });
  if (resp.status !== 200) throw new Error(`HR API returned ${resp.status}`);

  return {
    records: resp.body.teams.map((team) => ({
      externalId: String(team.id),
      data: {
        name: team.name,
        description: team.description,
        teamType: team.category,
        parentTeamId: team.parent_id ? String(team.parent_id) : undefined,
        // With the name as well, a parent that hasn't been synced yet is created.
        parentTeamName: team.parent_name ?? undefined,
        teamManagerEmail: team.manager_email ?? undefined,
      },
    })),
  };
}

Send parentTeamName with parentTeamId so teams can arrive in any order. A parent created this way takes parentTeamId as its externalId, so the parent's own record updates it later. See Parent team.

Example: functional structure

Areas, seats and the people in them are three hooks: functional_group, functional_position and functional_assignment. Run them in that order.

A functional record that points at an area, seat or person Flowstate hasn't seen is skipped with a warning, and the next run applies it. Nothing is created on demand.

js
// functional_group: parent areas must already exist, so send parents first.
async function run(ctx) {
  const resp = await ctx.http.get('https://hr.example.com/api/org-units', {
    headers: { Authorization: `Bearer ${ctx.secrets.API_KEY}` },
  });
  if (resp.status !== 200) throw new Error(`HR API returned ${resp.status}`);

  return {
    records: resp.body.units.map((unit) => ({
      externalId: String(unit.id),
      data: {
        name: unit.name,
        groupType: unit.level,
        startDate: unit.effective_from,
        parentExternalId: unit.parent_id ? String(unit.parent_id) : undefined,
        customAttributes: { cost_centre: unit.gl_code },
        deletedAt: unit.closed_on ?? undefined, // ends the area on that day
      },
    })),
  };
}
js
// functional_position: reportsToExternalId must name a seat in the same area.
async function run(ctx) {
  const resp = await ctx.http.get('https://hr.example.com/api/positions', {
    headers: { Authorization: `Bearer ${ctx.secrets.API_KEY}` },
  });
  if (resp.status !== 200) throw new Error(`HR API returned ${resp.status}`);

  return {
    records: resp.body.positions.map((pos) => ({
      externalId: String(pos.id),
      data: {
        name: pos.title,
        groupExternalId: String(pos.org_unit_id),
        requiredFte: pos.fte,
        startDate: pos.opened_on,
        reportsToExternalId: pos.reports_to ? String(pos.reports_to) : undefined,
        jobRole: pos.job_family,
        deletedAt: pos.closed_on ?? undefined,
      },
    })),
  };
}
js
// functional_assignment: exactly one of employeeExternalId, contractorExternalId or vacancyExternalId.
async function run(ctx) {
  const resp = await ctx.http.get('https://hr.example.com/api/assignments', {
    headers: { Authorization: `Bearer ${ctx.secrets.API_KEY}` },
  });
  if (resp.status !== 200) throw new Error(`HR API returned ${resp.status}`);

  return {
    records: resp.body.assignments.map((a) => ({
      externalId: String(a.id),
      data: {
        positionExternalId: String(a.position_id),
        employeeExternalId: String(a.employee_id),
        fte: a.allocation_percent / 100,
        startDate: a.start_date,
        deletedAt: a.left_on ?? undefined,
      },
    })),
  };
}

Warning

Functional records are written as the person who created the integration, and are limited by that person's editing rights. See Who a functional sync acts as.

PUSH hooks

A PUSH hook runs when a record changes in live data. Use it to send the change to another system.

When they run

A PUSH hook runs once for each change that matches its entity type and ticked event types, when the change is made:

  • in the Flowstate app
  • through the REST API, by the requests listed in What sends events
  • by merging a scenario
  • to the functional structure by a PULL hook

It doesn't run for:

  • changes inside a scenario, until the scenario is merged
  • changes a PULL hook makes to employees, contractors, vacancies, projects, teams and assignments

The hook and its integration must both be switched on. A change to a hook's settings, or switching it on or off, can take up to a minute to apply.

The return value is ignored. If the code throws or goes over a limit, the run is recorded as failed and isn't retried.

ctx.event

PropertyTypeDescription
entityTypestringThe hook's entity type
entityIdstringFlowstate ID of the changed record
changeTypestringcreate, update or delete
beforeobject or nullThe record before the change. null for create.
afterobject or nullThe record after the change. null for delete.

before and after are the record as Flowstate stores it, with the same content as a webhook's before and after. See Event object. They use Flowstate's field names, not the PULL data field names: an employee's job role arrives as jobRoleId, and decimal values such as rate are strings.

Hook entity typebefore and after
employee, contractor, vacancy, project, teamThe record.
assignmentAn allocation of an employee, contractor or vacancy to a team or project. It carries one of liveEmployeeId, liveContractorId or liveVacancyId, and one of liveTeamId or liveProjectId.
functional_group, functional_position, functional_assignmentThe changed area, seat or assignment only. A custom attribute change on an area or seat carries the attribute value instead, which has a definitionId.

Functional records are never deleted, so delete never arrives for them. Ending an area, a seat or somebody's time in a seat is an update whose after has the new endDate.

Example: send new contractors to a billing system

js
async function run(ctx) {
  const { changeType, entityId, after: contractor } = ctx.event;
  if (changeType !== 'create') return;

  const resp = await ctx.http.post(
    'https://billing.example.com/api/vendors',
    {
      flowstateId: entityId,
      name: contractor.name,
      email: contractor.email,
      rate: contractor.rate === null ? null : Number(contractor.rate),
      rateType: contractor.rateType,
      currency: contractor.currencyCode,
    },
    { headers: { Authorization: `Bearer ${ctx.secrets.BILLING_TOKEN}` } }
  );

  if (resp.status < 200 || resp.status >= 300) {
    throw new Error(`Billing returned ${resp.status}`);
  }
  ctx.log.info('Vendor created', { flowstateId: entityId });
}

Example: notify on project renames and end-date changes

js
async function run(ctx) {
  const { changeType, entityId, before, after } = ctx.event;
  if (changeType !== 'update') return;

  const nameChanged = before.name !== after.name;
  const endDateChanged = before.endDate !== after.endDate;
  if (!nameChanged && !endDateChanged) return;

  const resp = await ctx.http.post(
    'https://hooks.example.com/flowstate-project-update',
    {
      projectId: entityId,
      name: nameChanged ? { from: before.name, to: after.name } : undefined,
      endDate: endDateChanged ? { from: before.endDate, to: after.endDate } : undefined,
    },
    { headers: { 'X-Webhook-Secret': ctx.secrets.NOTIFY_SECRET } }
  );

  if (resp.status < 200 || resp.status >= 300) {
    throw new Error(`Notify endpoint returned ${resp.status}`);
  }
}

Debugging

Open the hook's Execution History tab. Each run shows:

ColumnValues
StatusRunning, Completed or Failed
Triggered ByScheduled, Manual, Event or Test
DurationTime taken
RecordsFor PULL runs: processed, created, updated and failed
Started AtStart time

A run's detail has its log entries, HTTP requests and any error. Secret values are masked as [REDACTED].

Flowstate Documentation