Webhooks
Push-based event delivery from a WHost agent to subscriber endpoints. Use webhooks instead of polling so partner billing systems learn about account / domain / SSL / backup lifecycle events the moment they happen on the agent.
How it works
- The operator registers a subscriber URL in the admin panel (or via the API — see Registration); the signing secret is generated by the agent unless the operator supplies one.
- Each time an operation that maps to a catalog event is written to the audit log, the agent's audit log bridge appends the event to an on-disk queue.
- A background dispatcher reads the queue, POSTs the payload to
every enabled, unmuted subscriber whose
eventsfilter matches, and records each delivery. - Failed deliveries are retried with exponential backoff; after the 5th unsuccessful attempt the delivery is dead-lettered (visible in the delivery history for manual retry).
Event catalog
Stable, public dot-notation identifiers. New events are additive;
existing names never change. Full machine-readable catalog:
GET /api/v1/system/webhooks/events.
account.createdAccount provisioned.account.suspendedAccount suspended (panel or API).account.unsuspendedAccount unsuspended.account.terminatedPermanent deletion completed.account.package_changedPlan / package switch (change-package).account.password_changedAn account's panel password was changed or reset (by the operator, a reseller, the account owner or a mailed reset link). Also sent when the operator changes the admin password — username is then null.domain.addedAddon domain attached (subdomains and parked domains send no event).domain.removedAddon domain removed.ssl.issuedA certificate was installed for a domain that had none: a Let's Encrypt request or a custom upload from the panel or the API, or the automatic certificate of a new addon domain. Not sent for the automatic certificate of a new account or for bulk issuance.ssl.renewedA Let's Encrypt certificate was requested again from the panel or the API for a domain that already had one, and a new certificate was issued. certbot's scheduled renewals (systemd timer or cron job) send no event.ssl.failedA Let's Encrypt request or custom upload from the panel or the API failed on the server side; in bulk issuance, every domain that failed. Automatic certificates (new account, new addon domain) and certbot's scheduled renewals send no event.backup.completedA backup archive was created (manual or scheduled); a failed remote upload is reported in extra.remote_upload_error.backup.failedA backup attempt produced no archive.database.createdMariaDB database provisioned.database.deletedDatabase dropped.email.createdMailbox provisioned.email.deletedMailbox removed.ftp.createdFTP account provisioned.ftp.deletedFTP account removed.dns.record_addedDNS record inserted.dns.record_updatedDNS record modified.dns.record_deletedDNS record removed.plan.createdHosting plan added.plan.updatedHosting plan modified.plan.deletedHosting plan removed.Payload shape
Every outbound delivery is JSON with a stable envelope (Stripe-style). It is shown indented here; the body is sent as compact JSON.
{
"id": "evt_4f2c8c1e9b3d40e98c2e1cf7e9c9c3a2",
"event": "account.created",
"created": 1748640123,
"data": {
"audit_id": "1c2a4f3e-...",
"actor": "admin",
"username": "demo01",
"description":"Account 'demo01' created with plan 'starter'",
"ip_address": "203.0.113.4",
"extra": {
"domain": "demo01.example.com",
"plan": "starter"
},
"timestamp": "2026-05-23T10:42:03.512947+00:00"
}
}
id— unique per event. Use this for idempotent dedupe on the subscriber side; the agent retries after a timeout, a transport error or any non-2xx answer, and a manual retry sends the sameidagain, so duplicate POSTs are expected.event— one of the catalog identifiers above.created— unix seconds; the agent's clock at enqueue time.data— event-specific payload. For an event raised by an operation it is the audit log row:audit_idmatches the row'sidinGET /api/v1/audit-logs,actoris who acted (admin,hmac,reseller,client, …), andextradiffers per event. A test event carries{"test": true, "endpoint_id": …, "note": …}instead (see Test fire).
Outbound HTTP headers
Content-Typeapplication/jsonUser-AgentWHost-Webhook/1.0X-WHost-Webhook-EventEvent name (account.created, ...) — same as the body's event, so a router in front of the receiver can dispatch without parsing the body.X-WHost-Webhook-Event-Idevt_<32 hex> — same as the body's id.X-WHost-Webhook-Delivery-Iddlv_<32 hex> — one per delivery: the automatic retries of a delivery reuse it, a manual retry gets a new one; visible in the agent's delivery history.X-WHost-Webhook-TimestampUnix seconds when the signature was minted (fresh on every attempt).X-WHost-Webhook-Signaturesha256=<hex> — lowercase hex HMAC-SHA256 of "{timestamp}.{raw_body}", keyed with the endpoint secret.Signing model
signature = HMAC_SHA256(
secret,
timestamp + "." + raw_body
)
- Always verify against the raw body bytes. Re-encoding the JSON on the subscriber side will reorder keys / whitespace and break the signature.
- Reject deliveries whose
X-WHost-Webhook-Timestampdrifts more than ±300 seconds from your clock — defeats replay. - Use a constant-time comparison (
hash_equalsin PHP,crypto.timingSafeEqualfromnode:cryptoin Node.js — both buffers must have the same length) when comparing the computed HMAC.
Verifying in PHP (whost-php-sdk)
use WHost\Http\WebhookVerifier;
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WHOST_WEBHOOK_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_WHOST_WEBHOOK_TIMESTAMP'] ?? '';
try {
$event = WebhookVerifier::parse($body, $sig, $ts, $WEBHOOK_SECRET);
} catch (\WHost\Exceptions\WHostException $e) {
http_response_code(400);
exit;
}
// $event is the decoded envelope (id, event, created, data).
Verifying without the SDK
# Bash — verify the HMAC manually
expected=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$secret" | awk '{print $2}')
test "sha256=$expected" = "$received_signature"
Retry + dead-letter
Failed deliveries (a timeout or other transport error, or any non-2xx response — redirects are not followed) are retried on this schedule:
| Attempt | Delay before retry |
|---|---|
| 1 | immediate |
| 2 | 1s |
| 3 | 10s |
| 4 | 60s |
| 5 | 5 min |
| 5+ | dead-letter |
Each attempt is recorded: while the dispatcher is still retrying, the
delivery reads failed with attempts and next_attempt_at; the final
record for the same delivery_id (success or dead_letter) supersedes it.
If the agent stops while a delivery waits for its next attempt, the
delivery keeps a final failed record and is not retried automatically —
retry it manually.
A delivery in the dead_letter state is visible in
GET /api/v1/system/webhooks/deliveries?status=dead_letter and can be
re-enqueued manually via POST /api/v1/system/webhooks/deliveries/{id}/retry.
Subscribers SHOULD:
- Return 2xx for any payload they accept (even unknown event types) — the agent treats non-2xx as a delivery failure.
- Acknowledge quickly (< 5s); the agent times out individual attempts at 10 seconds.
- Move heavy processing off the request path (queue, worker) so the ACK comes back before the timeout.
- Dedupe on the body's
id— one delivery makes up to 5 attempts with the sameid, and a manual retry sends it again; never increment counters twice for one event.
SSRF defenses
The agent accepts only http:// and https:// URLs and refuses to
register or deliver to URLs that resolve to private, loopback,
link-local, CGNAT or unspecified addresses:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16100.64.0.0/10(CGNAT),0.0.0.0/8169.254.0.0/16(link-local)127.0.0.0/8,::/128,::1/128,fc00::/7,fe80::/10- IPv4-mapped IPv6 addresses (
::ffff:…) are checked as the IPv4 address they carry - also ranges that never name a public destination:
192.0.0.0/24,198.18.0.0/15,224.0.0.0/4(multicast),240.0.0.0/4(reserved, including255.255.255.255),::/96,fec0::/10,ff00::/8,64:ff9b:1::/48; a NAT64 (64:ff9b::/96) or 6to4 (2002::/16) address is checked as the IPv4 address it carries
A hostname that does not resolve is refused as well.
DNS resolution is re-checked at delivery time — a registered hostname
that later flips to a blocked address, or no longer resolves, is
dead-lettered without sending the request (0 attempts; the record's
error starts with ssrf_block).
Every attempt of a delivery connects to the addresses that check approved:
the hostname is not looked up again between the check and the connection,
and the retries go to the same addresses. The request keeps the hostname in
its Host header and, over HTTPS, as the TLS server name, so certificate
validation is unchanged. A hostname with several addresses is tried in
resolver order; the next address is used only when no connection could be
opened to the previous one.
Registration
Admin panel
Settings (gear icon in the top bar) → All Settings → Webhooks → Add endpoint. Leave Secret empty to have one generated, or enter your own (16–128 printable ASCII characters, no spaces). After the endpoint is saved, the panel shows the secret once in the Webhook secret created dialog — copy it to the subscriber then.
API (HMAC)
The signature covers the full path /api/v1/system/webhooks and the exact
body string that is sent:
BODY='{"url":"https://billing.example.com/whost/webhook","events":["account.created","account.suspended","account.terminated"],"description":"Billing system order lifecycle"}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s\n%s\n%s\n%s\n%s' POST /api/v1/system/webhooks "$TS" "$NONCE" "$BODY" \
| openssl dgst -sha256 -hmac "$WHOST_API_SECRET" | awk '{print $NF}')
curl -X POST https://hosting.example.com/api/v1/system/webhooks \
-H "X-WHost-Key: $WHOST_API_KEY" \
-H "X-WHost-Timestamp: $TS" \
-H "X-WHost-Nonce: $NONCE" \
-H "X-WHost-Signature: $SIG" \
-H "Content-Type: application/json" \
--data-binary "$BODY"
events must name at least one event; an empty list is refused with
400 WEBHOOK_VALIDATION, on create and on update alike. secret is
optional: omit it to have a 43-character random secret generated, or send
16–128 printable ASCII characters without whitespace.
Response (one-time view of the plaintext secret):
{
"status": "success",
"data": {
"id": "whk_a1b2c3...",
"url": "https://billing.example.com/whost/webhook",
"secret": "<generated secret, 43 characters>",
"events": ["account.created", "account.suspended", "account.terminated"],
"description": "Billing system order lifecycle",
"enabled": true,
"created_at": "2026-05-23T10:00:00.418204+00:00",
"updated_at": "2026-05-23T10:00:00.418311+00:00",
"muted_until": null
},
"message": "Webhook endpoint registered. Save the secret — it won't be shown again."
}
Secret rotation
POST /api/v1/system/webhooks/{id}/rotate
Returns the new plaintext secret once, in the JSON response body:
{
"status": "success",
"data": { "endpoint_id": "whk_…", "secret": "<new secret, 43 characters>" },
"message": "Secret rotated. Subscriber must use this value immediately."
}
Copy it immediately — GET /api/v1/system/webhooks/{id} (like the list
and the update response) returns the secret masked: every character
except the last four is replaced by *. If you lose the secret, rotate
again.
Why rotate?
- Subscriber-side leak suspicion (CI logs, error tracker, backup dump).
- Operator turnover — the person who first saved the secret is leaving.
- Scheduled rotation policy (every 90 days is a common SOC requirement).
No rolling window — coordinate the cut-over.
Every delivery dispatched after the rotation call returns 200 is signed with the new secret. Deliveries that were already in their retry schedule keep signing with the secret they started with, until their last attempt (up to about seven minutes after their first). Subscribers must accept the new value before the next event fires, or signature verification will fail and the delivery will land in the retry queue (eventually dead-letter).
Recommended subscriber-side rotation flow:
- Generate the new secret on a calm cycle (off-peak hours).
- First, deploy the subscriber update that accepts BOTH the old AND the new secret (try old first, fall back to new). This is a per-call try/except, not a config flag — keeps verification cheap and avoids race windows.
- Trigger the rotation API call. The agent signs every new delivery with the new secret from then on.
- Confirm via the test-fire endpoint (see Test fire) that a real signed payload now matches the new secret on your side.
- Second deploy: drop the old-secret fallback path.
This three-step coordination is the same pattern Stripe and Slack recommend for their webhook rotations. Two-secret subscribers also cover the gap between "operator hits rotate" and "subscriber redeployed", and the retries of deliveries that started before the rotation.
If you cannot run the two-deploy pattern (subscriber is a frozen
SaaS, etc.), mute the endpoint first via muted_until, rotate, update
the subscriber's secret, then unmute. Mute / unmute is a single
PUT /api/v1/system/webhooks/{id} call: set muted_until to an ISO-8601
time (a value without a zone is read as UTC; the mute ends by itself at
that time), or send "muted_until": "" to unmute early — null leaves
the field unchanged. A mute does not hold events back: events that occur
while the endpoint is muted (or disabled) are not queued for it and are
not delivered afterwards — reconcile them from GET /api/v1/audit-logs.
Delivery history + manual retry
GET /api/v1/system/webhooks/deliveriesPaginated list, newest first (limit 1–500, default 50; offset; filter by endpoint_id, status — success, failed, dead_letter — and event_type).GET /api/v1/system/webhooks/deliveries/{id}Full record incl. original payload and last response excerpt (first 512 characters). 404 DELIVERY_NOT_FOUND for an unknown id.POST /api/v1/system/webhooks/deliveries/{id}/retryRe-enqueue the original event (same event id) at the delivery's own endpoint; the new delivery gets a fresh delivery_id. 404 WEBHOOK_NOT_FOUND when that endpoint is gone, 409 WEBHOOK_DISABLED while it is disabled or muted, 400 DELIVERY_PAYLOAD_MISSING when the stored payload is gone.Test fire
POST /api/v1/system/webhooks/{id}/test
Enqueues a synthetic event addressed at this endpoint only (the first
event in the endpoint's filter list); other subscribers of the same event
do not receive it. Its data is
{"test": true, "endpoint_id": "whk_…", "note": "Synthetic webhook fired from admin panel"}
— handle it without side effects. A disabled or muted endpoint answers
409 WEBHOOK_DISABLED. Useful for confirming network connectivity +
signature validation before relying on the endpoint for production
traffic.
Our support team is here around the clock for anything you can't find above.