Guides

Google Chat Integration

Build a webhook receiver that verifies LevelFour signatures and posts each event into a Google Chat space, in Python or TypeScript.

A Google Chat incoming webhook takes a JSON payload over HTTP POST at a URL. There is no OAuth flow and no bot to install, so the receiver stays small: verify the LevelFour signature, shape the event into a message, post it to the URL.

Webhooks owns the delivery model, the event payloads and the signing details this receiver depends on.

Environment variables

VariableDescription
LEVELFOUR_WEBHOOK_SECRETWebhook signing secret from LevelFour (whsec_...)
GOOGLE_CHAT_WEBHOOK_URLGoogle Chat incoming webhook URL

Set it up

Create the Google Chat webhook

  1. Open the Google Chat space where you want notifications
  2. Click the space name at the top, then Apps & integrations
  3. Click Add webhooks
  4. Name it, "LevelFour Costs" for example. The avatar URL is optional
  5. Copy the webhook URL. It looks like https://chat.googleapis.com/v1/spaces/...

That URL is what GOOGLE_CHAT_WEBHOOK_URL holds.

The Chat webhook URL is the credential. Nothing sits behind it, no OAuth and no bot, so anyone holding it can post into the space. Keep it in an environment variable or a secrets manager, never in a committed file.

Build the receiver

The receiver verifies the signature, builds a Chat message from the event, and posts it to the space.

pip install levelfour fastapi uvicorn httpx
main.py
import os

import httpx
from fastapi import FastAPI, Request, Response

from levelfour.webhooks.verifier import WebhookVerificationError, WebhookVerifier

app = FastAPI()

WEBHOOK_SECRET = os.environ["LEVELFOUR_WEBHOOK_SECRET"]
GOOGLE_CHAT_WEBHOOK_URL = os.environ["GOOGLE_CHAT_WEBHOOK_URL"]

verifier = WebhookVerifier(WEBHOOK_SECRET)

EVENT_LABELS = {
    "recommendation.accepted": "Recommendation Accepted",
    "recommendation.rejected": "Recommendation Rejected",
    "optimization.started": "Optimization Started",
    "optimization.completed": "Optimization Completed",
    "optimization.failed": "Optimization Failed",
}


def build_chat_message(event_type: str, payload: dict) -> dict:
    label = EVENT_LABELS.get(event_type, event_type)
    rec_id = payload.get("recommendation_id", "unknown")
    status = payload.get("status", "unknown")

    text = f"*LevelFour: {label}*\nRecommendation: `{rec_id}`\nStatus: `{status}`"

    if event_type == "recommendation.accepted":
        accepted_by = payload.get("saving_accepted_by", "unknown")
        text += f"\nAccepted by: {accepted_by}"

    if event_type == "recommendation.rejected":
        reason = payload.get("rejection_reason", "No reason provided")
        text += f"\nReason: {reason}"

    if event_type == "optimization.started":
        method = payload.get("implementation_method", "unknown")
        text += f"\nMethod: {method}"

    if event_type == "optimization.failed":
        text += "\n⚠️ Optimization failed"

    return {"text": text}


async def post_to_google_chat(message: dict) -> None:
    async with httpx.AsyncClient() as client:
        await client.post(GOOGLE_CHAT_WEBHOOK_URL, json=message)


@app.post("/webhook")
async def handle_webhook(request: Request) -> Response:
    body = await request.body()
    headers = {
        "webhook-id": request.headers.get("webhook-id", ""),
        "webhook-timestamp": request.headers.get("webhook-timestamp", ""),
        "webhook-signature": request.headers.get("webhook-signature", ""),
    }

    try:
        payload = verifier.verify(payload=body, headers=headers)
    except WebhookVerificationError:
        return Response(status_code=400, content="Invalid signature")

    event_type = payload.get("type", "")
    message = build_chat_message(event_type, payload)
    await post_to_google_chat(message)

    return Response(status_code=200, content="OK")

Run it with both variables in the environment:

LEVELFOUR_WEBHOOK_SECRET="whsec_..." \
GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/..." \
uvicorn main:app --port 8000
npm install levelfour express
server.ts
import express from "express";
import { WebhookVerifier, WebhookVerificationError } from "levelfour";

