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

# Webhooks

> Receive real-time HTTP notifications for CVM lifecycle events

Phala Cloud sends HTTP POST requests to your endpoint when events occur in your workspace — CVM deployments, updates, stops, deletes, and multisig approval requests.

Each request includes an HMAC-SHA256 signature for verification.

## Supported Events

| Event                         | When                                           |
| ----------------------------- | ---------------------------------------------- |
| `cvm.created`                 | CVM first deployment completed, now running    |
| `cvm.started`                 | Existing CVM started (after stop)              |
| `cvm.create_failed`           | CVM deployment failed (boot error, timeout)    |
| `cvm.restarted`               | CVM restart completed                          |
| `cvm.updated`                 | CVM compose update completed, now running      |
| `cvm.stopped`                 | CVM stopped or shut down                       |
| `cvm.deleted`                 | CVM deleted                                    |
| `cvm.update.pending_approval` | On-chain KMS update requires multisig approval |

## Payload Format

```json theme={"system"}
{
  "id": "evt_a1b2c3d4e5f67890",
  "event": "cvm.created",
  "version": "1",
  "created_at": "2026-03-19T10:30:00Z",
  "workspace": {
    "id": "my-team-slug",
    "name": "My Team"
  },
  "data": {
    "cvm_id": "1a09d706-2686-4e0a-8b1d-323ff0e3504b",
    "cvm_name": "my-app",
    "app_id": "0xabc...",
    "status": "running"
  }
}
```

For failure events, `data` includes an `error` field:

```json theme={"system"}
{
  "data": {
    "cvm_id": "...",
    "cvm_name": "my-app",
    "app_id": "0xabc...",
    "status": "error",
    "error": "CVM 82: Failed to request app keys: ..."
  }
}
```

## HTTP Headers

| Header                | Example                  |
| --------------------- | ------------------------ |
| `Content-Type`        | `application/json`       |
| `User-Agent`          | `PhalaCloud-Webhook/1.0` |
| `X-Webhook-Id`        | `evt_a1b2c3d4e5f67890`   |
| `X-Webhook-Event`     | `cvm.created`            |
| `X-Webhook-Timestamp` | `1679012345`             |
| `X-Webhook-Signature` | `sha256=a1b2c3...`       |

## Verifying Signatures

Each webhook has a signing secret (starts with `whsec_`). Use it to verify requests are authentic and untampered.

The signature is: `sha256=HMAC-SHA256(secret, "{timestamp}.{raw_body}")`

### Using SDKs (Recommended)

The Phala Cloud SDKs provide built-in verification with timestamp tolerance checks and constant-time comparison.

<CodeGroup>
  ```typescript JavaScript / TypeScript theme={"system"}
  import { verifyWebhookSignature, parseWebhookEvent } from "@phala/cloud/webhook";

  // Option 1: Verify signature manually
  const isValid = verifyWebhookSignature({
    secret: "whsec_...",
    timestamp: req.headers["x-webhook-timestamp"],
    body: rawBody,
    signature: req.headers["x-webhook-signature"],
  });

  // Option 2: Parse and verify in one step (throws on failure)
  try {
    const event = parseWebhookEvent({
      headers: req.headers,
      body: rawBody,
      secret: "whsec_...",
    });
    console.log(event.event, event.data);
  } catch (err) {
    // Invalid signature or expired timestamp
    res.status(401).send("Invalid signature");
  }
  ```

  ```python Python theme={"system"}
  from phala_cloud import verify_webhook_signature, parse_webhook_event

  # Option 1: Verify signature manually
  is_valid = verify_webhook_signature(
      secret="whsec_...",
      timestamp=request.headers["X-Webhook-Timestamp"],
      body=request.body.decode(),
      signature=request.headers["X-Webhook-Signature"],
  )

  # Option 2: Parse and verify in one step (raises on failure)
  try:
      event = parse_webhook_event(
          headers=dict(request.headers),
          body=request.body.decode(),
          secret="whsec_...",
      )
      print(event.event, event.data)
  except ValueError:
      # Invalid signature or expired timestamp
      return Response(status_code=401)
  ```

  ```go Go theme={"system"}
  import phala "github.com/Phala-Network/phala-cloud-sdk-go"

  // Option 1: Verify signature
  ok := phala.VerifyWebhookSignature(secret, timestamp, string(body), signature)

  // Option 2: Parse and verify (returns error on failure)
  event, err := phala.ParseWebhookEvent(r.Header, body, secret)
  if err != nil {
      http.Error(w, "Invalid signature", http.StatusUnauthorized)
      return
  }
  fmt.Println(event.Event, string(event.Data))
  ```
</CodeGroup>

