Skip to content

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.

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.

GET /api/webhook-event-kinds

Returns 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.

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.

Terminal window
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
}
}
GET /api/webhook-subscriptions
GET /api/webhook-subscription/{id}
GET /api/webhook-subscription/{id}/deliveries
PATCH /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:

Terminal window
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.

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.

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.

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.

The body is always {event: {...}, <resource>: {...}}. The event object’s shape differs slightly between the two resource families that currently produce 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.

{
"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"
}
}

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.