# Google Chat Integration

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

Source: https://docs.levelfour.ai/guides/google-chat-integration


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](/webhooks) owns the delivery model, the event payloads and the signing details this receiver depends on.

## Environment variables

| Variable                   | Description                                         |
| -------------------------- | --------------------------------------------------- |
| `LEVELFOUR_WEBHOOK_SECRET` | Webhook signing secret from LevelFour (`whsec_...`) |
| `GOOGLE_CHAT_WEBHOOK_URL`  | Google 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.

> **Warning**
>
> 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.

**Python (FastAPI)**

```bash
pip install levelfour fastapi uvicorn httpx
```

```python title="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:

```bash
LEVELFOUR_WEBHOOK_SECRET="whsec_..." \
GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/..." \
uvicorn main:app --port 8000
```

**TypeScript (Express)**

```bash
npm install levelfour express
```

```typescript title="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}`);
});
```

> **Warning**
>
> `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:

```bash
LEVELFOUR_WEBHOOK_SECRET="whsec_..." \
GOOGLE_CHAT_WEBHOOK_URL="https://chat.googleapis.com/v1/spaces/..." \
PORT=3000 \
npx tsx server.ts
```

> **Error**
>
> 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](/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.

**Python**

```python
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",
    ],
)
```

**TypeScript**

```typescript
import { LevelFourClient } from "levelfour";

const client = new LevelFourClient();
await client.webhooks.register({
    url: "https://your-domain.com/webhook",
    event_types: [
        "recommendation.accepted",
        "recommendation.rejected",
        "optimization.started",
        "optimization.completed",
        "optimization.failed",
    ],
});
```

> **Success**
>
> 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

* [Webhooks](/webhooks) is the reference: payloads, retries, timestamp tolerance and the signing algorithm
* [Slack Integration](/guides/slack-integration) is the same receiver against Slack
* [Scheduled Execution](/guides/scheduled-execution) polls on a schedule instead of receiving events
