> ## Documentation Index
> Fetch the complete documentation index at: https://help.exy.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get a signed request when a server is ready, suspended, released or deleted, or when an order is refunded.

A webhook sends events of your account to a URL of yours, so your code does not have to poll. It belongs to the account, not to the token that created it: revoking the token does not stop the webhook.

## Create a webhook

`POST /v1/webhooks` with scope `vms.manage`. Subscribing to `order_refunded` also needs `billing.read`.

```bash theme={null}
curl -X POST https://api.exy.sh/v1/webhooks \
  -H "Authorization: Bearer $EXY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "billing system", "url": "https://hooks.example.com/exy",
       "events": ["vm_ready", "vm_suspended", "vm_unsuspended", "vm_terminated"]}'
```

| Field | Rules |
| - | - |
| `name` | 1 to 64 characters |
| `url` | `https` on port 443, at most 2048 characters, and its host must resolve only to public addresses (`400 NOTIFICATION_WEBHOOK_URL_NOT_ALLOWED` otherwise, `400 NOTIFICATION_WEBHOOK_URL_UNREACHABLE` when it does not resolve). It is checked again at every delivery, and redirects are never followed |
| `events` | 1 to 5 of the events below, each at most once |

The answer `201` holds the webhook and its `signingSecret`, 64 hex characters. **It is shown once, in this answer, and never again.** Store it now. The URL is stored encrypted and is not shown again either, only its `host`, so it may carry a credential of yours.

An account has at most 10 notification channels, the dashboard's Telegram and Discord channels included. One more is `400 NOTIFICATION_CHANNEL_LIMIT_REACHED`.

* `GET /v1/webhooks` (scope `vms.read`) lists the account's webhooks, including those set up in the dashboard under **Settings → Notifications**, with their `status`, `consecutiveFailures`, `lastSuccessAt`, `lastFailureAt` and `lastError`.
* `DELETE /v1/webhooks/{id}` (scope `vms.manage`) deletes one. It gets no further events, and deliveries already queued are dropped.

## Events

Every delivery is a `POST` with a JSON body:

```json theme={null}
{
  "version": 1,
  "id": "…",
  "occurredAt": "2026-11-01T12:00:00.000Z",
  "type": "vm_suspended",
  "data": {
    "vmId": 123,
    "hostname": "…",
    "reason": "expired",
    "deletionAt": "2026-11-08T12:00:00.000Z"
  }
}
```

`id` names the event: it is the same on every retry of it. `occurredAt` is when the change happened, not when it was sent. `vmId` is the server's id, as in `GET /v1/servers/{id}`.

| `type` | When | `data` |
| - | - | - |
| `vm_ready` | A new server is built and running, also after a renewal rebuilt it | `vmId`, `hostname` |
| `vm_suspended` | A server was suspended, for any reason | `vmId`, `hostname`, `reason`, `deletionAt` |
| `vm_unsuspended` | A suspended server was released: its renewal was paid, a clean reinstall ended an abuse suspension, or our team lifted the suspension. It starts again shortly after | `vmId`, `hostname` |
| `vm_terminated` | A server and its data are gone | `vmId`, `hostname`, `reason` |
| `order_refunded` | An order's payment was refunded, to the balance or outside the platform | `orderId`, `vmId` (null if the order had no server), `amountCents`, `currency` |

`reason` of `vm_suspended`:

* `expired`: the term ended unpaid. `deletionAt` says when the server is deleted with its data unless its term is renewed first.
* `abuse`: the server was found abusing.
* `admin`: suspended by our team.
* `system`: a paid server could not be started again; our team takes over.

For every reason except `expired`, `deletionAt` is `null`.

`reason` of `vm_terminated`: `expired` (the term ended unpaid), `deleted` (by you, in the dashboard or with `DELETE /v1/servers/{id}`) or `admin` (by our team).

## Headers

