Appearance
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
- Flowstate calls your hook on its configured schedule
- Your code runs, fetches data from an external API using
ctx.http - You return an array of records, each with an
externalIdand adataobject - 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'
}
}
]
};
}externalIdis your external system's unique identifier for this record. Flowstate uses it to match records across syncs — if a record with the sameexternalIdalready exists, it's updated instead of duplicated.datacontains 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 field | Type | Default | Description |
|---|---|---|---|
records | array | required | Records fetched on this page |
more | boolean | false | Set to true to request another page |
meta | object | — | Pagination 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
| Limit | Value |
|---|---|
| Maximum pages per execution | 100 |
| HTTP requests per page | 50 (resets each page) |
| Execution time per page | 30 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:
| Interval | Runs every |
|---|---|
| 15 min | 15 minutes |
| 30 min | 30 minutes |
| 1 hour | 1 hour |
| 6 hours | 6 hours |
| 12 hours | 12 hours |
| 24 hours | Once 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
- An entity changes in Flowstate (created, updated, or deleted)
- Flowstate checks if any PUSH hooks are configured for that entity type and change type
- Your hook runs with
ctx.eventcontaining the change details - 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
| Property | Type | Description |
|---|---|---|
entityType | string | employee, vacancy, contractor, project, or assignment |
entityId | string | The Flowstate ID of the changed entity |
changeType | string | create, update, or delete |
before | object or null | The entity state before the change. null for creates. |
after | object or null | The 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
| Limit | Value |
|---|---|
| Memory | 128 MB |
| Execution time per page | 30 seconds |
| HTTP requests per page | 50 (resets each page) |
| HTTP response size | 10 MB per response |
| Max pages per execution | 100 |
| Max executions per hour | 5 |
PUSH hook limits
| Limit | Value |
|---|---|
| Memory | 128 MB |
| Execution time | 10 seconds |
| HTTP requests per execution | 50 |
| HTTP response size | 10 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:
- Go to Settings → Data Integrations → Custom Integrations → [your integration] → [your hook]
- Open the Executions tab
- 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.0user agent that cannot be overridden