Skip to content

Writing Integration Hooks

This guide walks through writing PULL and PUSH hooks with practical code examples. If you haven't set up an integration yet, start with the Custom Integrations overview.

For a full reference of everything available in your hook code, see the Context API Reference. For entity field definitions, see the Data Model Reference.

PULL hooks

A PULL hook fetches data from an external system and returns it to Flowstate. Flowstate handles deduplication, change detection, and upserting records — your hook just needs to fetch and return the data.

How it works

  1. Flowstate calls your hook on its configured schedule
  2. Your code runs, fetches data from an external API using ctx.http
  3. You return an array of records, each with an externalId and a data object
  4. Flowstate compares each record against the previous sync — only new or changed records are written

Return format

Your hook must return an object with a records array:

js
async function run(ctx) {
  return {
    records: [
      {
        externalId: 'emp-001',
        data: {
          firstName: 'Jane',
          lastName: 'Smith',
          email: 'jane.smith@example.com',
          startDate: '2024-03-15'
        }
      }
    ]
  };
}
  • externalId is your external system's unique identifier for this record. Flowstate uses it to match records across syncs — if a record with the same externalId already exists, it's updated instead of duplicated.
  • data contains the fields for the entity type your hook targets. See the Data Model Reference for available fields.

For paginated APIs, you can also return more and meta — see Pagination below.

Pagination

When your external API returns data in pages, you can tell Flowstate to re-invoke your hook automatically by returning more: true along with a meta object containing your pagination state. Flowstate will call your run(ctx) function again with ctx.meta set to the value you returned.

js
return {
  records: [...],
  more: true,              // signals there are more pages
  meta: { page: 2 }       // forwarded as ctx.meta on the next invocation
};

On the first invocation, ctx.meta is undefined. On each subsequent invocation, it contains whatever you returned as meta on the previous page.

Return fieldTypeDefaultDescription
recordsarrayrequiredRecords fetched on this page
morebooleanfalseSet to true to request another page
metaobjectPagination state forwarded to the next invocation as ctx.meta

Records are fingerprinted and upserted after each page, so if a later page fails, data from earlier pages is already saved.

Limits

LimitValue
Maximum pages per execution100
HTTP requests per page50 (resets each page)
Execution time per page30 seconds

WARNING

If your hook returns more: true for 100 consecutive pages, pagination stops and a warning is logged. If you need more than 100 pages, consider fetching more records per page.

Example: Page-based pagination

js
async function run(ctx) {
  const page = ctx.meta?.page ?? 1;

  const resp = await ctx.http.get('https://api.example.com/employees', {
    headers: { Authorization: `Bearer ${ctx.secrets.api_key}` },
    query: { page: String(page), per_page: '100' }
  });

  if (resp.status !== 200) {
    ctx.log.error('Failed to fetch employees', { status: resp.status, page });
    return { records: [] };
  }

  const records = resp.body.data.map(emp => ({
    externalId: emp.id,
    data: {
      firstName: emp.first_name,
      lastName: emp.last_name,
      email: emp.email,
      startDate: emp.hire_date
    }
  }));

  ctx.log.info(`Page ${page}: fetched ${records.length} employees`);

  return {
    records,
    more: resp.body.has_next_page,
    meta: { page: page + 1 }
  };
}

Example: Cursor-based pagination

js
async function run(ctx) {
  const cursor = ctx.meta?.cursor ?? null;

  const query = { limit: '100' };
  if (cursor) {
    query.after = 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) {
    ctx.log.error('Failed to fetch employees', { status: resp.status });
    return { records: [] };
  }

  const records = resp.body.data.map(emp => ({
    externalId: emp.id,
    data: {
      firstName: emp.first_name,
      lastName: emp.last_name,
      email: emp.email,
      startDate: emp.hire_date,
      endDate: emp.termination_date
    }
  }));

  ctx.log.info(`Fetched ${records.length} employees`);

  return {
    records,
    more: resp.body.pagination.has_more,
    meta: { cursor: resp.body.pagination.next_cursor }
  };
}

Example: Offset-based pagination

js
async function run(ctx) {
  const offset = ctx.meta?.offset ?? 0;
  const limit = 200;

  const resp = await ctx.http.get('https://api.example.com/contractors', {
    headers: { 'X-Api-Key': ctx.secrets.api_key },
    query: { offset: String(offset), limit: String(limit) }
  });

  if (resp.status !== 200) {
    ctx.log.error('Failed to fetch contractors', { status: resp.status });
    return { records: [] };
  }

  const records = resp.body.items.map(c => ({
    externalId: c.id,
    data: {
      name: c.full_name,
      email: c.email,
      rate: c.hourly_rate,
      currencyCode: c.currency
    }
  }));

  const hasMore = resp.body.total > offset + records.length;
  ctx.log.info(`Offset ${offset}: fetched ${records.length} of ${resp.body.total} contractors`);

  return {
    records,
    more: hasMore,
    meta: { offset: offset + limit }
  };
}

Scheduling

Configure how often your PULL hook runs:

IntervalRuns every
15 min15 minutes
30 min30 minutes
1 hour1 hour
6 hours6 hours
12 hours12 hours
24 hoursOnce daily

