# Webhooks

> Receive signed HTTPS events when machine lifecycle operations finish, verify their signatures, and handle retries.

Webhooks tell your service when machines become ready, stop, start, are deleted, or fail. VMYard sends a signed JSON `POST` to each HTTPS endpoint you register, and retries until the endpoint answers with a `2xx` status.

## Events

Events come from the final, observed result of an operation, never from anything a machine reports about itself.

| Event | Sent when |
| --- | --- |
| `machine.ready` | A create succeeded: the machine was observed running. This includes a live [fork](/docs/guides/fork-and-restore) and a woken restore. |
| `machine.started` | A start or restart succeeded. |
| `machine.stopped` | A stop succeeded, including auto-stops, [idle sleep](/docs/concepts/idle-sleep), and stops because the balance ran out, or a fork or restore created its machine stopped. |
| `machine.deleted` | A delete succeeded: the machine was confirmed gone. |
| `machine.error` | A create, start, stop, restart, or delete ended `failed` or `correlation_miss`. |
| `operation.failed` | Any public operation (lifecycle, command, background command, kill, file write, port or SSH change, [internet access](/docs/guides/networking) change, environment install, snapshot push, template save) ended `failed` or `correlation_miss`. |
| `snapshot.ready` | A [snapshot](/docs/guides/snapshots) push succeeded. `data.snapshot` is `{id, template_name, tier, status}`. |
| `template.ready` | A [template](/docs/guides/templates) save succeeded. `data.snapshot` names the template. |

An `uncertain` operation sends nothing until it settles. Successful command, file, port, SSH, and internet access operations send nothing. A restart never stops its machine, so it sends `machine.started` and never `machine.stopped`.

```json title="machine.ready"
{
  "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "type": "machine.ready",
  "created_at": "2026-10-12T05:51:01.738927Z",
  "organization_id": "2a7d4c1e-8b3f-4f6a-9c5d-0e1f2a3b4c5d",
  "data": {
    "machine": { "id": "0b6f6c55-2f1d-4f0e-9a51-6f0f4cba7a10", "name": "dev", "network": { "egress": "internet", "ipv6_address": null } },
    "operation": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "kind": "create", "status": "succeeded", "error_code": null },
    "state": { "desired": "running", "observed": "running" }
  }
}
```

`data.machine.name` is the machine's display name, and `data.machine.network` its [egress policy and IPv6 address](/docs/guides/networking#read-it) as its host last reported them, or `null` before any report. Events of a stop operation also carry `data.operation.stop_reason`: `api` (a caller asked for it), `auto_stop`, `billing`, or `idle_sleep`. Other events don't have the field.

## Register an endpoint

Only Owners and Admins can see or change webhooks, because endpoint URLs can contain secrets. API keys need the `full_access` preset, and their creator must still be an Owner or Admin. You can also manage endpoints in the console under **Webhooks**.

```sh tab="CLI"
vy webhooks add --name ci --url https://hooks.example.com/vmyard --event machine.ready --event machine.error
vy webhooks ls
```

```ts tab="TypeScript"
const { webhook, secret } = await vy.webhooks.create({
  name: "ci",
  url: "https://hooks.example.com/vmyard",
  events: ["machine.ready", "machine.error"],
});
```

```python tab="Python"
created = vy.webhooks.create(
    name="ci", url="https://hooks.example.com/vmyard", events=["machine.ready", "machine.error"]
)
secret = created["secret"]
```

```sh tab="curl"
curl -X POST "$VMYARD_API_URL/v1/webhooks" \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "ci", "url": "https://hooks.example.com/vmyard", "events": ["machine.ready", "machine.error"]}'
```

The answer includes the signing secret (`whsec_…`). It is shown **only now and when you rotate it**; store it with your receiver.

