Webhooks
Flowstate sends an HTTP POST to your endpoint when a record in live data is created, updated or deleted. Each request carries one event, signed with your endpoint’s secret.
What sends events
| Source | initiator.type |
|---|---|
| Changes people make in the Flowstate app | user |
| The REST API requests listed below | api_key |
| Merging a scenario: one event for each record the merge changes | merge |
| Changes a custom integration PULL hook makes to the functional structure | user |
REST API requests that send events, with paths relative to /api/v1/org/:orgId:
| Request | Events |
|---|---|
POST /employees, PATCH /employees/:id | employee create or update, plus a create event for each team allocation, project allocation and salary adjustment created in the same request |
DELETE /employees/:id | employee delete |
POST, PATCH and DELETE on /employees/:employeeId/salary-adjustments | salary_adjustment |
POST /contractors, PATCH /contractors/:id | contractor create or update, plus a create event for each team allocation, project allocation and rate adjustment created in the same request |
DELETE /contractors/:id | contractor delete |
POST, PATCH and DELETE on /contractors/:contractorId/rate-adjustments | contractor_rate |
POST, PATCH and DELETE on /vacancies | vacancy |
POST /vacancies/:id/fill | vacancy update, and a create event for the employee or contractor it creates. An employee filler also sends salary_adjustment create. |
POST, PATCH and DELETE on /teams | team |
POST, PATCH and DELETE on /projects | project |
Writes under /functional-groups | functional_group, functional_position, functional_assignment |
Not sent:
- Changes inside a scenario, until the scenario is merged.
- Changes a custom integration PULL hook makes to employees, contractors, vacancies, projects, teams and assignments.
Manage endpoints in Flowstate
Endpoints are managed in the app. There’s no API for endpoint configuration.
| Permission | Allows |
|---|---|
| View Webhooks | Opening the Webhooks page and seeing endpoints |
| Configure Webhooks | Adding, editing, switching off, testing, rotating the secret of, and deleting endpoints |
The Webhooks page isn’t in the Settings menu. Open https://{tenant}.flowstate.inc/settings/webhooks, where {tenant} is your organisation’s Flowstate address.
Add an endpoint
- Select Add Endpoint.
- In Name, enter up to 100 characters.
- In Endpoint URL, enter a URL Flowstate can reach over the internet. Use HTTPS.
- Under Entity Types, select Deselect all, then tick the types to receive.
- Under Event Types, tick Create, Update and Delete as needed.
- Select Create Endpoint.
- Copy the secret shown in Webhook Signing Secret. It isn’t shown again.
- Tick I have copied this secret and understand it will not be shown again, then select Done.
An endpoint needs at least one entity type and one event type. Each option in the form subscribes to one entity type:
| Option | entity_type |
|---|---|
| Employees | employee |
| Contractors | contractor |
| Teams | team |
| Projects | project |
| Vacancies | vacancy |
| Cost Centres | cost_center |
| Value Streams | value_stream |
| Locations | geography |
Edit, switch off or delete an endpoint
| Action | How |
|---|---|
| Switch off or on | Use the switch on the endpoint’s row. A switched-off endpoint keeps its settings and receives nothing. |
| Edit | Select ⋯, then Edit Endpoint. Make changes and select Save Changes. |
| Delete | Select ⋯, then Delete Endpoint. In Delete Webhook Endpoint, select Delete. |
A change to an endpoint, including switching it off, deleting it or rotating its secret, can take up to a minute to reach deliveries.
Test an endpoint
Select ⋯, then Test Connection. You’ll see Connectivity test passed, or a message starting Test failed with the HTTP status or error.
The test event:
- has no
X-Flowstate-SignatureorX-Flowstate-Event-Idheader - has
entity_typeemployee,change_typeupdate,entity_idtest_entity_id, you as theinitiator, and{ "test": true }as bothbeforeandafter - has the same 5-second timeout as a delivery
- updates the endpoint’s delivery status
A handler that rejects unsigned requests fails the test with HTTP 401. The test checks that the endpoint can be reached, not that your signature check works.
Rotate the signing secret
- Select ⋯, then Rotate Secret.
- Copy the new secret shown in Webhook Signing Secret.
- Tick the acknowledgement, then select Done.
Deliveries for up to a minute afterwards can still be signed with the old secret. Accept both secrets until then.
Delivery status
Each endpoint’s row shows the result of its last delivery or test.
| Badge | Meaning |
|---|---|
| Healthy | The last delivery got a 2xx response. |
| Failing | The last delivery got another status, timed out or couldn’t connect. |
| No deliveries | Nothing has been sent yet. |
Beside the badge is when the last delivery happened, or Never delivered. There’s no per-event delivery history in the app.
Entity types
Subscribe to the entity types you need. An endpoint receives events only for the types it’s subscribed to.
Core
entity_type | Record |
|---|---|
employee | An employee |
contractor | A contractor |
vacancy | A vacancy |
team | A team |
project | A project |
initiative | An initiative |
Reference data
entity_type | Record |
|---|---|
cost_center | A cost centre |
value_stream | A value stream |
geography | A location |
Allocations
entity_type | Record |
|---|---|
employee_team_allocation | An employee’s allocation to a team |
employee_project_allocation | An employee’s allocation to a project |
team_project_allocation | A team’s allocation to a project |
contractor_team_allocation | A contractor’s allocation to a team |
contractor_project_allocation | A contractor’s allocation to a project |
vacancy_team_allocation | A vacancy’s allocation to a team |
vacancy_project_allocation | A vacancy’s allocation to a project |
Compensation
entity_type | Record |
|---|---|
salary_adjustment | An employee’s dated salary |
bonus | An employee’s bonus |
contractor_rate | A contractor’s dated rate |
AI
entity_type | Record |
|---|---|
ai_agent | An AI agent in the workforce |
agent_policy | The organisation’s agent policy |
ai_policy | The organisation’s AI usage policy or budget caps |
Functional structure
entity_type | Record |
|---|---|
functional_group | An area, such as a division or department |
functional_position | A seat in an area |
functional_assignment | A person’s time in a seat: an employee, contractor or vacancy |
- These records are never deleted, so
deletenever arrives. Ending an area, a seat or somebody’s time in a seat is anupdatewhoseafterhas the newendDate. Subscribe toupdateto hear about leavers. beforeandafterare the changed area, seat or assignment only, not the structure around it.- Sharing an area with a manager, or revoking that, sends a
functional_groupupdatefor the area. - A custom attribute change on an area or seat sends an
updatefor that area or seat. Itsbeforeandafterare the attribute value, which has adefinitionId.
Event types
change_type | before | after |
|---|---|---|
create | null | The new record |
update | The record before | The record after |
delete | The deleted record | null |
Event object
{
"id": "5b0f6e3c-9a4d-4f1e-8c7b-2d1e0a9f3b64",
"timestamp": "2026-03-18T15:30:00.412Z",
"entity_type": "employee",
"entity_id": "clx1a2b3c4d5e6f7g8h9",
"change_type": "create",
"initiator": {
"type": "user",
"id": "clx9u8s7e6r5",
"email": "jane.chen@example.com"
},
"organization_id": "clx9o8r7g6i5d4",
"before": null,
"after": {
"id": "clx1a2b3c4d5e6f7g8h9",
"firstName": "Alex",
"lastName": "Rivera",
"email": "alex.rivera@example.com",
"startDate": "2026-04-01T00:00:00.000Z",
"endDate": null,
"jobRoleId": "clx9r8q7w6e5",
"geographyId": "clx3g2h1j0k9",
"defaultCurrencyCode": "GBP",
"updatedAt": "2026-03-18T15:30:00.281Z"
}
}
after is shortened in these examples.
| Field | Type | Description |
|---|---|---|
id | string | Event ID, a UUID. Same as the X-Flowstate-Event-Id header. An event sent to several endpoints has the same id at each. |
timestamp | string | ISO 8601 time, in UTC, that Flowstate sent the event. |
entity_type | string | See Entity types. |
entity_id | string | Flowstate ID of the record. |
change_type | string | create, update or delete. |
initiator | object | Who made the change. |
initiator.type | string | See Initiator types. |
initiator.id | string | A user ID for user and merge. An API key ID for api_key. |
initiator.email | string | The user’s email. Omitted when it isn’t known. |
organization_id | string | Organisation ID. |
before | object or null | The record before the change. |
after | object or null | The record after the change. |
before and after
- They carry the record’s fields with Flowstate’s field names. The shape isn’t versioned and can differ by entity type and by source. Treat fields as optional, and read the record from the REST API when you need a fixed shape.
- Dates are ISO 8601 strings.
- Decimal values, such as money and functional FTE, are strings without trailing zeros:
"85000","0.5". Other numbers are JSON numbers.
Ending a seat is an update:
{
"id": "9b1c2d3e-4f5a-4b6c-8d7e-0f1a2b3c4d5e",
"timestamp": "2026-06-30T09:12:44.107Z",
"entity_type": "functional_position",
"entity_id": "cmfa1b2c3d4e5f6g7h8i",
"change_type": "update",
"initiator": { "type": "api_key", "id": "clx4k3e2y1" },
"organization_id": "clx9o8r7g6i5d4",
"before": {
"id": "cmfa1b2c3d4e5f6g7h8i",
"functionalGroupId": "cmf0z9y8x7w6v5u4t3s2",
"name": "Staff Engineer, Payments",
"externalId": "pos-payments-staff-eng",
"requiredFte": "1",
"startDate": "2026-01-01T00:00:00.000Z",
"endDate": null
},
"after": {
"id": "cmfa1b2c3d4e5f6g7h8i",
"functionalGroupId": "cmf0z9y8x7w6v5u4t3s2",
"name": "Staff Engineer, Payments",
"externalId": "pos-payments-staff-eng",
"requiredFte": "1",
"startDate": "2026-01-01T00:00:00.000Z",
"endDate": "2026-06-30T00:00:00.000Z"
}
}
Initiator types
type | Change made by | id | email |
|---|---|---|---|
user | A person in the Flowstate app. Also functional structure changes by an assistant tool, or by a PULL hook acting as the integration’s creator. | User ID | In the app only |
api_key | A REST API request | API key ID | Omitted |
merge | Merging a scenario | ID of the user who merged | Omitted |
system | Reserved. Not currently sent. | — | — |
Signature verification
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Flowstate-Signature | sha256= followed by the lower-case hex HMAC-SHA256 of the raw request body |
X-Flowstate-Event-Id | The event id |
Verify a delivery
- Read the raw request body before parsing it.
- Compute HMAC-SHA256 over those bytes, with the signing secret as the key. Use the secret exactly as shown, a 64-character hex string, as text. Don’t hex-decode it.
- Hex-encode the result in lower case and prefix
sha256=. - Compare it with
X-Flowstate-Signaturein constant time. Reject the request if the header is missing or doesn’t match.
The signature covers the body only. To guard against replays, store the id of each event you process and ignore repeats. You can also reject events with an old timestamp.
Node.js
Express, reading the raw body:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.FLOWSTATE_WEBHOOK_SECRET;
function isValidSignature(rawBody, header, secret) {
if (typeof header !== 'string') return false;
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(header);
const computed = Buffer.from(expected);
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
function handleEvent(event) {
console.log(`${event.change_type} ${event.entity_type} ${event.entity_id}`);
}
app.post('/flowstate/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValidSignature(req.body, req.get('X-Flowstate-Signature'), secret)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(204).end(); // acknowledge within 5 seconds
setImmediate(() => handleEvent(event));
});
app.listen(3000);
Python
Python 3.10 or later:
import hashlib
import hmac
def is_valid_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if header is None:
return False
expected = "sha256=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header, expected)
Pass the raw request body bytes, the X-Flowstate-Signature header and your secret.
Delivery
| Property | Behaviour |
|---|---|
| Request | POST with a JSON body |
| Timeout | 5 seconds for your endpoint to respond |
| Success | Any 2xx status |
| Retries | None. Each event is sent to each endpoint once. A non-2xx status, timeout or connection error isn’t retried. |
| Ordering | Not guaranteed. Events can arrive out of order. |
| Duplicates | Rare. Deduplicate on id. |
| Scenarios | Nothing is sent until a scenario is merged. |
| Configuration changes | Can take up to a minute to reach deliveries. |
Recommendations
- Verify every signature, and reject requests without one.
- Respond with a
2xxstraight away and process the event afterwards. - Deduplicate on
id. - Don’t rely on order. When the current state matters, read the record from the REST API.
- Failed deliveries aren’t retried, so reconcile regularly against the REST API.
- Subscribe only to the entity and event types you use.
- Rotate the signing secret on your normal credential schedule.