-
Notifications
You must be signed in to change notification settings - Fork 15
Webhooks
Where they live: the Workflows module (a webhook is a workflow action)
DB tables: workflows, workflow_executions, webhook_deliveries
Related: Service Layer β the single write path that lets any change, from the UI or the API, fire a webhook.
Webhooks are how FreeITSM tells other systems that something happened β post a message to Slack when a P1 ticket is raised, ping a monitoring tool when a change is approved, kick off a script when a new asset is assigned. This page explains what a webhook is, how you build one here, the full catalogue of events you can hook into, and why the recent service-layer refactor is what makes the catalogue able to grow cleanly.
A webhook is the reverse of a normal API call.
- With a normal API, you ask the server for data: "hey FreeITSM, any new tickets?" β over and over. That's polling, and it's wasteful (you ask 1,000 times to catch the one time the answer changed).
- A webhook flips it around: FreeITSM calls you, once, the instant something happens. It sends an HTTP
POSTto a URL you own, with a JSON body describing the event. "A ticket was just created β here are its details." No polling, no delay.
That URL is called the endpoint β it's just a web address that's ready to receive a POST. Slack, Teams and Discord all publish "incoming webhook" URLs for exactly this; so does Zapier, Make, n8n, or any small script you host yourself.
Mental model: a webhook is a doorbell. You give FreeITSM your doorbell's address once; it presses the button whenever the thing you care about happens. You don't stand at the door checking.
That's the whole idea. Everything below is which doorbells exist, when they ring, and how to make them reliable and secure.
FreeITSM doesn't have a separate "webhooks" screen. A webhook is simply an action inside a Workflow. That's deliberate β it means every webhook gets the workflow module's full power for free: triggers, conditions, ordering, logging, and test-firing.
A workflow has three parts, and a webhook plugs into the last one:
TRIGGER CONDITIONS ACTIONS
"a ticket is β "β¦and its priority is β Send webhook β Slack
created" Critical" Send email
Assign to team
-
Trigger β the event you want to react to (e.g.
ticket.created). Exactly one per workflow. -
Conditions (optional) β filters on the event's data (
ticket.priority_id is Critical). All must pass (AND). - Actions β an ordered list of things to do. Send webhook is one of them.
So a "webhook" in practice is: pick an event β optionally filter it β add a Send-webhook action pointing at your URL. Done.
Goal: post to a Slack channel whenever a Critical ticket is created.
- Workflows β New workflow.
- Click the trigger node β choose "A ticket is created" (
ticket.created). -
Add condition β field
ticket.priority_id, operator equals, value Critical (the editor shows a dropdown of real priorities β no guessing ids). -
Add action β Send webhook. Choose the Slack preset and paste your Slack incoming-webhook URL. (Presets for Slack / Teams / Discord pre-shape the JSON body into the format each expects; Custom lets you write your own JSON template with
{{placeholders}}pulled from the payload.) - Test fire (toolbar) sends a synthetic event through the whole chain so you can confirm the message lands before a real ticket ever triggers it.
- Save, make sure the workflow is Active.
From then on, every Critical ticket β whether it was raised in the inbox, by the email pipeline, via the REST API, or by another workflow β fires that webhook.
The webhook body is JSON. With a preset (Slack/Teams/Discord) it's shaped to that platform's message format. With Custom, you control it entirely, interpolating fields from the event with {{ticket.subject}}-style placeholders. The available fields per event are listed in the catalogue below and surfaced in the editor's field dropdown.
Anyone who learns your endpoint URL could POST fake events to it. To prove a delivery genuinely came from your FreeITSM instance, the Send-webhook action can HMAC-sign every request: it computes a SHA-256 signature of the body using a shared secret you configure, and sends it in a header. Your receiver recomputes the same signature with the same secret and rejects anything that doesn't match.
On your side: read the raw request body, compute HMAC-SHA256(body, your_secret), and compare (constant-time) against the signature header. If they differ, drop the request β it wasn't us.
Keep the secret out of source control; treat it like a password.
Networks fail. The receiver might be down for 30 seconds, or rate-limit you. A naive "POST and hope" webhook would silently lose those events. FreeITSM doesn't do that.
Every webhook send is queued into webhook_deliveries and handed to a background worker (cron/webhook_deliveries.php), which gives you:
- Retries with backoff β a failed delivery is retried on an increasing delay, not hammered.
- Dead-letter β after the retry budget is exhausted, the delivery is parked as failed rather than lost or retried forever.
- A delivery log β Workflows β Webhook deliveries shows every attempt: which workflow, target URL, response code, and body.
- Replay β re-send any delivery by hand from that log (great for "the receiver was down, send it again once it's back").
Because sending is asynchronous, a slow or dead endpoint never blocks the thing that triggered it β a ticket still saves instantly even if your Slack workspace is on fire.
These are the events you can trigger a workflow (and therefore a webhook) on. They're defined in WorkflowEngine::availableTriggers(); each carries a payload whose fields are usable in conditions and in custom webhook templates. With the catalogue now dozens of entries deep, the workflow editor's trigger picker is searchable β start typing (resolved, contract, supplier, deleteβ¦) to filter the list.
CRUD + settings entities. Alongside the rich domain events below, several entities get explicit
.created/.updated/.deletedtriggers, each with an<entity>.idand<entity>.nameyou can condition on β e.g.calendar_category.created,incident_status.deleted,contract.updated. They're generated fromWorkflowEngine::crudEntities(), so the trigger list stays maintainable while every entity+action is still its own explicit trigger. Only entities that are actually wired to fire are registered (no dead triggers).
| Event | Fires when | Key payload fields |
|---|---|---|
ticket.created |
a ticket is raised (any source) |
ticket.subject, ticket.priority_id, ticket.status_id, ticket.department_id, ticket.type_id, ticket.assigned_analyst_id, ticket.requester_email
|
ticket.status_changed |
its status moves | above + old_status_id, new_status_id
|
ticket.priority_changed |
its priority moves | above + old_priority_id, new_priority_id
|
ticket.assigned |
it's assigned to an analyst | above + analyst_id, team_id
|
ticket.deleted / ticket.restored
|
moved to / restored from the trash | the full ticket payload |
| Event | Fires when | Key payload fields |
|---|---|---|
problem.created |
a problem is raised |
problem.problem_number, problem.title, problem.status_id, problem.priority_id, problem.assigned_analyst_id, problem.is_known_error, problem.company_id
|
problem.status_changed |
its status moves (e.g. β Resolved) | as above, with the new problem.status_id
|
problem.deleted |
a problem is deleted |
problem.problem_number, problem.title
|
| Event | Fires when | Key payload fields |
|---|---|---|
change.created |
a change request is raised |
change.title, change.status_id, change.priority_id, change.type_id, change.risk, change.assigned_to_id
|
change.approved |
it's approved (manual or CAB vote) |
change.title, change.risk, approver.id
|
change.deleted |
a change request is deleted | change.title |
| Event | Fires when | Key payload fields |
|---|---|---|
task.created |
a task is created |
task.title, task.status_id, task.priority_id, task.assignee_id
|
task.completed |
a task is marked done |
task.title, task.priority_id, task.assignee_id
|
task.deleted |
a task is deleted |
task.title, task.priority_id, task.assignee_id
|
| Event | Fires when | Key payload fields |
|---|---|---|
asset.assigned |
an asset is assigned to a user |
asset.hostname, user.id, user.name
|
asset.unassigned |
an asset is unassigned |
asset.hostname, user.id, user.name
|
| Event | Fires when | Key payload fields |
|---|---|---|
cmdb.object.created / .updated / .deleted
|
a configuration item is created / edited / removed |
object.name, object.class_id
|
| Event | Fires when | Key payload fields |
|---|---|---|
knowledge.published |
a knowledge article goes live |
article.id, article.title
|
knowledge.updated |
an article is edited |
article.id, article.title
|
knowledge.archived |
an article is sent to the recycle bin |
article.id, article.title
|
| Event | Fires when | Key payload fields |
|---|---|---|
contract.created / .updated / .deleted
|
a contract is added / edited / removed |
contract.title, contract.status_id, contract.supplier_id
|
supplier.created / .updated / .deleted
|
a supplier is added / edited / removed |
supplier.name, supplier.status_id, supplier.type_id
|
| Event | Fires when | Key payload fields |
|---|---|---|
calendar_event.created / .updated / .deleted
|
a calendar event is added / edited / removed |
calendar_event.title, calendar_event.category_id
|
calendar_category.created / .updated / .deleted
|
a category is added / edited / removed | calendar_category.name |
| Event | Fires when | Key payload fields |
|---|---|---|
service_status.incident_created |
a status-page incident is opened |
incident.title, incident.status_id
|
service_status.incident_updated |
an incident is edited |
incident.title, incident.status_id
|
service_status.incident_resolved |
an incident is resolved |
incident.title, incident.status_id
|
service_status.incident_deleted |
an incident is deleted | incident.title |
status_service.created / .updated / .deleted
|
a monitored service is added / edited / removed | status_service.name |
incident_status.* / impact_level.*
|
those settings are added / edited / removed | <entity>.name |
| Event | Fires when | Key payload fields |
|---|---|---|
software.application_discovered |
a new application first appears in inventory (agent sync) |
application.name, application.publisher
|
| Event | Fires when | Key payload fields |
|---|---|---|
morning_check.recorded |
a check result is recorded (pass, fail, whatever the status) |
check.name, result.status_id, result.status_name, result.date
|
morning_check.created / .updated / .deleted
|
a check is added / edited / removed | morning_check.name |
morning_check_status.* |
a check status setting is added / edited / removed | morning_check_status.name |
More settings triggers coming. The other modules' settings lookups β ticket / asset / change / problem / task statuses & priorities, CMDB classes, etc. β follow the same
<entity>.created/.updated/.deletedpattern and are being wired module by module (registered only once they actually fire).
| Event | Fires when | Key payload fields |
|---|---|---|
form.submitted |
a form submission is received |
form.id, form.name, submission.id, submission.email
|
Fields that are ids (status, priority, analyst, classβ¦) render as dropdowns of real values in the condition editor, backed by
WorkflowEngine::FIELD_LOOKUP_TABLES, so you never have to know an internal id.
This is the important architectural bit, and it's why the catalogue above could grow from a handful of ticket events to a cross-module set safely.
An event is only useful if it fires every time the thing happens β no matter how it happened. A problem can be created in the UI or through the REST API. If those are two separate code paths (which they were, before the service-layer refactor), then adding problem.created means remembering to fire it in both places and keeping them in sync forever. Miss one, and half your problems silently don't trigger the webhook. That's exactly the kind of drift the refactor eliminated.
After the refactor, every module has one write path β the service β that both the UI and the API call:
Session (UI) ββ ββ UI JSON
βββΊ <Module>Service::method() ββββββ€
API key ββ (the one write path) ββ API envelope
β
ββββΊ WorkflowEngine::dispatch('problem.created', β¦)
So emitting an event is one line, in one place:
// inside ProblemsService::createProblem(), after the insert
WorkflowEngine::dispatch('problem.created', [
'problem' => ['id' => $problemId, 'title' => $title, /* β¦ */],
]);That single dispatch() fires identically whether the problem was raised by an analyst in the browser, by a script hitting POST /api/v1/problems, or by another workflow. It cannot drift, because there's only one path. The dispatch is best-effort and wrapped β if the engine has a bad day it logs and moves on, and the problem still saves.
That's the payoff line for the whole refactor: "wider event catalogue" = "emit one line as each module lands on its service." All 14 modules are now on the service layer, so this is now just⦠adding lines.
To add a new event:
-
Register it in
workflow/includes/engine.php:- add
'module.thing_happened' => 'Human label'toavailableTriggers(); - add its payload field paths to
availableFields(); -
(optional but nice) add id-fields to
FIELD_LOOKUP_TABLES(dropdowns) and scalar fields toFIELD_TYPES(operator hints).
- add
-
Emit it from the module's service method, wrapped best-effort:
try { WorkflowEngine::dispatch('module.thing_happened', ['thing' => [ /* fields */ ]]); } catch (Exception $e) { error_log('Workflow dispatch error: ' . $e->getMessage()); }
That's it β the new event immediately appears in the workflow editor's trigger dropdown, conditions work against its fields, and any Send-webhook action attached to it starts delivering (with retries, logging and replay) automatically.
See also: Workflows Β· Service Layer Β· Service-Layer progress
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)