When you trigger a run with a callbackUrl, FlowKoi POSTs to that URL exactly once when the run finishes — whether it completed naturally, was stopped, was deleted, or errored. The body is HMAC-signed so you can prove it came from us.
When does it fire?
The webhook is fired by the first terminal-state transition. Possible triggers:
| Path | Body error field |
|---|---|
| Container exits naturally | (absent) on success, exit-code message on non-zero |
POST /api/v1/runs/{runId}/stop | "stopped_by_api" |
DELETE /api/v1/runs/{runId} | "deleted_by_api" |
| Container crashes / hits session timeout | "max_reconnects_exceeded" / similar |
If the same run reaches a terminal state through multiple paths (e.g. /stop racing the container’s own exit), only the first one delivers. An atomic claim on the run record guarantees exactly-once.
Payload
{
"runId": "0d29ff4e-0c91-4d33-8d10-c2b7b40f0bd9",
"flowId": "be3e4d9a-3bb0-4d1b-94e8-cba26d2ad65d",
"projectId": "proj_abc123",
"status": "sleep",
"claudeStatus": "idle",
"triggerType": "api",
"startedAt": "2026-06-01T16:42:01.000Z",
"finishedAt": "2026-06-01T16:44:33.000Z",
"error": "stopped_by_api"
}
status is always "sleep" (container gone). error is omitted on clean completion.
Headers
| Header | Value |
|---|---|
X-FlowKoi-Event | run.finished |
X-FlowKoi-Run-Id | The run’s UUID. |
X-FlowKoi-Signature | t=<unix>,v1=<hmac_sha256(t + "." + body, secret)> |
User-Agent | FlowKoi-Webhook/1.0 |
Content-Type | application/json |
Get your signing secret
Each project has a webhookSecret (auto-generated on the first webhook delivery). Find it in Project Settings → API.
Store it server-side as FLOWKOI_WEBHOOK_SECRET — it’s the only thing standing between an attacker forging completion events and your handler trusting them.
Verify the signature
warning
Verify against the raw request body, not a re-serialised JSON object. Re-stringifying changes byte order and key spacing, which breaks the HMAC.
- TypeScript (SDK)
- Node (no SDK)
- Python
- Go
import { verifyWebhook } from "@flowkoi/sdk/webhook";
app.post("/webhooks/flowkoi", express.raw({ type: "application/json" }), async (req, res) => {
const result = await verifyWebhook(
req.body,
req.header("x-flowkoi-signature"),
process.env.FLOWKOI_WEBHOOK_SECRET!,
);
if (!result.valid) return res.status(401).end();
// result.payload is fully typed as CompletionPayload.
await handleCompletion(result.payload);
res.status(200).end();
});import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map(p => p.split("=")));
if (!parts.t || !parts.v1) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (skew > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}import hmac, hashlib, time
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=") for p in signature_header.split(","))
if not {"t", "v1"} <= parts.keys():
return False
if abs(int(time.time()) - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{parts['t']}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])func verify(rawBody []byte, signatureHeader, secret string) bool {
parts := map[string]string{}
for _, p := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 { parts[kv[0]] = kv[1] }
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil { return false }
if math.Abs(float64(time.Now().Unix()-t)) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", t, rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}Retries
FlowKoi retries up to 3 times on any non-2xx response, with delays of 2 s, 10 s, 30 s. Each attempt times out at 10 seconds. After three failures, the delivery is logged and dropped — no manual retry surface today.
Your handler should be idempotent on runId: a 2xx + duplicate delivery is far easier to handle than a non-2xx + dropped event.
Local development
The easiest way to iterate locally:
- Install ngrok and run
ngrok http 3000. - Set
callbackUrl: 'https://<your-ngrok>.ngrok.app/webhooks/flowkoi'on your trigger. - Trigger a short run, watch the payload land.
webhook.site works too if you just want to see the body without writing a handler — but you won’t be able to verify the signature there since you don’t have the secret on their server.