Webhooks

Outbound HMAC-signed POST when a run reaches a terminal state. Verify it in any language.

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:

PathBody 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

HeaderValue
X-FlowKoi-Eventrun.finished
X-FlowKoi-Run-IdThe run’s UUID.
X-FlowKoi-Signaturet=<unix>,v1=<hmac_sha256(t + "." + body, secret)>
User-AgentFlowKoi-Webhook/1.0
Content-Typeapplication/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.

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();
});

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:

  1. Install ngrok and run ngrok http 3000.
  2. Set callbackUrl: 'https://<your-ngrok>.ngrok.app/webhooks/flowkoi' on your trigger.
  3. 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.