const WEBHOOK_SECRET = process.env.LEVELFOUR_WEBHOOK_SECRET!;
const GOOGLE_CHAT_WEBHOOK_URL = process.env.GOOGLE_CHAT_WEBHOOK_URL!;
const PORT = parseInt(process.env.PORT || "3000", 10);

const verifier = new WebhookVerifier(WEBHOOK_SECRET);

const EVENT_LABELS: Record<string, string> = {
    "recommendation.accepted": "Recommendation Accepted",
    "recommendation.rejected": "Recommendation Rejected",
    "optimization.started": "Optimization Started",
    "optimization.completed": "Optimization Completed",
    "optimization.failed": "Optimization Failed",
};

interface WebhookPayload {
    type?: string;
    recommendation_id?: string;
    status?: string;
    saving_accepted_by?: string;
    rejection_reason?: string;
    implementation_method?: string;
    [key: string]: unknown;
}

function buildChatMessage(eventType: string, payload: WebhookPayload): { text: string } {
    const label = EVENT_LABELS[eventType] || eventType;
    const recId = payload.recommendation_id || "unknown";
    const status = payload.status || "unknown";

    let text = `*LevelFour: ${label}*\nRecommendation: \`${recId}\`\nStatus: \`${status}\``;

    if (eventType === "recommendation.accepted" && payload.saving_accepted_by) {
        text += `\nAccepted by: ${payload.saving_accepted_by}`;
    }

    if (eventType === "recommendation.rejected") {
        const reason = payload.rejection_reason || "No reason provided";
        text += `\nReason: ${reason}`;
    }

    if (eventType === "optimization.started" && payload.implementation_method) {
        text += `\nMethod: ${payload.implementation_method}`;
    }

    if (eventType === "optimization.failed") {
        text += "\n⚠️ Optimization failed";
    }

    return { text };
}

const app = express();
app.use(express.raw({ type: "application/json" }));

app.post("/webhook", async (req, res) => {
    const body = req.body as Buffer;
    const headers: Record<string, string> = {
        "webhook-id": req.headers["webhook-id"] as string || "",
        "webhook-timestamp": req.headers["webhook-timestamp"] as string || "",
        "webhook-signature": req.headers["webhook-signature"] as string || "",
    };

    let payload: WebhookPayload;
    try {
        payload = verifier.verify(body, headers) as WebhookPayload;
    } catch (err) {
        if (err instanceof WebhookVerificationError) {
            res.status(400).send("Invalid signature");
            return;
        }
        throw err;
    }

    const eventType = payload.type || "unknown";
    const message = buildChatMessage(eventType, payload);

    await fetch(GOOGLE_CHAT_WEBHOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(message),
    });

    res.status(200).send("OK");
});

app.listen(PORT, () => {
    console.log(`Listening on port ${PORT}`);
});
express.raw on this route is load-bearing. The signature covers the exact bytes LevelFour sent, so an express.json() in front of it hands the verifier a re-serialized body and every delivery fails, which reads exactly like a wrong signing secret.

Run it with both variables in the environment:

LEVELFOUR_WEBHOOK_SECRET="whsec_..." \
GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/..." \
PORT=3000 \
npx tsx server.ts
A 400 Invalid signature means the verifier rejected the request, so nothing reached Google Chat. Check that LEVELFOUR_WEBHOOK_SECRET holds the whsec_ value for this endpoint, and that the verifier is reading the raw request body. Webhooks has the algorithm and the header names.

Register the endpoint with LevelFour

LevelFour delivers to a URL it can reach, so the receiver has to be live at a public HTTPS address before you register it.

from levelfour import LevelFour

client = LevelFour()
client.webhooks.register(
    url="https://your-domain.com/webhook",
    event_types=[
        "recommendation.accepted",
        "recommendation.rejected",
        "optimization.started",
        "optimization.completed",
        "optimization.failed",
    ],
)
The next matching event posts a message into the space, headed LevelFour: Recommendation Accepted or whichever label fits. That message is the proof: the signature verified and the post went through.

Next

On this page

Ask the FinOps Agent about your cloud spend