Skip to content

Webhooks

Ed Mozley edited this page Jul 12, 2026 · 9 revisions

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.


Webhooks 101 (the "for dummies" version)

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 POST to a URL you own, with a JSON body describing the event. "A ticket was just created — here are its details." You never have to poll.

⚠️ One thing to be clear about, because it bites everyone. Detecting the event needs no scheduling — saving the ticket fires the workflow instantly, in-process. But sending the webhook does: the action queues the message and a background worker delivers it (so a dead endpoint can never slow down a ticket save). If that worker isn't scheduled, your webhooks queue up and never leave. The editor's Send test button bypasses the queue, so it can work perfectly while every real webhook silently piles up unsent. See Reliability below, and the banner on System → Webhooks.

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.


How webhooks work in FreeITSM

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
  1. Trigger — the event you want to react to (e.g. ticket.created). Exactly one per workflow.
  2. Conditions (optional) — filters on the event's data (ticket.priority_id is Critical). All must pass (AND).
  3. 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.

Building one — a worked example

Goal: post to a Slack channel whenever a Critical ticket is created.

  1. Workflows → New workflow.
  2. Click the trigger node → choose "A ticket is created" (ticket.created).
  3. Add condition → field ticket.priority_id, operator equals, value Critical (the editor shows a dropdown of real priorities — no guessing ids).
  4. Add actionSend 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.)
  5. 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.
  6. 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 payload

The webhook body is JSON. You pick one of four formats for the Send-webhook action:

  • Slack / Teams / Discord presets — shape a templated message into that platform's exact chat-message JSON, so a delivery lands as a proper formatted message with no work on your side.
  • Custom — you control the body 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.
  • Full record — sends the entire object as JSON, in exactly the shape the REST API returns for GET /<resource>/{id} (it reuses the same serialisers, so a webhook payload and an API response for the same record are byte-for-byte identical and share the OpenAPI schema).

Full record / {{entity.full}} — the whole object, for free

Any event that carries a real record can attach that record's full API-shaped object. The Full record preset sends it wholesale; in Custom mode you can embed it anywhere with {{entity.full}} (e.g. {{ticket.full}}, {{change.full}}) and the template engine JSON-encodes it inline. It's loaded lazily — only when the Full-record preset is chosen or the body references .full — so a plain Slack ping never pays for an extra query.

Supported today (one per resource whose API GET /{id} is a clean single fetch): ticket.full, change.full, problem.full, task.full, asset.full, article.full (knowledge), contract.full, supplier.full, calendar_event.full, software_licence.full, and incident.full (service-status). Because it reuses the REST serialisers, each is automatically as rich, typed and consistent as the API — and lights up for every module the moment its serialiser exists.

Not (yet) full-record: cmdb.object.* and network_diagram.* — their API GET hydrates deep child collections (typed CI properties / nodes + connectors) beyond a single fetch, so their events carry id + name inline rather than a .full object.


Security — signed payloads (HMAC)

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.

On our side: the secret is encrypted at rest (AES-256-GCM) rather than sitting in the database in plain text — a reader of the database could otherwise forge our signature, which would defeat the entire point of signing. It's also redacted in a dry run's output, so the debugging surface can't be used to read it back out of a saved workflow. See Data protection below.


Reliability — the async delivery engine

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 logWorkflows → Webhook deliveries (a.k.a. System → Webhooks) 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").
  • An overview dashboard — the same page opens with an at-a-glance health panel: 7-day success rate, volume and average delivery time, how many are queued or dead-lettered, a 14-day delivered-vs-failed chart, and which endpoints and workflows send the most (each with its own success rate) — so you can spot a failing integration without reading the log line by line.

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.


Message formats — add your own platform

Look closely at what a "preset" actually is:

Slack    → {"text": "{{message}}"}
Discord  → {"content": "{{message}}"}
Teams    → {"@type": "MessageCard", …, "text": "{{message}}"}

A chat format is nothing but a JSON body template with a {{message}} slot. Which means the Custom (raw JSON) mode already does everything a preset does — a preset is just a custom body someone saved and named.

So they aren't a switch in PHP any more. They're rows in webhook_message_formats, managed under Workflows → Settings → Message formats. Add Google Chat, Mattermost, Rocket.Chat, Zulip, ntfy — anything that accepts an incoming webhook — with no code change and no release.

