Webhooks
Register HTTPS receivers, verify raw-body signatures and handle retries.
Registration and delivery
Webhook management uses dedicated reseller keys (rk_…), not account sk_
keys or Clerk sessions. GET /api/reseller/v1/webhooks requires
reseller.vms.read. POST /api/reseller/v1/webhooks and
DELETE /api/reseller/v1/webhooks/{id} require reseller.vms.write.
The dashboard exposes the equivalent team-owner/admin session routes at
/api/teams/{teamId}/reseller/webhooks, plus
POST /api/teams/{teamId}/reseller/webhooks/{id}/test to queue ping.
Create with { "url": "https://example.com/webhooks", "events": ["vm.active"] }.
The endpoint must resolve exclusively to public IP addresses, use HTTPS, and
contain no credentials or fragment. Validation runs on save and delivery;
delivery pins a validated address and never follows redirects. Maximum five
webhooks per account. Mutation requests share a 10-per-minute account limit
(429 with Retry-After). Invalid input returns 400, cap exhaustion 409, and IDs
outside the authenticated account return 404. Authentication failures are
401/403. Creation returns 201 and a secret once; store it securely. The
secret is encrypted at rest with encryption. Lists never
include plaintext or encrypted secrets, or delivery payloads. Lists include
deliveries (zero or one most recent delivery), with status, attempts and HTTP
status. DELETE returns { "deleted": true } and removes delivery history.
The team test route returns 202 with the queued delivery ID.
Events: vm.created, vm.active, vm.failed, vm.suspended,
vm.unsuspended, vm.deleted, vm.rebuilt, vm.resized,
vm.rescue_enabled, vm.rescue_disabled, and account.deletion_scheduled
(sent when an overdue account is scheduled for deletion).
Created means the VM row was reserved; active/rebuilt mean the provider accepted
the build/rebuild, not that the guest has finished booting. Replayed idempotent
creates and no-op state changes do not emit again.
Delivery bodies are JSON:
{
"id": "delivery-uuid",
"event": "vm.active",
"createdAt": "2026-10-21T20:00:00.000Z",
"data": { "id": "vm-uuid", "name": "example", "status": "active" }
}
data has the same complete VM shape as the API serializer (including
endUserRef, resource sizes, timestamps, ips, and power); live IP/power
fields are empty/null when unavailable. ping uses data: {}.
Headers: X-Expanse-Event, X-Expanse-Delivery, X-Expanse-Timestamp
(Unix seconds), and X-Expanse-Signature (sha256=<hex>). The signature is
HMAC-SHA256 with the exact secret string as key and timestamp + "." + rawBody
as the message. Never re-encode parsed JSON before verification. Verify before
processing, reject stale timestamps, and deduplicate by delivery ID. Timestamp
and signature refresh on each attempt, but delivery ID and body stay stable.
Node.js verification (the framework must provide the unparsed request body):
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers["x-expanse-timestamp"] ?? "";
const signature = headers["x-expanse-signature"] ?? "";
if (!/^\d+$/.test(timestamp) ||
Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
!/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const expected = "sha256=" + createHmac("sha256", secret)
.update(timestamp + ".").update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
PHP verification:
<?php
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_EXPANSE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_EXPANSE_SIGNATURE'] ?? '';
$secret = getenv('RESELLER_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300 ||
!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
// Deduplicate by delivery ID, then enqueue work and return a 2xx response.
http_response_code(204);
Only 2xx acknowledges delivery. Requests time out after 10 seconds. Other statuses, redirects, unsafe DNS changes and network errors retry after approximately 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, then 24 hours. The seventh failed attempt is terminal failed. Disabled accounts/endpoints are not sent. Delivery is at least once:
a crash after receiver acceptance but before database commit can cause a retry.
Consumers must use delivery IDs for idempotency. VM API calls tolerate queue
failures, so a database queue failure can omit an event rather than failing the
VM operation.
Management request and response examples
DELETE /webhooks/{id}
Scope: reseller.vms.write.
curl -sS -X DELETE "$BASE/webhooks/cmvmexample001" -H "Authorization: Bearer $RESELLER_KEY"
HTTP 200:
{
"data": {
"deleted": true
}
}
GET /webhooks
Scope: reseller.vms.read.
curl -sS "$BASE/webhooks" -H "Authorization: Bearer $RESELLER_KEY"
HTTP 200:
{
"data": [
{
"id": "cmvmexample001",
"url": "https://hooks.example.com/corepanel",
"events": [
"vm.active"
],
"enabled": false,
"createdAt": "2026-10-24T10:00:00.000Z",
"deliveries": [
{
"id": "cmvmexample001",
"event": "example",
"status": "pending",
"attempts": 1,
"lastStatusCode": 1,
"createdAt": "2026-10-24T10:00:00.000Z",
"deliveredAt": "2026-10-24T10:00:00.000Z"
}
]
}
]
}
POST /webhooks
Scope: reseller.vms.write.
curl -sS -X POST "$BASE/webhooks" -H "Authorization: Bearer $RESELLER_KEY" -H 'Content-Type: application/json' -d '{"url":"https://hooks.example.com/corepanel","events":["vm.active"]}'
HTTP 201:
{
"data": {
"id": "cmvmexample001",
"url": "https://hooks.example.com/corepanel",
"events": [
"vm.active"
],
"enabled": false,
"createdAt": "2026-10-24T10:00:00.000Z",
"deliveries": [
{
"id": "cmvmexample001",
"event": "example",
"status": "pending",
"attempts": 1,
"lastStatusCode": 1,
"createdAt": "2026-10-24T10:00:00.000Z",
"deliveredAt": "2026-10-24T10:00:00.000Z"
}
],
"secret": "example-hook-secret"
}
}