# Webhook recipes

kosa8 Cloud can POST an event to your server when a sandbox fails, your spending cap is reached, and more. This page has receivers to copy in Node, Python and Go: each checks the signature and forwards `sandbox.error` and `spending_cap.reached` to a Slack channel.

## Add an endpoint

In the dashboard, or with an `admin` key. The URL must be public `https`. The `whsec_…` signing secret is in the answer, once: store it.

```
curl -s -X POST https://api.kosa8.com/v1/webhooks \
  -H "Authorization: Bearer $KOSA8_API_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://hooks.example.com/kosa8","events":["sandbox.error","spending_cap.reached"]}'
# {"webhook":{"id":"wh_…","url":"https://hooks.example.com/kosa8",…,"secret":"whsec_…"},…}

# later: send a webhook.test event, and see recent deliveries
curl -s -X POST https://api.kosa8.com/v1/webhooks/wh_…/test -H "Authorization: Bearer $KOSA8_API_KEY"
curl -s https://api.kosa8.com/v1/webhooks/wh_…/deliveries -H "Authorization: Bearer $KOSA8_API_KEY"
```

Leave out `events` to get all of them: `sandbox.created`, `sandbox.deleted`, `sandbox.slept`, `sandbox.woke`, `sandbox.error`, `function.deployed`, `function.invocation.failed` and `spending_cap.reached` (sent once a month).

## What arrives

A `POST` with a JSON body and three headers:

```
Kosa8-Signature: t=1794400000,v1=5f0c…e81a
Kosa8-Event: sandbox.error
Kosa8-Delivery: whd_…

{"id":"evt_…","type":"sandbox.error","created_at":1794400000000,"org_id":"org_…",
 "data":{"id":"sbx_…","error":"…","during":"create"}}
```

| Event | `data` |
|---|---|
| `sandbox.error` | `id`, `error`, `during` (what was being done, e.g. `create`) |
| `spending_cap.reached` | `cost_usd`, `spending_cap_usd`, `month` (`YYYY-MM`) |

`v1` is the hex HMAC-SHA256 of `"<t>.<raw body>"`, keyed with the whole secret, `whsec_` included. To check it:

1. Read the raw body bytes before parsing them; re-serialized JSON will not match.

2. Compute the HMAC and compare it to `v1` in constant time.

3. Refuse a `t` more than five minutes from now, so a captured request cannot be replayed later.

Answer any `2xx` to acknowledge. Anything else, a timeout over 10 seconds, or a redirect is retried after 1 min, 5 min, 30 min, 2 h and 8 h, then marked failed. A retry carries the same `Kosa8-Delivery`, so use it to skip one you have already handled.

## Slack

