Partner webhooks
Signed messages the licence server sends to your systems about your licences, how to check the HMAC-SHA256 signature, and the retries.
Instead of asking the partner API again and again whether something changed, your systems can be told. The licence
server sends a signed POST to your own address when something happens to one of your licences.
Set up a webhook
Send HostFog Ltd the HTTPS address that should receive the messages. You get a signing secret back, once: store it like your API key. Your address must answer from the public internet over HTTPS. If the secret is lost or leaked, ask for a new one; the old one stops working at once. Licences you already have are not sent when the address is set: only what changes after that.
Events
| Event | Sent when |
|---|---|
licence.issued |
a licence was issued, by the partner API or on the partner page |
licence.changed |
a licence's products or plans changed through the partner API |
licence.moved |
a licence moved to another server; from_ip is the old address |
licence.removed |
a licence was removed |
licence.suspended |
a licence stopped working: revoked, or your bulk licences ended |
server.unseen |
a licensed server has not checked in for 7 days |
What the partner API does is sent at once. Changes made anywhere else, and servers going quiet, are found every few minutes, so they can take up to about 5 minutes to arrive. Each change is sent once.
What a message looks like
Each message is one POST with a JSON body and these headers:
Content-Type: application/json
X-HF-Event: licence.issued
X-HF-Delivery: 9051
X-HF-Timestamp: 1793023200
X-HF-Signature: sha256=5c1f…
X-HF-Event names the event and X-HF-Delivery numbers this delivery. A retry of the same message carries the same
delivery number, so you can skip a message you already handled. The body looks like this:
{
"id": "4f1c2a9e0b7d3c55",
"event": "licence.moved",
"time": "2026-11-02T10:15:00Z",
"partner": {"id": 7, "company": "Example Hosting"},
"licence": {"key": "HFL-…", "email": "customer@example.com", "ip": "203.0.113.20", "hostname": "srv1.example.com",
"status": "active", "licence_status": "active", "last_check_at": "2026-11-02T05:47:00Z",
"version": "1.0.0", "accounts": 42,
"products": [{"product": "panel", "name": "HF Panel", "plan": "pro", "plan_name": "Pro"}]},
"from_ip": "203.0.113.10"
}
licence.status is the same as in the fleet. A licence.removed message carries
only the licence's key, email, ip and hostname.
Check the signature
Never act on a message before checking it. X-HF-Signature is sha256= followed by the hexadecimal HMAC-SHA256 of
<X-HF-Timestamp>.<body>, made with your signing secret.
import hmac, hashlib, time
def genuine(secret: bytes, ts: str, body: bytes, header: str) -> bool:
if abs(time.time() - int(ts)) > 300: # refuse old or replayed messages
return False
want = "sha256=" + hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, header)
function genuine(string $secret, string $ts, string $body, string $header): bool {
if (abs(time() - (int) $ts) > 300) {
return false;
}
$want = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
return hash_equals($want, $header);
}
Important: compute the signature over the body exactly as it arrived, before any JSON parsing, and compare it in constant time (
compare_digest,hash_equals).
Answer and retries
- Answer with any 2xx status, quickly. Do slow work after answering.
- A 5xx, 408 or 429 answer, or no answer at all (10 seconds at most), is tried again 5 times: after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After that it counts as failed.
- Any other 4xx answer counts as failed at once. Redirects are not followed.
- Each retry has a new
X-HF-Timestampand signature, and the sameX-HF-Delivery.
Because a message can arrive more than once, and messages can arrive out of order, treat each one as a hint: when it matters, read the licence's current state with the partner API.