# Receive alerts with a webhook

> Send Budgets alerts to your own HTTPS endpoint as signed JSON, verify each request, and handle retries.

This guide sends a budget's alerts to an HTTPS endpoint you run, such as a chat integration, or an
on-call system, and shows how to check that each request really comes from Budgets.

## Before you begin

* An HTTPS URL reachable from the internet that accepts `POST` requests. Budgets does not follow
  redirects.
* A budget to send alerts from. See [Create a budget](/budgets/guides/create-a-budget).

## 1. Create the webhook channel

<CodeBlockTabs defaultValue="CLI">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="CLI">
      CLI
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="API">
      API
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="CLI">
    ```sh
    openstack budget channel create ops --type webhook --url https://hooks.example.com/budget-alerts
    ```
  </CodeBlockTab>

  <CodeBlockTab value="API">
    ```sh
    curl --request POST https://budget.public.global.vexxhost.net/v1/notification_channels \
      --header "X-Auth-Token: $TOKEN" \
      --header "Content-Type: application/json" \
      --data '{"name": "ops", "type": "webhook", "url": "https://hooks.example.com/budget-alerts"}'
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```
+------------+---------------------------------------------------+
| Field      | Value                                             |
+------------+---------------------------------------------------+
| id         | 8b2d4f6a-1c3e-4a5b-9d7f-2e4c6a8b1d3f              |
| name       | ops                                               |
| project_id | a6f1c2d93b8e4f07b5d4e2c1a9f8e7d6                  |
| type       | webhook                                           |
| status     | confirmed                                         |
| url        | https://hooks.example.com/budget-alerts           |
| address    | None                                              |
| secret     | whsec_Q2l4b0VyQ2pVd1Rsb3hNcUpwWGZ6Y1RnS3ZOYkhhRWQ |
| created_by | 4c8e2f1a9b7d4e6f8a3c5b1d7e9f2a4c                  |
| created_at | 2026-10-07T14:05:43Z                              |
+------------+---------------------------------------------------+
```

Store the `secret` now. Budgets shows it only when you create the channel or rotate its secret,
and your endpoint needs it to verify requests.

## 2. Send a budget's alerts to it

Add the channel to the thresholds that should use it. On the CLI, `--threshold` replaces all of a
budget's thresholds, so list every one you want to keep:

```sh
openstack budget set compute-monthly \
  --threshold name=warn,value=80,channel=finance,channel=ops \
  --threshold name=overrun,value=100,basis=forecasted,channel=finance,channel=ops
```

## 3. Handle the request

Each alert arrives as a `POST` with a JSON body:

```http
POST /budget-alerts HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: vexxhost-budget
X-Budget-Signature: t=1792854007,v1=4b7e0f2c9a1d6e3b8f5c2a7d4e1b9c6f3a8d5e2b7c4f1a9e6d3b0c8f5a2e7d4b
```

```json
{
  "id": "5e9a3c1f-7b2d-4f6e-9c8a-1d3b5f7e9a2c",
  "type": "budget.threshold.alarm",
  "text": "Budget \"compute-monthly\": spend 812.40 has reached 800.00 USD (threshold \"warn\")",
  "budget_id": "c7e1a5b3-9d2f-4e8c-a6b4-1f3d5e7c9a2b",
  "budget_name": "compute-monthly",
  "project_id": "a6f1c2d93b8e4f07b5d4e2c1a9f8e7d6",
  "threshold": {
    "name": "warn",
    "basis": "actual",
    "comparison": "greater_than_or_equal",
    "message": "Ask in #infra before adding servers."
  },
  "time_period": {"start": "2026-10-01T00:00:00Z", "end": "2026-11-01T00:00:00Z"},
  "currency": "USD",
  "amount": "1000",
  "target": "800",
  "observed": "812.395",
  "as_of": "2026-10-24T14:00:00Z"
}
```

Budgets sends the body as compact JSON. It is formatted here for reading.

| Field         | Meaning                                                                           |
| ------------- | --------------------------------------------------------------------------------- |
| `id`          | The alert's ID. It stays the same on every delivery attempt.                      |
| `type`        | Always `budget.threshold.alarm`.                                                  |
| `text`        | A one-line summary, ready to post to chat.                                        |
| `threshold`   | The threshold that crossed, with its optional `message`.                          |
| `time_period` | The budget window the alert is about.                                             |
| `amount`      | The budget's amount for the window.                                               |
| `target`      | The threshold's level as an amount.                                               |
| `observed`    | The spending, or for a forecasted threshold the forecast, that crossed the level. |
| `as_of`       | When the spending was measured.                                                   |

Answer with any `2xx` status within 10 seconds. Budgets retries any other answer, or no answer,
with increasing delays for about four hours. Because a retry can repeat an alert you already
handled, ignore an `id` you have seen before.

## 4. Verify the signature

Check every request before you act on it. `X-Budget-Signature` holds a Unix timestamp `t` and `v1`,
the hex-encoded HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with the
channel's secret. Reject the request if the signature does not match, or if `t` is more than five
minutes from your clock. Compute the signature over the body exactly as it arrived, before you
parse it.

<CodeBlockTabs defaultValue="Python">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Go">
      Go
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Python">
    ```python
    import hashlib
    import hmac
    import time

    def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
        parts = dict(item.split("=", 1) for item in header.split(","))
        timestamp, signature = parts.get("t", ""), parts.get("v1", "")
        if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
            return False
        expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, signature)
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    import { createHmac, timingSafeEqual } from "node:crypto";

    export function verify(secret, header, body, tolerance = 300) {
      const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
      const timestamp = Number(parts.t);
      if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
      const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(body).digest("hex");
      const given = Buffer.from(parts.v1 ?? "", "utf8");
      return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected, "utf8"));
    }
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Go">
    ```go
    func verify(secret, header string, body []byte, tolerance time.Duration) bool {
    	var timestamp, signature string
    	for _, part := range strings.Split(header, ",") {
    		key, value, _ := strings.Cut(part, "=")
    		switch key {
    		case "t":
    			timestamp = value
    		case "v1":
    			signature = value
    		}
    	}
    	t, err := strconv.ParseInt(timestamp, 10, 64)
    	if err != nil || time.Since(time.Unix(t, 0)).Abs() > tolerance {
    		return false
    	}
    	mac := hmac.New(sha256.New, []byte(secret))
    	mac.Write([]byte(timestamp + "."))
    	mac.Write(body)
    	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature))
    }
    ```
  </CodeBlockTab>
</CodeBlockTabs>

## Verify

Ask Budgets which alerts it sent and whether your endpoint accepted them:

```sh
openstack budget notification list compute-monthly
```

Each alert is `pending` while Budgets is still trying, `sent` once your endpoint answered with a
`2xx` status, or `failed` with the last error once it stops trying.

## Rotate the secret

Replace the secret if it might have leaked:

```sh
openstack budget channel secret rotate ops
```

The new secret replaces the old one immediately, so update your endpoint at the same time.

## Next steps

* Read how [notification channels](/budgets/concepts/notification-channels) deliver alerts.
* Look up the channel operations in the [API reference](/budgets/api).