All three SDKs automatically reject timestamps older than 5 minutes (configurable via `toleranceSeconds`) and use constant-time comparison to prevent timing attacks.

<Accordion title="Manual Verification (Python / Node.js)">
  <CodeGroup>
    ```python Python theme={"system"}
    import hmac
    import hashlib

    def verify_webhook(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
        message = f"{timestamp}.{body.decode('utf-8')}".encode("utf-8")
        expected = "sha256=" + hmac.new(
            secret.encode("utf-8"), message, hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(expected, signature)
    ```

    ```javascript Node.js theme={"system"}
    const crypto = require("crypto");

    function verifyWebhook(secret, timestamp, body, signature) {
      const message = `${timestamp}.${body}`;
      const expected = "sha256=" +
        crypto.createHmac("sha256", secret).update(message).digest("hex");
      const expectedBuf = Buffer.from(expected);
      const actualBuf = Buffer.from(signature);
      if (expectedBuf.length !== actualBuf.length) return false;
      return crypto.timingSafeEqual(expectedBuf, actualBuf);
    }
    ```
  </CodeGroup>

  **Replay protection:** reject events where `X-Webhook-Timestamp` is older than 5 minutes.
</Accordion>

## Delivery Behavior

| Aspect             | Behavior                                          |
| ------------------ | ------------------------------------------------- |
| **Success**        | HTTP `2xx` (`200`, `201`, `202`, etc.)            |
| **Failure**        | Any non-`2xx` status, network error, or timeout   |
| **Redirects**      | Not followed (`3xx` = failure)                    |
| **Timeout**        | 10 seconds                                        |
| **Retries**        | Up to 3 retries: 1 min → 10 min → 1 hour          |
| **Total attempts** | 4 (1 initial + 3 retries)                         |
| **Recording**      | One delivery record per event (final result only) |

## Secret Management

Webhook signing secrets follow a strict security model:

* **Shown once at creation** — the full secret is returned only in the `POST /workspace/webhooks` response
* **Masked afterward** — subsequent `GET`/`LIST` responses return a masked value (e.g. `whsec_****...xxxx`)
* **Reveal requires 2FA** — to view the full secret again, your account must have two-factor authentication enabled and recently verified
* **Rotation requires 2FA** — generating a new secret also requires 2FA verification

### Reveal Secret

```
POST /api/v1/workspace/webhooks/{id}/reveal-secret
```

Returns the full signing secret. Requires:

* Account with 2FA enabled (returns `403` if not)
* Recent 2FA step-up verification (returns `428` if not verified)

### Rotate Secret

```
POST /api/v1/workspace/webhooks/{id}/rotate-secret
```

Generates a new signing secret and invalidates the previous one. Same 2FA requirements as reveal.

<Warning>
  After rotation, update your webhook consumer with the new secret immediately. Events signed with the old secret will fail verification.
</Warning>

## URL Requirements

Webhook URLs must meet the following requirements:

* **HTTPS only** — HTTP URLs are rejected
* **Public addresses only** — URLs resolving to private or reserved IP ranges are blocked:
  * `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` (private)
  * `169.254.0.0/16` including `169.254.169.254` (cloud metadata)
  * `::1`, `fc00::/7`, `fe80::/10` (IPv6 private/loopback)
* **No localhost** — `localhost` and `.local` domains are not allowed

These restrictions apply both at webhook creation and at delivery time to prevent DNS rebinding attacks.

## Managing Webhooks

### Dashboard

**Settings → Webhooks** in your workspace:

* Create, edit, enable/disable, delete webhooks
* Select subscribed events
* Send test events
* View delivery history with stats (success rate, response time)
* Resend failed deliveries

### API

```bash theme={"system"}
# Create
POST /api/v1/workspace/webhooks
{"url": "https://example.com/webhook", "events": ["cvm.created", "cvm.stopped"], "name": "My Hook"}

# List
GET /api/v1/workspace/webhooks

# Update
PUT /api/v1/workspace/webhooks/{id}
{"enabled": false}

# Delete
DELETE /api/v1/workspace/webhooks/{id}

# Test
POST /api/v1/workspace/webhooks/{id}/test

# Deliveries
GET /api/v1/workspace/webhooks/{id}/deliveries

# Stats
GET /api/v1/workspace/webhooks/{id}/stats

# Resend
POST /api/v1/workspace/webhooks/{id}/deliveries/{event_id}/resend
```

## Best Practices

* **Verify signatures** — always check `X-Webhook-Signature` before processing
* **Respond fast** — return `200` immediately, process asynchronously (10s timeout)
* **Be idempotent** — use the `id` field to deduplicate retries
* **Check timestamps** — reject events older than 5 minutes