You also set a preferred hour (0–23 in your organisation's timezone) for when the hook should run. For intervals longer than an hour, this controls what time of day the execution happens.

WARNING

PULL hooks are limited to 5 executions per hour. This includes both scheduled and manual triggers. If you hit this limit, subsequent executions are skipped until the next hour.

Example: Sync employees from an external HR API

This hook fetches employees from an external HRIS using cursor-based pagination. Flowstate automatically calls run(ctx) again for each page.

js
async function run(ctx) {
  const cursor = ctx.meta?.cursor ?? null;

  const query = { limit: '100' };
  if (cursor) {
    query.after = 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) {
    ctx.log.error('Failed to fetch employees', { status: resp.status });
    return { records: [] };
  }

  const records = resp.body.data.map(emp => ({
    externalId: emp.id,
    data: {
      firstName: emp.first_name,
      lastName: emp.last_name,
      email: emp.email,
      internalEmployeeId: emp.employee_number,
      startDate: emp.hire_date,
      endDate: emp.termination_date
    }
  }));

  ctx.log.info(`Fetched ${records.length} employees`);

  return {
    records,
    more: resp.body.pagination.has_more,
    meta: { cursor: resp.body.pagination.next_cursor }
  };
}

Example: Sync projects with custom attributes

This hook imports projects and includes custom attribute values that map to fields you've defined in Flowstate.

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) {
    ctx.log.error('Failed to fetch projects', { status: resp.status });
    return { records: [] };
  }

  const records = resp.body.projects.map(project => ({
    externalId: 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'
      }
    }
  }));

  ctx.log.info(`Fetched ${records.length} projects`);
  return { records };
}

TIP

Custom attributes are matched by key to Custom Attribute Definitions configured in your Flowstate organisation. If a key doesn't match a definition, it's silently ignored.

PUSH hooks

A PUSH hook runs automatically whenever an entity changes in Flowstate. Use it to send updates to external systems — notifications, syncs, audit logs, or anything else.

How it works

  1. An entity changes in Flowstate (created, updated, or deleted)
  2. Flowstate checks if any PUSH hooks are configured for that entity type and change type
  3. Your hook runs with ctx.event containing the change details
  4. Your code handles the event — typically by calling an external API

PUSH hooks don't return a value. They're fire-and-forget from Flowstate's perspective — your code handles the side effects.

The ctx.event object

PropertyTypeDescription
entityTypestringemployee, vacancy, contractor, project, or assignment
entityIdstringThe Flowstate ID of the changed entity
changeTypestringcreate, update, or delete
beforeobject or nullThe entity state before the change. null for creates.
afterobject or nullThe entity state after the change. null for deletes.

Example: Push new contractors to a billing system

js
async function run(ctx) {
  const { event } = ctx;

  if (event.changeType === 'create') {
    const contractor = event.after;

    ctx.log.info('New contractor, syncing to billing', {
      name: contractor.name
    });

    const resp = await ctx.http.post(
      'https://billing.example.com/api/vendors',
      {
        name: contractor.name,
        email: contractor.email,
        rate: contractor.rate,
        currency: contractor.currencyCode,
        type: contractor.contractorType
      },
      {
        headers: { Authorization: `Bearer ${ctx.secrets.billing_token}` }
      }
    );

    if (resp.status !== 201) {
      ctx.log.error('Failed to create vendor in billing', {
        status: resp.status,
        body: resp.body
      });
    }
  }
}

Example: Notify an external system when a project is updated

js
async function run(ctx) {
  const { event } = ctx;

  // Only notify on updates, not creates or deletes
  if (event.changeType !== 'update') {
    return;
  }

  const before = event.before;
  const after = event.after;

  // Check if the project name or end date changed
  const nameChanged = before.name !== after.name;
  const endDateChanged = before.endDate !== after.endDate;

  if (!nameChanged && !endDateChanged) {
    ctx.log.info('No relevant changes, skipping');
    return;
  }

  await ctx.http.post(
    'https://hooks.example.com/flowstate-project-update',
    {
      projectId: event.entityId,
      changes: {
        name: nameChanged ? { from: before.name, to: after.name } : undefined,
        endDate: endDateChanged
          ? { from: before.endDate, to: after.endDate }
          : undefined
      },
      timestamp: new Date().toISOString()
    },
    {
      headers: { 'X-Webhook-Secret': ctx.secrets.webhook_secret }
    }
  );

  ctx.log.info('Notified external system of project update');
}

Limits reference

PULL hook limits

LimitValue
Memory128 MB
Execution time per page30 seconds
HTTP requests per page50 (resets each page)
HTTP response size10 MB per response
Max pages per execution100
Max executions per hour5

PUSH hook limits

LimitValue
Memory128 MB
Execution time10 seconds
HTTP requests per execution50
HTTP response size10 MB per response

Error handling

When your hook throws an error or times out, the execution is marked as failed or timed out. The error message and stack trace are captured in the execution log.

To debug issues:

  1. Go to Settings → Data Integrations → Custom Integrations → [your integration] → [your hook]
  2. Open the Executions tab
  3. Click on a failed execution to see logs, HTTP requests, and the error

TIP

Use ctx.log.info(), ctx.log.warn(), and ctx.log.error() liberally during development. All log output appears in the execution record, making it easier to trace what happened.

Security restrictions

Your hook code runs in an isolated sandbox with the following restrictions:

  • HTTPS only — all HTTP requests must use HTTPS
  • No private networks — requests to private IP ranges (10.x, 172.16.x, 192.168.x, localhost) and cloud metadata endpoints are blocked
  • No Node.js APIs — you cannot use require, fs, process, or other Node.js built-ins
  • No redirects — HTTP responses with redirect status codes are not followed automatically
  • User-Agent is fixed — all outbound requests use a Flowstate-CustomIntegration/1.0 user agent that cannot be overridden

Flowstate Documentation