Expanse API
Sign up
Reseller API

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:

Example
{
  "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):

Example
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:

Example
<?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.

Example
curl -sS -X DELETE "$BASE/webhooks/cmvmexample001" -H "Authorization: Bearer $RESELLER_KEY"

HTTP 200:

Example
{
  "data": {
    "deleted": true
  }
}

GET /webhooks

Scope: reseller.vms.read.

Example
curl -sS "$BASE/webhooks" -H "Authorization: Bearer $RESELLER_KEY"

HTTP 200:

Example
{
  "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.

Example
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:

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