| Header | Value |
| - | - |
| `X-Exy-Event` | The event type |
| `X-Exy-Delivery` | The event `id` |
| `X-Exy-Timestamp` | Unix seconds of this delivery attempt |
| `X-Exy-Signature` | `v1=` followed by the hex HMAC-SHA256 of `<X-Exy-Timestamp>.<raw body>`, keyed with the signing secret |

## Verify the signature

Check every request before you trust it:

1. Compute the HMAC-SHA256 of the timestamp header, a dot, and the **raw** request body, exactly as received. Use the signing secret string as the key. Compare `v1=<hex digest>` with `X-Exy-Signature` in constant time.
2. Reject the request when the timestamp is more than 300 seconds from your clock: that is a replay of an old request.

Parse the JSON only after the check. Parsing and serialising it again changes the bytes and breaks the signature.

```js Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto"
import { createServer } from "node:http"

const MAX_AGE_SECONDS = 300

export function verifyExyWebhook(rawBody, headers, secret) {
  const timestamp = headers["x-exy-timestamp"]
  const signature = headers["x-exy-signature"]
  if (typeof timestamp !== "string" || typeof signature !== "string") return false

  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false

  const expected =
    "v1=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")
  const a = Buffer.from(signature)
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}

createServer((req, res) => {
  const chunks = []
  req.on("data", (chunk) => chunks.push(chunk))
  req.on("end", async () => {
    const rawBody = Buffer.concat(chunks)
    if (!verifyExyWebhook(rawBody, req.headers, process.env.EXY_WEBHOOK_SECRET)) {
      res.writeHead(401).end()
      return
    }
    try {
      const event = JSON.parse(rawBody.toString("utf8"))
      // Your own durable store or queue; skip the event if you have seen event.id before.
      await saveEvent(event)
      res.writeHead(204).end()
    } catch {
      res.writeHead(500).end() // Not stored, so we send it again.
    }
  })
}).listen(8080)
```

```php PHP theme={null}
<?php
function exy_verify_webhook(string $rawBody, ?string $timestamp, ?string $signature, string $secret): bool
{
    if ($timestamp === null || $signature === null || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input');
$valid = exy_verify_webhook(
    $rawBody,
    $_SERVER['HTTP_X_EXY_TIMESTAMP'] ?? null,
    $_SERVER['HTTP_X_EXY_SIGNATURE'] ?? null,
    getenv('EXY_WEBHOOK_SECRET')
);
if (!$valid) {
    http_response_code(401);
    exit;
}
$event = json_decode($rawBody, true);
// Your own durable store or queue; skip the event if you have seen $event['id'] before.
if (!save_event($event)) {
    http_response_code(500); // Not stored, so we send it again.
    exit;
}
http_response_code(204);
```

Your endpoint has to be reachable over `https` on port 443. The sample servers listen on plain HTTP, so put them behind your TLS proxy.

## Answer fast, then work

Store or queue the event first, answer with any `2xx` within 10 seconds, then do the work. A `2xx` counts as delivered and the event is not sent again, so answer only once the event is safe. A slow handler times out and the event is sent again.

## Retries

| Your answer | What we do |
| - | - |
| `2xx` | Delivered |
| Network error, timeout, `408`, `429` or `5xx` | Retried 5 times within 15.5 minutes: after 30 seconds, then 1, 2, 4 and 8 minutes. Each attempt is signed anew with a fresh `X-Exy-Timestamp` |
| A redirect or any other `4xx` | Not retried |

When 5 deliveries in a row have failed for good, after all their retries, the webhook is switched off: its `status` becomes `failed` and the account owner gets an email. A successful delivery resets the count. Resume a `failed` webhook in the dashboard, or delete it and create it again. A webhook paused in the dashboard is `paused`; neither state gets events.

Because of retries:

* **A delivery can arrive twice.** Deduplicate on the event `id` (also in `X-Exy-Delivery`).
* **A retried delivery can arrive after a newer event.** Order events by `occurredAt`, not by arrival.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.