-
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. You pick one of four formats for the Send-webhook action:
-
Slack / Teams / Discord presets β shape a templated
messageinto 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).
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.*andnetwork_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.fullobject.
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.
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 (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.
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.
Three things about a webhook end up on disk. It's worth knowing what happens to each.
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.urlisVARCHAR(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 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.
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.
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 |
ticket_status.* / ticket_priority.* / ticket_type.* / ticket_origin.*
|
a ticket settings lookup is added / edited / removed | <entity>.name |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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
|
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 |
| 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
|
software_licence.created / .updated / .deleted
|
a software licence record is added / edited / removed |
software_licence.name (the licence type) |
| 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 |
| 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) |
| 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)