Field What it's for
Key Stored inside every workflow using the format. Don't change it later.
Body template The exact JSON the platform expects, with {{message}} where the message goes. Any merge code works here too ({{ticket.number}}).
URL pattern Regex matching that platform's webhook URLs. The editor warns when a workflow's URL doesn't match — which catches "pasted a Discord URL but left the format on Slack", otherwise an unexplained HTTP 400.
Formatting hint One line shown under the Message box. Worth filling in: Discord reads *one asterisk* as italic where Slack reads it as bold.

Built-ins are locked (editing Slack's template in place would break every Slack webhook on the install) — Copy gives you an editable clone. Escaping is handled for you: the template is decoded, substitution happens inside the string values, and json_encode does the escaping, so a message containing quotes or newlines can never break the payload or inject structure. And the engine keeps the built-ins as a hardcoded fallback, so a mangled setting can't stop webhooks going out.

Custom and Full record are not formats and aren't listed there. They don't wrap a message — one sends the exact JSON you write, the other sends the whole record — so they stay in the engine rather than pretending to be rows.

Webhook Message Formats — the full guide, with ready-to-paste templates for Google Chat, Mattermost, Rocket.Chat, ntfy, Telegram, PagerDuty and a Discord rich embed.


Data protection — what's stored, and for how long

Three things about a webhook end up on disk. It's worth knowing what happens to each.

The URL and the signing secret — encrypted at rest

A webhook URL is a credential in its own right: anyone holding your Discord or Slack URL can post into that channel. The signing secret is a true secret — its whole purpose is proving a message really came from you, which anyone able to read it could forge.

Both are AES-256-GCM encrypted (reusing includes/encryption.php, the same facility that protects the AI API keys), in two places: the workflow itself (workflows.actions) and the delivery queue (webhook_deliveries.url).

The URL is additionally redacted in the delivery log:

https://discord.com/api/webhooks/…/••••

The tail of a webhook URL is the token. Printing it in a screen every analyst can open would undo encrypting it in the first place.

Best-effort by design. The encryption key is a file an admin creates (ENCRYPTION_KEY_PATH), and not every install has one. If it's missing, FreeITSM stores the values as it always did rather than breaking your webhooks outright — and shows a plain warning at the top of System → Webhooks rather than implying a protection it isn't providing. Configure the key and re-save each webhook workflow to encrypt the stored values.

Migration is free: decryptValue() passes non-ENC: values straight through, so rows written before encryption keep working and are encrypted on their next save. No backfill, no migration window.

⚠️ webhook_deliveries.url is VARCHAR(2000), not 1000. Encryption inflates a string by ~⅓ + 28 bytes, so a max-length 1000-char URL becomes ~1377. At the old width MySQL would have silently truncated the ciphertext — which can then never be decrypted. See Pitfalls #3.

Not hidden behind **** in the editor — unlike an AI API key. This is a deliberate divergence from that convention, and it's worth understanding before anyone "fixes" the inconsistency.

Masking a field means the browser sends back a placeholder meaning "leave mine as it was", and the server has to work out which stored secret that refers to. For a settings page with one API key, that's trivial. On the workflow canvas it isn't: actions are boxes you drag around, and the engine reads them top to bottom — so an action's position is its identity. Drag a box, and "the first action" becomes a different action. Restore the secrets by position and you attach Slack's secret to the Discord webhook, breaking both, silently, because someone moved a box.

So the values come back in the clear instead, and there's nothing to map. Encryption at rest still does its job — it's protecting against a stolen database, not against an admin reading a workflow they own and could edit anyway.

Full worked example: Pitfalls #4.

The payload — kept only as long as you choose

The delivery log stores the exact payload that was sent, so you can see what went out. With the Full record preset that is an entire ticket — subject, requester, the lot — copied into webhook_deliveries.request_body in plain text.

That's a real data-at-rest question, so it gets an explicit answer rather than an accidental one. System → Webhooks → Data protection sets how long payload bodies are kept:

Setting Effect
Don't store them at all Body scrubbed the instant the delivery settles.
1 / 7 / 30 days Body scrubbed once the window passes. 7 days is the default.
As long as the delivery record lasts Body lives until the row itself is pruned.

When the window passes, the request and response bodies are scrubbed but the delivery record is kept — endpoint, status, attempts, timing, errors — so the dashboard KPIs and your audit trail survive intact.

Two retention settings, doing different jobs:

  • webhook_payload_retention_days (new, default 7) — blanks the bodies, keeps the row.
  • webhook_delivery_retention_days (default 30) — deletes the whole row.

The payload clock is deliberately the shorter of the two: the sensitive part goes early, the part you'd actually audit lives on.

The trade-off, stated plainly. Replay re-sends the stored payload. Once it's been scrubbed there is nothing to re-send, so that delivery can no longer be replayed — and FreeITSM says exactly that, rather than quietly POSTing an empty body to a live endpoint. In practice you replay a webhook within hours of it failing, not weeks, which is why the payload window is shorter than the record's.

Enforced by the cron worker after each queue run, and applied immediately when an admin tightens the setting rather than waiting for the next sweep.


Troubleshooting: unable to get local issuer certificate

The single most common first-webhook failure, especially on Windows:

Transport error: SSL certificate problem: unable to get local issuer certificate

This is almost never a problem with your webhook. It means the server has no list of trusted certificate authorities, so it can't confirm it's really talking to Slack or Discord. A stock Windows/WAMP install ships PHP with no CA bundle configured, so every outbound HTTPS call fails this way.

FreeITSM recognises it (webhookDiagnoseError()), explains it in plain English in both the Send-test panel and the delivery log, and deep-links to the fix.

HTTPS Certificates & CA Bundles

One gotcha worth repeating here: WAMP has two php.ini files. Apache's serves the browser (and the Send test button); the CLI's runs scheduled tasks (and the background delivery worker). Fix only Apache's and Send test will pass while every real delivery keeps failing.


Event catalogue

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 / .deleted triggers, each with an <entity>.id and <entity>.name you can condition on — e.g. calendar_category.created, incident_status.deleted, contract.updated. They're generated from WorkflowEngine::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).

Tickets

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
ticket_status.* / ticket_priority.* / ticket_type.* / ticket_origin.* a ticket settings lookup is added / edited / removed <entity>.name

Problems

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
problem_status.* / problem_priority.* a problem settings lookup is added / edited / removed <entity>.name

Changes

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
change_status.* / change_type.* / change_priority.* / change_impact.* a change settings lookup is added / edited / removed <entity>.name

Tasks

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
task_status.* / task_priority.* / task_tag.* a task settings lookup is added / edited / removed <entity>.name

Assets

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
asset_type.* / asset_status.* / asset_location.* an asset settings lookup is added / edited / removed <entity>.name

CMDB

Event Fires when Key payload fields
cmdb.object.created / .updated / .deleted a configuration item is created / edited / removed object.name, object.class_id
cmdb_class.* / cmdb_property.* / cmdb_relationship_type.* a CMDB model definition is added / edited / removed <entity>.name

Knowledge

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

Contracts

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
supplier_contact.* a supplier contact is added / edited / removed supplier_contact.name
contract_status.* / contract_term_tab.* / payment_schedule.* / supplier_status.* / supplier_type.* a contracts settings lookup is added / edited / removed <entity>.name

Calendar

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

Service Status (status page)

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

Software

Event Fires when Key payload fields
software.application_discovered a new application first appears in inventory (agent sync) application.name, application.publisher
software_licence.created / .updated / .deleted a software licence record is added / edited / removed software_licence.name (the licence type)

Morning Checks

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

Network Mapper

Event Fires when Key payload fields
network_diagram.created / .updated / .deleted a network diagram (or a new version of one) is created / saved / removed network_diagram.name (the diagram title)

Forms

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.


Why the service layer unlocks all this

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.


Extending the catalogue (for contributors)

To add a new event:

  1. Register it in workflow/includes/engine.php:
    • add 'module.thing_happened' => 'Human label' to availableTriggers();
    • add its payload field paths to availableFields();
    • (optional but nice) add id-fields to FIELD_LOOKUP_TABLES (dropdowns) and scalar fields to FIELD_TYPES (operator hints).
  2. 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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally