Events & webhooks
Everything that happens in a project lands on one append-only event feed: run lifecycle, approvals, tool calls, memory consolidation, budget alerts. You consume it two ways: poll the feed, or register webhooks and receive the same envelope as signed POSTs.
The event envelope
Both delivery modes carry the identical shape:
{ "v": 1, "id": "evt_…", "type": "run.completed",
"created_at": "2026-06-11T…Z", "tenant_id": "…",
"smith_id": "smt_…", "data": { "stop_reason": "end_turn", "usage": { … } } }
Polling the feed
# Authorization: tenant-admin token (server-side only)
curl "https://api.cloud.ingram.tech/v1/events?type=run.completed&smith_id=smt_…&since=2026-06-10T00:00:00Z&limit=50"
The feed is the reliable source of truth: if a webhook delivery is ever missed, everything is still here. The console's Observe → Events renders this same feed with the exact payload per row.
Registering webhooks
# Authorization: tenant-admin token (server-side only)
curl https://api.cloud.ingram.tech/v1/tenant/webhooks \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" \
-d '{ "url": "https://you.example/ic/webhook",
"events": ["run.completed", "run.failed", "approval.required"] }'
# → { "id": "wh_…", "secret": "whsec_…" } ← shown ONCE; store it
- An empty
eventslist subscribes to everything; otherwise a type matches exactly or by family prefix. Subscribing toslackcatches everyslack.*type. POST /v1/tenant/webhooks/{id}/testfires a sample event;PATCH { "active": false }pauses deliveries;DELETEremoves the endpoint.
Verifying signatures
Every delivery carries X-IC-Event-Id (dedupe key) and
X-IC-Signature: t=<unix ts>,v1=<hex>, where
v1 = HMAC-SHA256(secret, "<t>." + raw_body):
import hashlib, hmac
def verify(headers, raw_body: bytes, secret: str) -> bool:
t, v1 = (kv.split("=", 1)[1] for kv in headers["X-IC-Signature"].split(","))
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Compute over the raw request body (before any JSON parsing), and reject timestamps older than a few minutes to block replays.
Your tools themselves run over MCP: Ingram Cloud calls your MCP
server directly (it is the MCP client), so tool execution does not ride this
event feed; only the human-in-the-loop and onboarding moments do
(approval.required, connection.required, unbound_message). The envelope's
top-level smith_id is which smith the event belongs to.
Delivery semantics
- Delivery is one signed attempt per event today. There is no automatic
retry queue yet. Build on the combination: webhooks for latency, the
/v1/eventspoll mirror for completeness (e.g. a periodic reconciliation sweep withsince=). - Use
X-IC-Event-Idto dedupe; deliveries are not guaranteed to arrive in order. - Respond
2xxquickly (within 10s) and do real work async.
Event type catalog
| Type | Fired when | Notable data |
|---|---|---|
run.started | a run begins | smith_id, thread_id |
run.paused | waiting on a tool result or approval | reason, tool_calls |
run.completed | a run finishes | stop_reason (+ usage on sync runs) |
run.failed | a run errors | error |
run.cancelled | cancelled via /submit, or a dropped OpenAI-compatible stream | reason |
tool.executing | the agent invoked a server-side tool (also a live stream frame) | tool, tool_call_id, args |
tool.completed | a server-side tool returned (also a live stream frame) | tool, tool_call_id, output |
approval.required | a gated tool wants to run | approval_id, tool, args |
approval.resolved | a human decided | approval_id, decision, actor |
connection.required | an oauth MCP tool ran but the smith has no connection | provider, mcp_server, authorize_url (when a connect flow is available; else null) |
unbound_message | a message from an unbound sender (no smith) | kind, sender, text (preview) |
message.completed | a turn's final text | content, suggested_replies (when the agent offered quick-reply chips) |
budget.threshold | a budget crossed 80% / 100% | budget_id, tier, spent |
credit.exhausted | a run was refused because the org wallet is empty | organization, balance_cents |
deployment.bound | a deferred deployment binding completed | kind, channel_id, address |
deployment.inbound | an inbound message woke the smith | kind, channel_id, triggered_run_id |
slack.app_provisioned | the factory minted a per-smith Slack app | channel_id, slack_app_id, display_name |
slack.install | an OAuth install completed | channel_id, team_id, slack_user_id, scopes, user_scopes |
slack.uninstalled | a workspace uninstalled the app | channel_id, team_id |
email.send_failed | an outbound email could not be delivered | channel_id, to, detail |
webhook.test | you called the test endpoint | sample payload |