# Webhooks

> What LevelFour posts to your endpoint when a recommendation or an optimization changes state, and how to verify the signature before you act on it.

Source: https://docs.levelfour.ai/webhooks


LevelFour delivers webhooks through [Svix](https://svix.com). When events occur in your account, LevelFour sends HTTPS POST requests to your registered endpoints with a signed payload.

The snippets below use the LevelFour SDK clients. [SDKs](/sdks) covers installing one and building `client`.

## Register an endpoint

**Python**

```python
endpoint = client.webhooks.register(
    url="https://your-app.com/webhooks/levelfour",
    event_types=["recommendation.accepted", "optimization.completed"],
    description="Production webhook",
)
```

**TypeScript**

```typescript
const endpoint = await client.webhooks.register({
    url: "https://your-app.com/webhooks/levelfour",
    event_types: ["recommendation.accepted", "optimization.completed"],
});
```

**Go**

```go
endpoint, err := client.Webhooks.Register(ctx, &levelfour.RegisterEndpointRequest{
    URL:        "https://your-app.com/webhooks/levelfour",
    EventTypes: []string{"recommendation.accepted", "optimization.completed"},
})
```

The call takes:

| Parameter     | Type       | Required | Description                |
| ------------- | ---------- | -------- | -------------------------- |
| `url`         | `string`   | Yes      | HTTPS endpoint URL         |
| `event_types` | `string[]` | Yes      | Events to subscribe to     |
| `description` | `string`   | No       | Human-readable description |

`url` must be HTTPS. [Webhooks in the API reference](/api-reference/webhooks) has the full request and response schema.

## Manage endpoints

### List endpoints

**Python**

```python
endpoints = client.webhooks.list()
```

**TypeScript**

```typescript
const endpoints = await client.webhooks.list();
```

**Go**

```go
endpoints, err := client.Webhooks.List(ctx)
```

### Delete an endpoint

**Python**

```python
client.webhooks.delete("ep_123")
```

**TypeScript**

```typescript
await client.webhooks.delete({ endpoint_id: "ep_123" });
```

**Go**

```go
_, err := client.Webhooks.Delete(ctx, "ep_123")
```

## Events

| Event                                      | Description                                                 |
| ------------------------------------------ | ----------------------------------------------------------- |
| `recommendation.terms_accepted`            | Someone accepted the terms for applying a recommendation    |
| `recommendation.accepted`                  | A savings recommendation was accepted                       |
| `recommendation.rejected`                  | A savings recommendation was rejected                       |
| `recommendation.rejection_reason_added`    | A reason was added to a rejected recommendation             |
| `recommendation.implementation_method_set` | Someone chose how a recommendation is delivered             |
| `execution.approval_requested`             | A member submitted a recommendation for an admin to release |
| `optimization.started`                     | An automated optimization has started processing            |
| `optimization.completed`                   | An automated optimization finished successfully             |
| `optimization.failed`                      | An automated optimization failed                            |
| `virtual_tag.changed`                      | A virtual tag was created, changed or deleted               |

## Payloads

### recommendation.accepted / recommendation.rejected

```json
{
    "recommendation_id": "REC-1234",
    "saving_acceptance": "accepted",
    "saving_accepted_by": "user@example.com",
    "saving_accepted_at": "2025-10-15T10:00:00",
    "rejection_reason": null,
    "rejection_explanation": null,
    "status": "optimized"
}
```

A rejected event carries `rejection_reason` and `rejection_explanation`, and its `status` is `rejected`.

### recommendation.terms\_accepted

```json
{
    "recommendation_id": "REC-1234",
    "terms_accepted_by": "user@example.com",
    "terms_accepted_at": "2025-10-15T10:00:00Z"
}
```

### recommendation.rejection\_reason\_added

```json
{
    "recommendation_id": "REC-1234",
    "saving_acceptance": "rejected",
    "rejection_reason": "not_applicable",
    "rejection_explanation": "This volume backs a disaster recovery test."
}
```

`rejection_reason` is one of `operational`, `strategy`, `not_applicable` or `other`.

### recommendation.implementation\_method\_set

```json
{
    "recommendation_id": "REC-1234",
    "implementation_method": "iac",
    "status": "pending"
}
```

### execution.approval\_requested

```json
{
    "recommendation_id": "REC-1234",
    "execution_request_status": "pending_approval",
    "execution_request_by": "user@example.com"
}
```

Subscribe an endpoint your approvers watch to this event. Without it, a request waits in the dashboard until an admin opens it.

### optimization.started

```json
{
    "recommendation_id": "REC-1234",
    "status": "processing",
    "implementation_method": "one-click",
    "completed_at": "2025-10-15T10:05:00Z",
    "message": "Optimization started"
}
```

The `implementation_method` field can be: `one-click`, `iac`, `one-click-plus-iac`, or `manual`. [`l4 rec execute`](/cli/commands/recommendations/execute) has a table of what each one does.

### optimization.completed

```json
{
    "recommendation_id": "REC-1234",
    "status": "optimized",
    "implementation_method": "one-click"
}
```

### optimization.failed

```json
{
    "recommendation_id": "REC-1234",
    "status": "failed",
    "implementation_method": "one-click"
}
```

### virtual\_tag.changed

```json
{
    "key_id": "vtk_8f3a1c",
    "name": "Teams",
    "action": "updated",
    "version": 7,
    "actor": "ana@example.com"
}
```

## Verify the signature

> **Warning**
>
> Always verify webhook signatures before processing events. Your endpoint is a public URL, so a handler that skips this acts on whatever reaches it.

Every webhook request carries these headers for HMAC-SHA256 signature verification:

| Header              | Description                           |
| ------------------- | ------------------------------------- |
| `webhook-id`        | Unique message ID (for deduplication) |
| `webhook-timestamp` | Unix timestamp in seconds             |
| `webhook-signature` | `v1,<base64-encoded-signature>`       |

> **Note**
>
> Legacy Svix headers (`svix-id`, `svix-timestamp`, `svix-signature`) are also supported.

**Python**

```python
from levelfour.webhooks.verifier import WebhookVerifier, WebhookVerificationError

verifier = WebhookVerifier("whsec_your_signing_secret")

try:
    payload = verifier.verify(
        payload=request_body,
        headers={
            "webhook-id": headers["webhook-id"],
            "webhook-timestamp": headers["webhook-timestamp"],
            "webhook-signature": headers["webhook-signature"],
        },
    )
    handle_event(payload)
except WebhookVerificationError:
    return Response(status_code=400)
```

**TypeScript**

```typescript
import { WebhookVerifier, WebhookVerificationError } from "levelfour";

const verifier = new WebhookVerifier("whsec_your_signing_secret");

try {
    const payload = verifier.verify(requestBody, {
        "webhook-id": headers["webhook-id"],
        "webhook-timestamp": headers["webhook-timestamp"],
        "webhook-signature": headers["webhook-signature"],
    });
    handleEvent(payload);
} catch (err) {
    if (err instanceof WebhookVerificationError) {
        res.status(400).json({ error: "Invalid signature" });
    }
}
```

**Go**

```go
import (
    "io"
    "net/http"

    "github.com/LevelFourAI/levelfour-go/levelfour/webhooks"
)

verifier, err := webhooks.NewVerifier("whsec_your_signing_secret")
if err != nil {
    log.Fatal(err)
}

http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }

    payload, err := verifier.Verify(r.Header, body)
    if err != nil {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    handleEvent(payload)
    w.WriteHeader(http.StatusOK)
})
```

> **Tip**
>
> Keep the signing secret in an environment variable or a secrets manager, never in a file you commit.

### The algorithm

Each signature is an HMAC over the message id, the timestamp and the body, joined by dots:

```text
base64(HMAC-SHA256(base64_decode(secret), "{webhook-id}.{webhook-timestamp}.{body}"))
```

> **Note**
>
> Every signing secret starts with `whsec_`. The SDK verifiers strip this prefix before they base64-decode the secret bytes, so pass the value exactly as it was issued.

### Timestamp tolerance

By default, the verifiers reject messages with timestamps more than **5 minutes** from the current time. This prevents replay attacks. You can customize the tolerance:

**Python**

```python
payload = verifier.verify(body, headers, tolerance_seconds=600)
```

**TypeScript**

```typescript
const payload = verifier.verify(body, headers, { toleranceSeconds: 600 });
```

**Go**

```go
payload, err := verifier.VerifyWithTolerance(r.Header, body, 10*time.Minute)
```

A wider tolerance widens the window in which a captured request can be replayed.

## Retries

Svix retries failed deliveries with exponential backoff. A delivery fails if your endpoint:

* Returns a non-2xx HTTP status code
* Does not respond within the timeout
* Is unreachable

> **Note**
>
> A `2xx` is what tells LevelFour the delivery worked. Return it as soon as the signature checks out and the event is stored, then do the heavy processing asynchronously.

> **Warning**
>
> A retry replays the same event, so one `webhook-id` can reach you more than once. Implement idempotent handlers using the `webhook-id` header for deduplication.

## Next

* [SDKs](/sdks) has the Python, TypeScript and Go clients and their constructor options
* [Webhooks in the API reference](/api-reference/webhooks) has the endpoint schemas and status codes
* [Slack integration](/guides/slack-integration) builds a receiver that verifies signatures and posts to a channel, in Python and TypeScript
* [Google Chat integration](/guides/google-chat-integration) does the same for a Google Chat space
