iterion

Outbound run-completion callbacks

Inbound webhooks (an external event triggering an iterion run) live in docs/webhooks.md. This page covers the opposite direction: a run reaching a terminal state and POSTing the result somewhere.

A run-completion callback lets an external integration trigger an iterion run asynchronously and be told — without polling — when the run finished and what its final answer was. It is a generic engine primitive: the payload is a neutral JSON envelope, and all platform-specific handling lives in the receiver.

The headline consumer is the Mattermost @clarify-bot adapter, but the contract is platform-neutral: a CI bridge, a Slack adapter, or any service that wants “call me back when this run is done” uses the same mechanism.

Requesting a callback

Supply three optional fields when launching a run via POST /api/runs (they also exist on runview.LaunchSpec for programmatic callers):

{
  "source": "<.bot source>",
  "vars": { "...": "..." },
  "callback_url": "https://my-service.example.com/iterion-callback",
  "callback_token": "<opaque correlation value>",
  "callback_answer_node": "facilitator"   // optional
}

The three fields are persisted on the run and propagated across the cloud queue (queue.RunMessage), so both local (in-process) and cloud (runner-pod) executions deliver the callback identically.

The payload

When the run terminates, iterion POSTs this JSON to callback_url:

{
  "v": 1,
  "run_id": "01J…",
  "status": "finished",            // finished | failed | failed_resumable | cancelled
  "workflow_name": "clarify",
  "final_answer": "Did you mean staging or prod?",
  "final_answer_node": "facilitator",
  "error": "",                     // populated when status != finished
  "callback_token": "<your value, verbatim>"
}

Delivery is best-effort: a webhook failure is logged and never affects the run outcome. The callback_url is never logged (it may embed a secret in its query string).

Authenticating the payload (HMAC signature)

The callback_token is for correlation, not authentication — iterion echoes it back but never verifies it, so on its own it does not stop a forged POST to a known callback URL. To let the receiver prove a delivery genuinely came from iterion, set a shared secret on the iterion server:

ITERION_COMPLETION_WEBHOOK_SECRET=<random-secret>

When set, iterion HMAC-SHA256-signs the exact response body and sends the result in a header:

X-Iterion-Signature: sha256=<hex>

The receiver recomputes HMAC_SHA256(secret, raw_body) over the bytes it received (before JSON parsing) and compares — constant-time — against the header. A mismatch means a forged or tampered request; reject it. The helper that produces and checks this is notify.Sign / notify.Verify (the same primitive a future native inbound webhook would use to authenticate trigger requests).

When the secret is empty (default) iterion sends no signature header. A receiver should then either run only on a trusted private network or refuse to start — notify.Verify returns false for every input when its secret is empty, so “no secret configured” fails closed rather than silently accepting everything.

Security: SSRF guard

callback_url arrives over the launch API, so it is treated as attacker-influenced. Before delivering, iterion vets it (pkg/notify):

This mirrors the SSRF blocklist used by the preview proxy (pkg/server.isPublicUnicast).

Allowing private targets (self-host)

Many self-hosted deployments run the callback receiver on the same private network as iterion (e.g. the Mattermost adapter beside the server). Set:

ITERION_COMPLETION_WEBHOOK_ALLOW_PRIVATE=1

on the iterion server (and on cloud runner pods, if applicable) to relax the guard and permit private/loopback callback URLs. Off by default — cloud runners must not be able to gateway into a private network.

Implementation map

Concern Location
Payload + notifier + SSRF guard pkg/notify/completion.go
Run fields store.Run.{CallbackURL,CallbackToken,CallbackAnswerNode}
Queue propagation queue.RunMessage.{CallbackURL,…}
Launch option runtime.WithCallback
Launch API launchRunRequest in pkg/server/runs.go; runview.LaunchSpec
Fired (local) runview spawnRun, after the run body
Fired (cloud) runner processOne, after executeRun