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

# Webhooks

> The alert payload CostGraph posts to your endpoint, and how to verify its signature

A webhook destination receives each alert as a signed JSON `POST`. Signatures
follow the [Standard Webhooks](https://www.standardwebhooks.com) scheme, so
any Standard Webhooks library can verify them. To create one, see
[Alert destinations](/costgraph/alerts/destinations).

## Request

CostGraph sends these headers with every request:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `CostGraph-Alerts/1.0` |
| `webhook-id` | A unique ID for this message. Retries of the same message keep the same ID |
| `webhook-timestamp` | When the message was signed, in Unix seconds |
| `webhook-signature` | `v1,` followed by the base64 signature |

Your endpoint must answer with a `2xx` status within 10 seconds. A `429` or a
`5xx` is retried for up to 24 hours. Any other `4xx` is treated as a rejection
and isn't retried.

## Payload

Every alert has the same envelope. `data.details` carries the fields for the
alert type.

```json theme={null}
{
  "type": "cost.anomaly",
  "timestamp": "2026-10-09T08:15:02Z",
  "data": {
    "title": "Spend on Amazon EC2 rose 240%",
    "summary": "Daily spend on Amazon EC2 in us-east-1 is well above its usual range.",
    "url": "https://app.costgraph.ai/insights/9b1f...",
    "severity": "critical",
    "organization_id": "4c0e...",
    "tenant_id": "a71d...",
    "subject": "9b1f...",
    "details": {
      "anomaly_id": "9b1f...",
      "service": "Amazon EC2",
      "region": "us-east-1",
      "provider": "aws",
      "recoverable_monthly_cents": 184000,
      "started_at": "2026-10-08T00:00:00Z"
    }
  }
}
```

The most common alert types and their `details` fields:

| `type` | When | `details` fields |
| - | - | - |
| `cost.anomaly` | A critical cost anomaly opens or escalates | `anomaly_id`, `severity`, `title`, `description`, `service`, `region`, `provider`, `resource_type`, `resource_id`, `resource_name`, `recoverable_monthly_cents`, `started_at` |
| `cost.budget_threshold` | Spend crosses a budget threshold | `budget_id`, `budget_name`, `currency`, `amount`, `actual`, `threshold`, `forecast`, `period_start`, `period_end`, `period_label` |
| `cost.forecast_breach` | The forecast for a budget reaches its amount | The same fields as `cost.budget_threshold` |
| `destination.test` | You click **Send test** | `test: true` |

When many critical anomalies open at once, CostGraph sends the first few and
then one summary alert with an `overflow_count` of the rest.

## Verify the signature

Each destination has its own signing secret, shown once when you create the
destination. It starts with `whsec_`. To check a request:

1. Strip `whsec_` from the secret and base64-decode the rest to get the key.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Compute an HMAC-SHA256 of the signed content with the key, and base64-encode
   it.
4. Compare it with each `v1,` entry in `webhook-signature`, using a
   constant-time comparison.
5. Reject the request if `webhook-timestamp` is more than five minutes from
   your clock.

Verify against the raw request body, before any JSON parsing.

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import base64
    import hashlib
    import hmac
    import time


    def verify(secret: str, headers: dict, body: bytes) -> bool:
        key = base64.b64decode(secret.removeprefix("whsec_"))
        msg_id = headers["webhook-id"]
        timestamp = headers["webhook-timestamp"]
        if abs(time.time() - int(timestamp)) > 300:
            return False
        signed = f"{msg_id}.{timestamp}.".encode() + body
        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
        for entry in headers["webhook-signature"].split():
            version, _, signature = entry.partition(",")
            if version == "v1" and hmac.compare_digest(signature, expected):
                return True
        return False
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    import { createHmac, timingSafeEqual } from "node:crypto";

    export function verify(secret, headers, rawBody) {
      const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
      const id = headers["webhook-id"];
      const timestamp = headers["webhook-timestamp"];
      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
      const expected = createHmac("sha256", key)
        .update(`${id}.${timestamp}.${rawBody}`)
        .digest();
      return headers["webhook-signature"].split(" ").some((entry) => {
        const [version, signature] = entry.split(",");
        if (version !== "v1") return false;
        const received = Buffer.from(signature, "base64");
        return received.length === expected.length && timingSafeEqual(received, expected);
      });
    }
    ```
  </Tab>
</Tabs>

Store `webhook-id` values you have processed and skip repeats, because a retry
can deliver a message you already handled.

## Change the secret

A signing secret stays the same when you edit the destination, including its
URL. To get a new secret, delete the destination and create it again.


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