Webhooks
Webhooks let your own service receive a signed HTTP POST whenever something happens to your instances or Kubernetes clusters, instead of polling the API. You register a subscription (a URL, a secret and a list of event kinds), and the platform delivers a JSON body to that URL as matching events occur.
This page covers the user API surface (/api) only: subscribing, the published event-kind catalog, the delivery request and its signature, the two payload shapes (instance and Kubernetes cluster), and how retries and auto-disable work.
Subscribing
Section titled “Subscribing”A subscription belongs to your account (or, for a subuser, the account owner). All webhook routes require the Authorization: Bearer <TOKEN> header described in the API overview.
List available event kinds
Section titled “List available event kinds”GET /api/webhook-event-kindsReturns the full catalog your event_kinds values are validated against. Call this to build a subscribe form, or to check a kind before you use it.
{ "success": true, "data": [ { "kind": "cluster.created", "resource": "kubernetes_cluster", "description": "A Kubernetes cluster finished provisioning." }, { "kind": "instance.created", "resource": "instance", "description": "An instance was created." } ]}The full table is in Event kinds below.
Create a subscription
Section titled “Create a subscription”POST /api/webhook-subscriptions| Field | Required | Notes |
|---|---|---|
name |
yes | Friendly label, up to 255 characters. |
url |
yes | HTTPS endpoint to POST deliveries to, up to 1024 characters. HTTP is rejected, and internal, link-local and loopback targets are rejected at save time, the same way outbound notification-channel URLs are. |
secret |
yes | 16 to 255 characters. This is the HMAC-SHA256 signing secret. It is never returned again once you save it, so store it wherever you verify signatures. |
event_kinds |
yes | Array of 1 to 50 values. Each entry is either * (all kinds) or an exact kind from the catalog. An unknown value is rejected with a 422 that names the value and points at GET /api/webhook-event-kinds. |
cluster_filter |
no | Array of up to 100 cluster UUIDs you own. Limits delivery to those clusters. Applies to Kubernetes kinds only, see cluster_filter scope. |
enabled |
no | Defaults to true. |
A request body larger or shaped differently than this fails validation with Laravel’s standard {message, errors} envelope and a 422, never a 500. There is a cap of 20 webhook subscriptions per account; creating a 21st subscription is a 422.
curl -X POST https://panel.example.com/api/webhook-subscriptions \ -H "Authorization: Bearer <TOKEN>" \ -H "Content-Type: application/json" \ -d '{ "name": "ops-events", "url": "https://example.com/hooks/hypervisor", "secret": "a-long-random-signing-secret", "event_kinds": ["instance.suspended", "instance.unsuspended", "cluster.created"] }'A successful create answers 201 with the stored subscription (the secret is never included in the response, even on create):
{ "success": true, "webhook_subscription": { "id": "3f9c2a10-8e1b-4f7a-9d3c-6b2f0a1e4c8d", "name": "ops-events", "url": "https://example.com/hooks/hypervisor", "event_kinds": ["instance.suspended", "instance.unsuspended", "cluster.created"], "cluster_filter": null, "enabled": true, "consecutive_failures": 0, "last_delivery_status": null }}List, show and delete
Section titled “List, show and delete”GET /api/webhook-subscriptionsGET /api/webhook-subscription/{id}GET /api/webhook-subscription/{id}/deliveriesPATCH /api/webhook-subscription/{id}DELETE /api/webhook-subscription/{id}Notice the list route is plural (/webhook-subscriptions) and every item route is singular (/webhook-subscription/{id}). This is not a typo: it is the convention across the whole user API (the same split exists for vpcs/vpc, projects/project, security-groups/security-group, and every other resource except ssh-keys). Expect it, do not build against the plural for a single-item call.
PATCH /api/webhook-subscription/{id} accepts any subset of the create fields. secret is only replaced when you send a new one; omit it to keep the existing secret. Setting enabled: true on a subscription that was auto-disabled also resets its consecutive-failure counter to zero, so it starts clean:
curl -X PATCH https://panel.example.com/api/webhook-subscription/3f9c2a10-8e1b-4f7a-9d3c-6b2f0a1e4c8d \ -H "Authorization: Bearer <TOKEN>" \ -H "Content-Type: application/json" \ -d '{"enabled": true}'DELETE removes the subscription and its delivery history; any delivery jobs already queued for it cancel themselves rather than erroring.
Event kinds
Section titled “Event kinds”The table below is the same list GET /api/webhook-event-kinds returns. It is not maintained by hand on this page.
| Kind | Resource | Description |
|---|---|---|
cluster.created |
kubernetes_cluster |
A Kubernetes cluster finished provisioning. |
cluster.deleted |
kubernetes_cluster |
A Kubernetes cluster was deleted. |
cluster.updated |
kubernetes_cluster |
A Kubernetes cluster configuration changed (e.g. a load balancer certificate attach/detach result). |
cluster.autoscaling_updated |
kubernetes_cluster |
A worker pool’s autoscaling settings were changed. |
pool.created |
kubernetes_cluster |
A worker node pool was created. |
pool.created.scale_failed |
kubernetes_cluster |
A newly created worker node pool failed its initial scale-up. |
pool.updated |
kubernetes_cluster |
A worker node pool’s configuration was changed. |
pool.deleted |
kubernetes_cluster |
A worker node pool was deleted. |
pool.default_reassigned |
kubernetes_cluster |
The cluster’s default worker node pool was reassigned to a different pool. |
pool.deletion_max_attempts |
kubernetes_cluster |
A pending node pool deletion exhausted its retry attempts and needs operator attention. |
admin.force_destroy |
kubernetes_cluster |
An admin force-destroyed the cluster. |
admin.force_evict_node |
kubernetes_cluster |
An admin force-evicted a node from the cluster. |
admin.suspend_toggle |
kubernetes_cluster |
An admin toggled the cluster’s suspended state. |
admin.reset_state |
kubernetes_cluster |
An admin reset the cluster’s lifecycle state. |
admin.cancel_task |
kubernetes_cluster |
An admin cancelled an in-flight cluster task. |
cert.renewed |
kubernetes_cluster |
A Kubernetes-managed load balancer certificate was renewed. |
instance.created |
instance |
An instance was created. |
instance.deployed |
instance |
An instance finished its first successful deployment. |
instance.status_changed |
instance |
An instance’s running state changed (e.g. running, stopped). |
instance.suspended |
instance |
An instance was suspended (by the user, an admin, or network policy). |
instance.unsuspended |
instance |
An instance was unsuspended. |
instance.reinstalled |
instance |
An instance finished a reinstall. |
instance.deleted |
instance |
An instance was deleted. |
You can subscribe to a subset of these kinds, or pass event_kinds: ["*"] to receive everything.
Accepted limitation: instance.deployed can recur
Section titled “Accepted limitation: instance.deployed can recur”Whether a deploy is reported as instance.deployed (first successful deployment) or instance.reinstalled (a later redeploy) is determined by looking back through this account’s recent event history for a prior deploy or reinstall event on that instance, because the instances table itself carries no deployed_at/first_deployed_at column. That history is only kept for 7 days (events already delivered are pruned after that window). If more than 7 days pass between deploy events on the same instance, the older evidence is gone and the next deploy is reported as instance.deployed again, not instance.reinstalled. This is a known, accepted limitation, not a bug: treat instance.deployed as “this instance finished deploying,” not as a guaranteed one-time-only signal.
cluster_filter scope: Kubernetes kinds only
Section titled “cluster_filter scope: Kubernetes kinds only”cluster_filter narrows delivery to specific Kubernetes clusters you own. It is only meaningful for the cluster.*, pool.*, admin.* and cert.renewed kinds above. Instance events are matched by account and event_kinds alone; a subscription’s cluster_filter is ignored entirely when the event being dispatched is an instance.* kind, so setting it never blocks or scopes instance deliveries.
The delivery request
Section titled “The delivery request”Each matching event produces one POST request per subscription, with a Content-Type: application/json body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Hypervisor-Delivery |
A UUID unique to this delivery attempt. |
X-Hypervisor-Event |
The event kind, e.g. instance.suspended. |
X-Hypervisor-Event-Id |
The id of the underlying event record. |
X-Hypervisor-Subscription |
The id of your subscription. |
X-Signature-256 |
sha256= followed by the hex-encoded HMAC-SHA256 of the raw request body, computed with your subscription’s secret. |
User-Agent |
Hypervisor-Webhook/1.0 |
TLS is verified on every delivery; there is no way to point a subscription at a plain-HTTP or self-signed endpoint.
Verifying the signature
Section titled “Verifying the signature”Compute the HMAC over the exact raw bytes of the body you received, before any JSON parsing, and compare it to the X-Signature-256 header using a constant-time comparison.
const crypto = require('crypto');
function isValidSignature(rawBody, signatureHeader, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(rawBody) // Buffer or string of the raw request body, unparsed .digest('hex');
const a = Buffer.from(expected); const b = Buffer.from(signatureHeader || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express example, reading the raw body before JSON parsing runs:// app.post('/hooks/hypervisor', express.raw({ type: 'application/json' }), (req, res) => {// if (!isValidSignature(req.body, req.header('X-Signature-256'), SECRET)) {// return res.status(401).end();// }// const event = JSON.parse(req.body);// // ...// });If you re-serialize the parsed JSON before hashing, whitespace or key-order differences will make the signature fail to verify even though the delivery is genuine, so always hash the raw bytes.
Payload shapes
Section titled “Payload shapes”The body is always {event: {...}, <resource>: {...}}. The event object’s shape differs slightly between the two resource families that currently produce events.
Instance events
Section titled “Instance events”{ "event": { "id": "9c1e4b2a-2f4d-4b8a-9e2b-9f0b7f2b8a1e", "kind": "instance.suspended", "created_at": "2026-09-25T08:03:41+00:00", "data": { "reason": "admin" } }, "instance": { "id": "a1beeb0e-4d3c-4a2b-9e1f-7c8b6a5d4e3f", "name": "h1000", "hostname": "web01.example.com", "display_name": "Production Web 01", "status": "running", "location": { "id": "5e2a1b3c-8f4d-4e6a-b1c2-3d4e5f6a7b8c", "name": "Toronto" } }}event.data varies by kind: instance.suspended/instance.unsuspended carry {reason: "user"|"admin"|"network"}; instance.status_changed carries {status: "running"|"stopped", previous: "running"|"stopped"}; the other instance kinds send an empty data object. instance.status in the resource object is always the string "running" or "stopped", never a raw database integer. name is the platform-assigned identifier (for example h1000) and never changes; display_name is the label the customer set and sees in the panel; hostname is the guest’s host name.
Kubernetes cluster events
Section titled “Kubernetes cluster events”{ "event": { "id": "5e8a3c1b-2d4f-4a6e-9b1c-8f7e6d5c4b3a", "kind": "cluster.created", "severity": "info", "message": "Cluster finished provisioning", "data": {}, "created_at": "2026-09-25T08:00:00+00:00" }, "cluster": { "id": "b7ef4a2c-1d3e-4f5a-8b6c-7d8e9f0a1b2c", "name": "prod-cluster", "slug": "prod-cluster" }}Retries, backoff and auto-disable
Section titled “Retries, backoff and auto-disable”A delivery is attempted up to 5 times in total. Each request uses a 5-second connect timeout and a 20-second response timeout, inside an overall 30-second timeout for the whole attempt. Anything other than a 2xx response, or a network error, counts as a failed attempt.
Failed attempts back off before retrying, waiting 10 seconds, then 60 seconds, then 5 minutes (300 seconds), then 15 minutes (900 seconds) before each subsequent attempt. If the 5th attempt also fails, the delivery is marked failed_permanent and is not retried again.
Every permanently failed delivery increments the subscription’s consecutive_failures counter. After 20 consecutive permanently-failed deliveries, the subscription is automatically disabled (enabled set to false). A single successful delivery resets the counter to zero, so an intermittently flaky endpoint does not accumulate failures across separate good deliveries.
To re-enable a disabled subscription, send:
PATCH /api/webhook-subscription/{id}{"enabled": true}This also resets consecutive_failures to 0, so the subscription gets a full 20-failure budget again before it can be auto-disabled a second time.
Instance events are enqueued for delivery within seconds of the underlying event: recording the event triggers delivery immediately, instead of waiting for the next poll. A background process still polls for any undispatched events once every minute, processing up to 200 events per resource type per run; it is a backstop for anything the immediate path missed (a worker being down, a lost enqueue), not the normal delivery path.
You can check delivery history for a subscription with GET /api/webhook-subscription/{id}/deliveries, which returns each attempt’s status (pending, pending_retry, delivered, failed_permanent or cancelled), HTTP response code, and duration.

