Every cPanel server already fires a structured event for the things you wish you could notify on — account created, account suspended, password changed, SSL installed, domain parked. The Standardized Hooks system is how you wire your own script into those events without patching cPanel itself. Hooks survive upgrades, they are scoped by stage (pre or post), and pre-hooks can refuse an action outright by returning a non-zero result.
This guide covers when to use a hook instead of a WHM API token polling loop, how to register one with manage_hooks, how to write the script so cPanel actually invokes it, and how to debug the inevitable "the hook is registered but nothing fires" problem.
When to use a hook (and when not to)
Hooks are the right tool when you need to react synchronously to a cPanel event:
- Push every new account into your CRM the moment it provisions.
- Sync user passwords to a downstream system (LDAP, Mattermost) without scraping logs.
- Block the creation of accounts on domains that match a regex (an industry filter, a legal hold list).
- Tear down billing-side records when an account is removed in WHM before the billing poller next runs.
They are the wrong tool when you want a scheduled summary, a daily reconciliation, or anything that can tolerate a 5-minute lag. For those, poll a scoped WHM API token on a cron — a hook that fires 4,000 times a day per server because someone is iterating accounts in a script is a load problem you do not need.
The other constraint: hooks run as root (or as the user that owns the event, for some
user-context hooks). Code that runs as root inside the cPanel event path is a supply-chain
target. Audit anything you put there as if it were a CI runner with prod access.
Anatomy of a hook
A registered hook is a row in /var/cpanel/hooks.yaml that points cPanel at:
- A category —
Accounts,Whostmgr,Cpanel,Backup,Mail, etc. - An event within that category —
Create,Remove,Modify,Suspend,SiteIP,Changepasswd. - A stage —
pre(runs before the event, can block it) orpost(runs after, cannot block but sees the result). - A script or Perl module to invoke. Script hooks are simpler; module hooks live in
/var/cpanel/perl/and are appropriate when you need to share state across multiple events.
When the event fires, cPanel passes a JSON payload on stdin. Your script reads it,
optionally does work, and prints a JSON response on stdout: {"result": 1} for "carry
on" or {"result": 0, "msg": "human-readable reason"} to block a pre-event.
Step 1 — Discover the event you actually want
cPanel's event names are not always intuitive — Whostmgr::Accounts::Create and
Cpanel::Accounts::Create are different events with different payloads. Before you
register, ask the server what's available:
/usr/local/cpanel/bin/manage_hooks list
That dumps registered hooks. To see the event catalog and the data each one passes, inspect the hookable function map:
ls /usr/local/cpanel/Cpanel/Hooks/
ls /var/cpanel/perl/Cpanel/Hooks/
For account events specifically, the canonical reference is
/usr/local/cpanel/Whostmgr/Accounts/Create.pm — read the source for the exact field
names you'll see in the payload. There is no substitute for this when the docs lag a
release.
For a quick payload sample, register a logging stub against the event, fire the event once, and grep the log:
cat > /usr/local/bin/hook-debug.sh <<'EOF'
#!/bin/bash
cat >> /var/log/hook-debug.log
echo '{"result":1}'
EOF
chmod +x /usr/local/bin/hook-debug.sh
/usr/local/cpanel/bin/manage_hooks add script /usr/local/bin/hook-debug.sh \
--category Whostmgr --event Accounts::Create --stage post
Create a test account in WHM, then cat /var/log/hook-debug.log — every field you can
read from inside a hook is in that file.
Step 2 — Write the script
Hook scripts must be executable, must read stdin, and must print exactly one JSON object
on stdout. Anything else (a stray print from a debug line, a Python framework writing a
banner) confuses the parser and the hook is treated as a soft failure.
A minimal account-creation post-hook in Python:
#!/usr/bin/env python3
import json
import sys
import urllib.request
payload = json.loads(sys.stdin.read())
data = payload.get("data", {})
# Whostmgr::Accounts::Create post-hook payload contains the new account context
account = {
"username": data.get("user"),
"domain": data.get("domain"),
"plan": data.get("plan"),
"ip": data.get("ip"),
}
req = urllib.request.Request(
"https://crm.internal.example.com/hooks/cpanel-account",
data=json.dumps(account).encode("utf-8"),
headers={"Content-Type": "application/json",
"Authorization": "Bearer REPLACE_WITH_SECRET"},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=5)
except Exception:
# Post-hooks cannot undo the account creation — never raise here
pass
print(json.dumps({"result": 1}))
Two non-obvious rules:
- Post-hooks should never raise. The account has already been created. An exception
in your hook does not roll it back; it just generates noise in
error_logand leaves your downstream out of sync. Wrap network calls intry/exceptand queue retries in your own system. - Time out fast. cPanel waits for your hook to complete before continuing. A hook that blocks for 30 seconds on a flaky CRM hold WHM up for 30 seconds for every account created. Set a tight timeout and fail open.
For a pre-hook that blocks, the script returns result: 0:
print(json.dumps({
"result": 0,
"msg": "Domain matches the reserved-name list; create disallowed."
}))
WHM surfaces msg to the operator. Keep it specific — "blocked by policy" wastes a
support ticket.
Step 3 — Register the hook
manage_hooks add takes a script or module argument plus the category, event, and
stage. Use the full absolute path; hooks do not inherit a shell PATH:
/usr/local/cpanel/bin/manage_hooks add script /usr/local/bin/sync-account-to-crm.py \
--category Whostmgr --event Accounts::Create --stage post
Verify it's wired:
/usr/local/cpanel/bin/manage_hooks list category=Whostmgr event=Accounts::Create
You should see the script path, the stage, and the hook ID. To remove:
/usr/local/cpanel/bin/manage_hooks delete hook_id=<id>
The hook ID is what list printed in the first column.
Step 4 — Debug a hook that "should be firing"
Three failure modes account for most of the silent-hook tickets:
- Script is not executable.
chmod +xit.manage_hooks addwill register a non-executable file without complaining. - Script writes to stdout before the JSON response. Any line of debug output gets parsed as JSON and your hook is treated as malformed. Send logs to stderr or to a file instead.
- You registered the wrong category.
Whostmgr::Accounts::Createfires when an account is created from WHM orcreateacct.Cpanel::Accounts::Createdoes not exist — there is no per-user "create my own account" surface — butCpanel::Accounts::SiteIPdoes fire on the user side, for example. Read the source under/usr/local/cpanel/Whostmgr/or/usr/local/cpanel/Cpanel/to confirm the namespace.
cPanel logs hook execution to /usr/local/cpanel/logs/error_log and, for blocked
pre-hooks, to the originating WHM action's response. Tail both while you reproduce:
tail -f /usr/local/cpanel/logs/error_log /var/log/hook-debug.log
If nothing appears in error_log when you fire the event, the hook is not registered
against the right event. If error_log shows the hook firing but no log file appears,
the script is being invoked but exiting before it writes — usually a permission problem
on the log path.
Step 5 — Test the pre-hook block path before relying on it
A pre-hook that blocks is a policy enforcement point. You want to know it actually blocks, not just that it runs. Register the hook, then attempt the action that should be refused. For an account-creation block:
whmapi1 createacct username=blocked01 domain=test-reserved.example.com plan=default
If the hook is doing its job, the response includes result: 0 and your msg. If the
account creates anyway, the hook is registered against the wrong stage (you registered
post, not pre) or the script is returning result: 1 for the input you thought was
blocked.
Pre-hooks that block accidentally are worse than no pre-hook. Always include a kill switch — for example, an environment variable check at the top of the script — so you can disable the policy without unregistering the hook:
import os
if os.environ.get("CPANEL_HOOK_BYPASS") == "1":
print('{"result":1}')
sys.exit(0)
CPANEL_HOOK_BYPASS then becomes an emergency override. Set it on the WHM process
environment only when you genuinely need to bypass.
Next steps
- Pair hooks with scoped credentials so your script writes back to WHM safely: Create and scope WHM API tokens.
- If the hook is going to talk to your billing platform, the Blesta cPanel provisioning module is the cleaner integration for account lifecycle events.
- Tune brute-force defaults so hook-triggered actions are not buried under noise: WHM cPHulk tuning. And size your fleet against cPanel license tiers before assuming a hook scales — license caps bite first.