- `name`: 1–64 characters.
- `url`: `https`, port 443, at most 2048 bytes, no username, password, or fragment, and a public host. Private, loopback, link-local, and other non-public addresses and names such as `localhost` or `*.internal` are refused.
- `events`: one or more of the events above, without duplicates. The CLI subscribes to all eight when you give no `--event`.
- `enabled`: `true` by default.

An organization can have up to **10** endpoints (`409 webhook_limit_reached`).

## Verify signatures

Every delivery carries these headers:

| Header | Value |
| --- | --- |
| `X-VMYard-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `X-VMYard-Event-Id` | The event `id`. The same for every endpoint and every attempt. |
| `X-VMYard-Event-Type` | The event type. |
| `X-VMYard-Delivery-Attempt` | 1 for the first attempt, then 2, 3, … |
| `User-Agent` | `VMYard-Webhooks/1` |

`v1` is the HMAC-SHA256 of `t + "." + body`, keyed with the **whole** secret string including `whsec_`. Each attempt is signed again with a fresh timestamp. To verify:

1. Read the raw request body before parsing it.
2. Recompute the HMAC and compare it in constant time.
3. Reject timestamps more than 5 minutes old.
4. Skip any `X-VMYard-Event-Id` you have already processed.

```js tab="Node.js"
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body as received (Buffer), before JSON parsing.
export function verifyVmyard(secret, rawBody, header, tolerance = 300) {
  const fields = Object.fromEntries(
    header.split(",").map((part) => {
      const at = part.indexOf("=");
      return [part.slice(0, at), part.slice(at + 1)];
    }),
  );
  const timestamp = Number(fields.t);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
  const expected = createHmac("sha256", secret)
    .update(`${fields.t}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(fields.v1 ?? "", "hex");
  return (
    received.length === expected.length &&
    timingSafeEqual(received, expected)
  );
}
```

```python tab="Python"
import hashlib, hmac, time

def verify_vmyard(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    fields = dict(part.split("=", 1) for part in header.split(","))
    timestamp = int(fields["t"])
    if abs(time.time() - timestamp) > tolerance:
        return False  # stale or replayed
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, fields.get("v1", ""))
```

## Retries and ordering

A `2xx` answer delivers the event. Anything else is a failed attempt: other statuses, redirects (never followed), no answer within 10 seconds, a connection failure, or a destination that resolves to a non-public address. VMYard retries after 1, 2, 4, … minutes, up to 6 hours apart; after 12 attempts (about 20.5 hours) the delivery is marked `dead`.

Delivery is **at least once** and **unordered**: the same event can arrive twice, and events can arrive out of order. Deduplicate on `X-VMYard-Event-Id`, and order by `created_at` or by reading the machine's current state rather than by arrival.

## Deliveries

```sh tab="CLI"
vy webhooks deliveries ci --limit 20
```

```ts tab="TypeScript"
const deliveries = await vy.webhooks.deliveries(webhook.id, { limit: 20 });
```

```python tab="Python"
deliveries = vy.webhooks.deliveries(webhook_id, limit=20)
```

```sh tab="curl"
curl "$VMYARD_API_URL/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20" \
  -H "Authorization: Bearer $VMYARD_API_KEY"
```

Deliveries are listed newest first with `status` (`pending`, `delivering`, `succeeded`, or `dead`), `attempt_count`, `next_attempt_at`, `last_status_code`, and `last_error`.

## Pause, change, rotate, delete

- **Disable** an endpoint (`PATCH` with `{"enabled": false}`) to pause it. Queued deliveries wait until you enable it again; no new events are queued while it is disabled.
- **Change** its `name`, `url`, or `events` with `PATCH`; omitted fields keep their value.
- **Rotate** the secret (`POST /v1/webhooks/{id}/rotate-secret`, or `vy webhooks rotate`). The old secret stops signing at once, including retries of queued events, so update your receiver promptly.
- **Delete** the endpoint (`DELETE`, or `vy webhooks rm`). Its queued and past deliveries are deleted with it.