Each receiver below posts to a Slack [incoming webhook](https://api.slack.com/messaging/webhooks). Create one for the channel (Slack: **Apps → Incoming Webhooks → Add to Slack**) and run the receiver with both secrets in its environment:

```
$ export KOSA8_WEBHOOK_SECRET=whsec_…
$ export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/…
```

Other events are acknowledged and not forwarded. If Slack is down, the receiver answers 502 and kosa8 Cloud tries again later.

### Node.js

Node 18 or later, no packages:

```
// kosa8 webhook receiver: verify Kosa8-Signature, forward two events to Slack.
// KOSA8_WEBHOOK_SECRET=whsec_… SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… node server.mjs
import crypto from "node:crypto";
import http from "node:http";

const SECRET = process.env.KOSA8_WEBHOOK_SECRET;
const SLACK = process.env.SLACK_WEBHOOK_URL;

// verifyKosa8 checks the HMAC over "<t>.<raw body>" and refuses a t more than
// toleranceS seconds away (a replay).
export function verifyKosa8(rawBody, header, secret, toleranceS = 300) {
  const parts = {};
  for (const kv of String(header || "").split(",")) {
    const i = kv.indexOf("=");
    if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
  const want = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 || "", "hex");
  return got.length === want.length && crypto.timingSafeEqual(got, want);
}

function slackText(evt) {
  const d = evt.data || {};
  if (evt.type === "sandbox.error") {
    return `:warning: kosa8 sandbox ${d.id} failed during ${d.during}: ${d.error}`;
  }
  if (evt.type === "spending_cap.reached") {
    return `:money_with_wings: kosa8 Cloud reached its spending cap for ${d.month}: ` +
      `$${Number(d.cost_usd).toFixed(2)} of $${d.spending_cap_usd}. New work is refused until the cap is raised.`;
  }
  return null; // other events: acknowledged, not forwarded
}

const server = http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", async () => {
    const raw = Buffer.concat(chunks).toString("utf8"); // the exact bytes signed
    if (req.method !== "POST" || !verifyKosa8(raw, req.headers["kosa8-signature"], SECRET)) {
      res.writeHead(401).end();
      return;
    }
    const evt = JSON.parse(raw);
    const text = slackText(evt);
    if (text) {
      const r = await fetch(SLACK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
      if (!r.ok) { res.writeHead(502).end(); return; } // kosa8 retries later
    }
    res.writeHead(200).end("ok");
  });
});
server.listen(Number(process.env.PORT || 8080));
```

### Python

Python 3.8 or later, standard library only:

```
# kosa8 webhook receiver: verify Kosa8-Signature, forward two events to Slack.
# KOSA8_WEBHOOK_SECRET=whsec_… SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… python3 server.py
import hashlib
import hmac
import json
import os
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["KOSA8_WEBHOOK_SECRET"]
SLACK = os.environ["SLACK_WEBHOOK_URL"]


def verify_kosa8(raw: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
    """Check the HMAC over b"<t>." + raw body; refuse a t more than tolerance_s away."""
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    ts = parts.get("t", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance_s:
        return False
    want = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))


def slack_text(evt: dict):
    d = evt.get("data") or {}
    if evt.get("type") == "sandbox.error":
        return f":warning: kosa8 sandbox {d.get('id')} failed during {d.get('during')}: {d.get('error')}"
    if evt.get("type") == "spending_cap.reached":
        return (f":money_with_wings: kosa8 Cloud reached its spending cap for {d.get('month')}: "
                f"${float(d.get('cost_usd', 0)):.2f} of ${d.get('spending_cap_usd')}. New work is refused until the cap is raised.")
    return None  # other events: acknowledged, not forwarded


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # the exact bytes signed
        if not verify_kosa8(raw, self.headers.get("Kosa8-Signature"), SECRET):
            self.send_response(401)
            self.end_headers()
            return
        text = slack_text(json.loads(raw))
        if text:
            req = urllib.request.Request(SLACK, data=json.dumps({"text": text}).encode(),
                                         headers={"Content-Type": "application/json"})
            try:
                urllib.request.urlopen(req, timeout=10).read()
            except OSError:
                self.send_response(502)  # kosa8 retries later
                self.end_headers()
                return
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")


HTTPServer(("", int(os.environ.get("PORT", "8080"))), Handler).serve_forever()
```

### Go

```
// kosa8 webhook receiver: verify Kosa8-Signature, forward two events to Slack.
// KOSA8_WEBHOOK_SECRET=whsec_… SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… go run .
package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

// VerifyKosa8 checks the HMAC over "<t>." + raw body and refuses a t more
// than tolerance away (a replay).
func VerifyKosa8(raw []byte, header, secret string, tolerance time.Duration) bool {
	var ts, sig string
	for _, p := range strings.Split(header, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(p), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Since(time.Unix(t, 0)); d > tolerance || d < -tolerance {
		return false
	}
	got, err := hex.DecodeString(sig)
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(raw)
	return hmac.Equal(got, mac.Sum(nil))
}

type event struct {
	Type string         `json:"type"`
	Data map[string]any `json:"data"`
}

func slackText(e event) string {
	switch e.Type {
	case "sandbox.error":
		return fmt.Sprintf(":warning: kosa8 sandbox %v failed during %v: %v", e.Data["id"], e.Data["during"], e.Data["error"])
	case "spending_cap.reached":
		cost, _ := e.Data["cost_usd"].(float64)
		return fmt.Sprintf(":money_with_wings: kosa8 Cloud reached its spending cap for %v: $%.2f of $%v. New work is refused until the cap is raised.",
			e.Data["month"], cost, e.Data["spending_cap_usd"])
	}
	return "" // other events: acknowledged, not forwarded
}

func main() {
	secret, slack := os.Getenv("KOSA8_WEBHOOK_SECRET"), os.Getenv("SLACK_WEBHOOK_URL")
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the exact bytes signed
		if err != nil || r.Method != http.MethodPost || !VerifyKosa8(raw, r.Header.Get("Kosa8-Signature"), secret, 5*time.Minute) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}
		var e event
		if err := json.Unmarshal(raw, &e); err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		if text := slackText(e); text != "" {
			body, _ := json.Marshal(map[string]string{"text": text})
			resp, err := http.Post(slack, "application/json", bytes.NewReader(body))
			if err != nil || resp.StatusCode >= 300 {
				http.Error(w, "slack failed", http.StatusBadGateway) // kosa8 retries later
				return
			}
			resp.Body.Close()
		}
		fmt.Fprint(w, "ok")
	})
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	http.ListenAndServe(":"+port, nil)
}
```

Already using a framework? Keep the verify function and call it with the raw body: in Express use `express.raw({ type: "application/json" })` on that route, in Flask `request.get_data()`, in FastAPI `await request.body()`.

## Test it

Send yourself a signed event without waiting for a real one:

```
$ curl -s -X POST https://api.kosa8.com/v1/webhooks/wh_…/test -H "Authorization: Bearer $KOSA8_API_KEY"
```

That sends `webhook.test`, which the receivers above acknowledge without posting to Slack. To see the Slack message, set a spending cap just under this month's spend in **Settings**: the next create is refused and `spending_cap.reached` is sent.

---
Source: https://docs.kosa8.com/guides/cloud-webhooks/
