kosa8 docs

Guides / kosa8 Cloud / Webhook recipes

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"}}
Eventdata
sandbox.errorid, error, during (what was being done, e.g. create)
spending_cap.reachedcost_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. 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.