Event hooks
The events HF Panel raises, their JSON payloads, signed webhooks with retries, and root script hooks in /etc/hostfog/hooks.
HF Panel raises an event each time something happens on the server: a hosting account is created, a domain is removed, a backup finishes, a staff member signs in. You can send these events to your own systems with webhooks, or run your own programs on the server with script hooks. Both get the same JSON payload.
Where: Server Admin › System › Plugins & hooks
Hooks never hold up the person who did the action: they run in the background, and a hook that fails is written to a log, never shown as an error.
Events
| Event | When it is raised | Its own fields |
|---|---|---|
account.created |
a hosting account is created (Accounts screen, the API, an order, an import) | account, account_id, email, package, domain, reseller |
account.suspended |
an account is suspended (by staff, the API or unpaid invoices) | account, account_id |
account.unsuspended |
a suspended account is switched back on | account, account_id |
account.deleted |
an account is deleted | account, account_id |
account.package_changed |
an account moves to another package | account, account_id, package |
accounts.bulk_changed |
a bulk change of accounts has finished (or was cancelled) | job_id, kind, status, by, total, changed, unchanged, refused, failed, skipped |
account.limit_reached |
an account has been at a CPU, memory, process or disk limit for 15 minutes, or a process was stopped for lack of memory (once a day per kind) | account, account_id, kind (cpu, memory, procs, io or oom), minutes, detail |
account.moved |
an account moved to another server (Move accounts or Server balancing), or an automatic move was rolled back | account, account_id, from, to, move_id, automatic, rolled_back |
account.password_changed |
an account's password changes | account, account_id, by (self, staff or system) |
domain.added |
a website, subdomain or parked domain is added | account, account_id, domain, kind (main, subdomain, alias) |
domain.removed |
a domain is removed | account, account_id, domain |
email.created |
a mailbox is created | account, account_id, address, domain, quota_mb |
email.deleted |
a mailbox is deleted | account, account_id, address, domain |
mail.limit_reached |
an account reaches its emails per hour or per day (once per hour or day) | account, account_id, kind (hour or day), window, sent, limit |
mail.spike_held |
an account suddenly sends far more than usual and its sending is put on hold | account, account_id, kind (spike), window, sent, limit |
database.created |
a MySQL, PostgreSQL or MongoDB database is created | account, account_id, database, engine (mysql, postgresql, mongodb) |
database.deleted |
a database is deleted | account, account_id, database, engine |
db.dump_finished |
a database dump is made (by hand, on a schedule or through the API) | account, account_id, database, engine (mysql, postgresql), file, size (bytes), source (manual, schedule) |
db.optimised |
a maintenance task (optimise, repair, analyse, check, VACUUM ANALYZE) finished | account, account_id, database, engine, operation, table (empty = every table), tables, problems |
ssl.issued |
a certificate is issued or renewed | account, account_id, domain, kind (letsencrypt, wildcard, custom, renew, autossl) |
backup.finished |
a full backup of an account is made | account, account_id, backup |
backup.failed |
a full backup of an account could not be made | account, account_id, error |
panelarc.finished |
the nightly panel backup was made and copied | name, size, stores (names of the storages that got it) |
panelarc.failed |
the panel backup failed | error, stores (the storages that failed) |
backup.target_added |
a customer adds own backup storage | account, account_id, target, kind (s3, sftp) |
backup.target_removed |
a customer removes own backup storage | account, account_id, target, kind |
backup.remote_uploaded |
a backup is copied to a customer's own storage | account, account_id, target, backup |
backup.remote_failed |
a copy to a customer's own storage failed | account, account_id, target, error |
backup.remote_restored |
a customer restores from their own storage | account, account_id, target, backup, what |
terminal.started |
a customer opens the web terminal | account, account_id, ref, ip, by (a staff or team member, or empty) |
terminal.ended |
a web terminal session ends | account, account_id, ref, reason (closed, exit, idle, limit, suspended, signed_out), seconds |
ssh.key_added |
an SSH key is added (also by import or a key pair made on the server) | account, account_id, name, type, fingerprint |
ssh.key_removed |
an SSH key is removed | account, account_id, name, fingerprint |
ssh.access_changed |
a customer switches SSH and SFTP logins on or off | account, account_id, enabled |
wordpress.updated |
a safe WordPress update installed its updates and every check passed | account, account_id, site, url, items (kind, slug, from, to), backup, source (auto, manual, api) |
wordpress.rollback |
a safe WordPress update broke a check and the backup was put back | account, account_id, site, url, item (kind, slug, from, to), reason, restored, source |
app.installed |
an app of the App installer was installed on a website | account, account_id, app, version, url, domain |
site.cloned |
a website was copied (also into another account) | account, account_id (the account that got the copy), from, from_account, domain, database, kind |
template.created |
a site template was saved | account, account_id, template_id, title, scope (own, host), domain, size |
site.from_template |
a website was made from a site template | account, account_id, domain, template_id, template, scope, database |
staging.pushed |
a staging copy was pushed to the live site | account, account_id, domain, staging, what (files, db, both), backup |
preview.deployed |
a Git branch's preview website was built | account, account_id, repository, branch, url, commit, status (ready, failed) |
malware.found |
a malware scan finds new suspicious files in an account | account, account_id, files, paths (up to 50, below web/) |
malware.cleaned |
a file is cleaned in place or moved to quarantine (by a person or the policy) | account, account_id, path, signature, action (clean or quarantine), automatic |
site.cache_purged |
a website's page cache is emptied (Speed test, PHP settings or the API) | account, account_id, domain, by (panel or api) |
site.speed_changed |
a speed setting of a website changes | account, account_id, domain, setting (page_cache, cache_query_strings, compression, browser_cache_days), value |
speed.tested |
a speed test finishes (also the weekly ones) | account, account_id, domain, score, ttfb_ms, error (empty when it worked) |
site.check_finished |
a scheduled or "Check now" site check finishes | account, account_id, domain, run_id, pages, issues, new, fixed, stopped (pages, time or empty), error (empty when it worked), manual |
images.optimised |
the image optimiser finishes | account, account_id, domain, images, changed, saved_bytes, webp |
login.succeeded |
an administrator or reseller signs in | user, user_id, role, ip, method (password or passkey) |
panel.updated |
HF Panel was updated to a new version (seen within 5 minutes) | from, to |
config.restored |
an administrator put back an earlier version of a server config file (Config history) | path, version, version_time, service, by |
status.incident |
an incident or maintenance is posted, updated, edited, started or finished on the status page | incident_id, action, kind, title, impact, status, parts, url |
chat.started |
a customer or website visitor starts a live chat | conversation, source (panel or website), account and account_id, or visitor |
chat.message |
a message is sent in a live chat | conversation, source, account/account_id or visitor, from (customer, visitor or staff), text (first 500 characters) |
chat.closed |
a live chat is closed | conversation, source, account/account_id or visitor, by (customer, visitor or staff) |
ticket.rated |
a customer rates a closed support ticket (or changes the rating) | ticket, subject, score (1–5), comment, account, account_id, staff, staff_id |
chat.rated |
a customer rates an ended live chat (or changes the rating) | conversation, score (1–5), comment, account/account_id (not for a website visitor), staff, staff_id |
team.member_invited |
a customer (or their team member, or staff) invites a team member | account, account_id, member_id, email, role, by |
team.member_changed |
a team member's role changes, or they are suspended or let in again | account, account_id, member_id, email, role, status, by |
team.member_removed |
a team member is removed | account, account_id, member_id, email, by |
reseller.domain_changed |
a reseller saves or removes their own addresses | reseller, reseller_id, panel_host, webmail_host, files_host, website_url |
reseller.domain_verified |
one of a reseller's own addresses points to the server and has its certificate | reseller, reseller_id, host, kind (panel, webmail, files), dns, ssl |
coupon.created |
a discount code is made | code, kind (percent, amount, trial), value, duration, applies |
coupon.used |
a discount code's use is paid (an order, a licence or an invoice made by hand) | code, kind (order, licence, invoice), account_id, discount, currency |
announcement.sent |
an announcement goes out (now, or when its time comes) | announcement, subject, kind, audience, channels, recipients, by |
kb.article_published |
a help article is published | article, title, slug, visibility, owner_id |
onboarding.completed |
a customer finishes the welcome wizard | account, account_id, steps |
privacy.export_ready |
a customer's personal data export is ready and emailed | account, account_id, request, expires_at |
privacy.deletion_requested |
a customer confirms deleting their account (the waiting time starts) | account, account_id, request, due_at, automatic |
privacy.deletion_done |
an account is deleted on the customer's request, after its final backup | account, account_id, request, backup, backup_until, by |
twostep.policy_changed |
the two-step sign-in policy of a role changes (also a reseller's for their customers) | role, policy (required or optional), grace_days, by, reseller |
twostep.reset |
an administrator or reseller resets a login's two-step sign-in | user, user_id, role, by, passkeys |
abuse.received |
a complaint arrives on the abuse desk | complaint, source (email, arf, form, api), kind, ip, domain, account, account_id |
abuse.status_changed |
a complaint's status changes | complaint, status, from, by |
abuse.action |
a one-click action is run from a complaint | complaint, action (suspend_account, pause_mail, suspend_site, lock_mailbox), target, account, account_id, by |
ipaddr.assigned |
a website or account gets an IPv6 address of its own | account, account_id, address, domain (empty: every website) |
ipaddr.released |
such an address is taken back | account, account_id, address |
dnssec.enabled |
DNSSEC is turned on for a zone | domain, account, account_id, ds_published (the panel sent the DS records to the registrar) |
dnssec.disabled |
DNSSEC is turned off for a zone | domain, account, account_id |
firewall.rules_changed |
a website's firewall rules are saved (screen or API) | account, account_id, domain, allow, block (counts), countries, login_limit, bot, by |
cron.failed |
a monitored cron job ends with an exit code other than 0 (once, until it works again) | account, account_id, job_id, schedule, command, exit_code, duration, output (first 2,000 characters) |
cron.missed |
a monitored cron job did not start within its schedule and grace time | account, account_id, job_id, schedule, command, expected_at, last_run_at |
server.capacity_low |
disk space, files or memory is expected to run out within a warning step (14, 7, 2 days) | resource (e.g. disk:/), label, days_left, step, used, total, unit |
The Events tab of the screen shows each event with an example payload.
The payload
Every event is one JSON object. These fields are always there:
| Field | What it is |
|---|---|
id |
a random id for this event (16 hexadecimal characters) |
event |
the event's name, for example account.created |
time |
when it happened, in UTC (RFC 3339) |
server |
the panel's address, for example panel.yourdomain.com |
actor |
the username of the person who did it (the staff member when they use Log in as), or empty when the panel did it by itself |
The event's own fields follow. For example:
{
"id": "6f1c2a9e0b7d4c35",
"event": "account.created",
"time": "2026-10-25T08:00:00Z",
"server": "panel.yourdomain.com",
"actor": "admin",
"account": "sarahbakes",
"account_id": 3,
"email": "sarah@example.com",
"package": "Basic",
"domain": "sarahbakes.co.uk",
"reseller": ""
}
Note: a payload never holds a password, a secret, a token or a key. HF Panel removes any field with such a name before it sends the event.
Webhooks
A webhook sends the events you choose to an address of yours, as a POST with the payload as its body.
Add a webhook
- Open Plugins & hooks and click the Webhooks tab.
- Enter the Address (https://), tick the Events it should get (or click All events), and keep On ticked.
- Click Add webhook. The message at the top shows the webhook's signing secret once. Keep it in your receiving program.
To see the secret again, click Show secret. To replace it, click New secret: the old one stops working at once. Both are written to the activity log.
What HF Panel sends
POST /hostfog HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: HF Panel hooks
X-HF-Event: account.created
X-HF-Delivery: 42
X-HF-Timestamp: 1792915200
X-HF-Signature: sha256=9b2f0c…
{"account":"sarahbakes","account_id":3,"actor":"admin","event":"account.created",…}
| Header | What it is |
|---|---|
X-HF-Event |
the event's name |
X-HF-Delivery |
the delivery's number in the log (the same number on every retry) |
X-HF-Timestamp |
when this attempt was sent (Unix time) |
X-HF-Signature |
sha256= and the hexadecimal HMAC-SHA256 of the raw body, made with the webhook's signing secret |
Check the signature
Work out the HMAC-SHA256 of the body exactly as you received it, with the signing secret as the key, and compare it
with the header in constant time. Refuse the request when they differ. To refuse old requests sent again, also check
that the payload's time is recent.
<?php
$secret = 'whsec_…';
$body = file_get_contents('php://input');
$sig = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($sig, $_SERVER['HTTP_X_HF_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// … do your work, then answer quickly with any 2xx status
http_response_code(204);
import hashlib, hmac
def valid(secret: str, body: bytes, header: str) -> bool:
sig = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, header)
Retries and the delivery log
- An answer with a
2xxstatus counts as delivered. Answer within 10 seconds: a slower answer counts as a failure. - A network error, a
5xx,408or429is tried again 3 times: after 10 seconds, 1 minute and 5 minutes. Any other answer (for example404) fails at once. - Redirects are not followed.
- The Deliveries card shows the last 100 deliveries: when, the event, the address, the status (sent, retry, failed), the attempts and the answer's status code. The body of your answer is never stored.
- Click Send test to send a
hook.testevent to one webhook at once (one attempt, no retries).
Addresses you can use
The address must start with https:// and point at a public address. HF Panel refuses private, loopback,
link-local and reserved addresses (for example 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16, 169.254.169.254,
::1) and names such as localhost, both when you save the address and each time it connects, so a name that
points at such an address later is refused too. An address with a user name or password in it is refused. You can
have up to 20 webhooks.
Script hooks
A script hook is a program you put on the server as root. It runs as root for each event, with the payload on its standard input.
Add a script hook
-
Make a folder for the event, owned by root:
install -d -m 755 /etc/hostfog/hooks/account.created.d -
Put an executable file in it, owned by root, that only root can change:
bashcat >/etc/hostfog/hooks/account.created.d/10-welcome.sh <<'SH' #!/bin/sh payload="$(cat)" echo "$payload" >>/var/log/new-accounts.json SH chmod 755 /etc/hostfog/hooks/account.created.d/10-welcome.sh -
Open the Script hooks tab to check that its status is Runs.
How script hooks run
- The files of a folder run one after the other, in name order (start their names with
10-,20-… to choose). - Each file gets the payload on its standard input and in the file named by
$HF_PAYLOAD_FILE, and the event's name in$HF_EVENT. Nothing else from the panel's environment is passed on. - A file may run for 30 seconds; then it is stopped. Its exit status and what it prints (up to 64 KB) are written to
/var/log/hostfog-hooks.log. The end of that log is shown on the Script hooks tab. - Script hooks run on the main HF Panel server, also for accounts on other servers of a cluster.
A file is skipped, and the tab says why, when it:
| Status | Why |
|---|---|
| Not owned by root | its owner is not root |
| Others can change it | its group or other users can write it |
| Not executable | it has no execute permission |
| A symlink: skipped | it is a symbolic link |
| Name not allowed | its name has characters other than letters, digits, ., _ and -, or it is a backup copy (name~, name.dpkg-old, name.bak) |
| Folder not safe | the event's folder, or /etc/hostfog/hooks, is not owned by root or others can write it |
Warning: a script hook runs as root. Never let anyone else write to
/etc/hostfog/hooks, and treat the payload's text as data: quote it, and never pass it to a shell as a command.