Source: https://docs.cumuluslabs.io/triggers-and-webhooks
Content version: 2699866e

# Triggers and webhooks

Webhooks tell your systems what a run did; triggers start runs without an API call. Both are part of the agent's definition: you add them to a draft and they take effect when that version is published.

## Webhooks {#webhooks}

A webhook sends chosen run events to your HTTPS endpoint, signed with a secret you create. Add it with the `notification.put` draft edit and publish. The events you can subscribe to: `run.admitted`, `run.ended`, `recording.updated`, `analysis.updated`.

**Add a webhook**

```
# 1. Create the signing secret (admin key). Keep the value: your receiver needs it.
PUT /api/v1/secrets/crm-webhook      { "kind": "webhook_signing", "value": "<a long random string>" }
#    → { "id": "crm-webhook", "current_version": "<version>", … }

# 2. Add the webhook to the agent's draft, then publish.
PATCH /api/v1/agents/{agent_id}/draft
{ "draft_id": "…", "expected_revision": 7, "edits": [
  { "type": "notification.put", "notification": {
      "id": "crm", "url": "https://hooks.example.com/talos", "format": "semantic.v1",
      "secret_ref": { "id": "crm-webhook", "version": "<version>" },
      "events": ["run.ended", "analysis.updated"] } } ] }
```

### Delivery {#delivery}

The body is the event exactly as the run's event stream carries it (format `semantic.v1`).

**What your endpoint receives**

```
POST https://hooks.example.com/talos
content-type: application/json
x-talos-event-id: 3f6c…            # the event's id; the same on every retry
idempotency-key: 3f6c…             # the same value: deduplicate on it
x-talos-signature: t=1790380800,v1=5d41402abc4b2a76b9719d911017c592…

{ "event_id": "3f6c…", "type": "run.ended", "sequence": 14, "run": { "run_id": "…", … }, "data": { "outcome": { … }, … }, … }
```

*   Answer `2xx` to accept. A `408`, `429`, `5xx` or no answer within 10 seconds is retried, up to 11 attempts; any other `4xx` is final.
*   A retry can arrive after a success you sent but Talos did not receive: deduplicate on `idempotency-key`.
*   Each attempt is signed again, with its own timestamp. Deliveries are governed effects: see them with `GET /api/v1/runs/{run_id}/effects`.
*   Test runs (`"test": true`) are never delivered.

### Verify the signature {#verify}

`x-talos-signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256>` of `<t>.<raw body>` with your signing secret. Compute it over the raw bytes you received (before parsing the JSON), compare in constant time, and reject old timestamps; five minutes is a reasonable window.

<!-- code-tabs: Verify a webhook -->


**Verify a webhook · typescript**

```typescript
import { createHmac, timingSafeEqual } from "node:crypto";

/** True when the body was signed with your webhook signing secret within the last five minutes. */
export function verifyTalosWebhook(rawBody: string, signatureHeader: string, secret: string): boolean {
  const parts = Object.fromEntries(signatureHeader.split(",").map(part => part.split("=", 2) as [string, string]));
  // t is unix seconds; v1 is exactly 64 lowercase hex characters (Buffer.from would skip trailing junk).
  if (!/^\d{1,12}$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
  return timingSafeEqual(Buffer.from(parts.v1, "hex"), expected);
}
```

**Verify a webhook · python**

```python
import hashlib
import hmac
import re
import time


def verify_talos_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    """True when the body was signed with your webhook signing secret within the last five minutes."""
    parts = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part)
    # t is unix seconds; v1 is exactly 64 lowercase hex characters.
    if not re.fullmatch(r"\d{1,12}", parts.get("t", "")) or not re.fullmatch(r"[0-9a-f]{64}", parts.get("v1", "")):
        return False
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```




<!-- /code-tabs -->

> **Note**
> 
> Publications pin a secret version. After you rotate a secret with `PUT /api/v1/secrets/{secret_id}`, update the draft's `secret_ref` and publish again.

## Triggers {#triggers}

A trigger starts a `background` cloud run of the agent it belongs to:

*   **schedule**: a five-field cron expression in a time zone, with a fixed `input`.
*   **event**: when a run matching `source` (an agent, a channel, dispositions) emits `run.ended` or `analysis.updated`. `input_mapping` builds the new run's input from JSON pointers into that event.

**Draft edits**

```
# Every weekday at 9:00 in New York, start a background run with this input.
{ "type": "trigger.put", "trigger": {
    "kind": "schedule", "id": "morning-sweep", "cron": "0 9 * * 1-5", "timezone": "America/New_York",
    "input": { "segment": "overdue" } } }

# When a phone run of another agent ends as voicemail, start a run of this agent with fields from that event.
{ "type": "trigger.put", "trigger": {
    "kind": "event", "id": "after-voicemail", "event_type": "run.ended",
    "source": { "agent_id": "<caller agent id>", "channel": "phone", "dispositions": ["voicemail_left"] },
    "input_mapping": { "previous_run_id": "/run/run_id" } } }
```

*   Only the head's triggers are active. Publishing with `"live": false` does not register them; promoting that version does, and replaces the previous head's triggers.
*   Publishing a trigger checks that you may start those runs; if not, `403 policy_denied`.
*   Test runs never fire event triggers. Remove a trigger with `trigger.remove` and publish.

## Secrets {#secrets}

Secrets hold the values agents must not carry in their definition: webhook signing keys, HTTP action credentials and SIP passwords (kinds `webhook_signing`, `http_auth`, `sip_auth`). Values are write-only: reads return names and versions, never values. Each `PUT` adds a version; drafts reference a secret as `{ id, version }`. Writing and deleting secrets takes an admin key. See [Phone](/docs/phone) for trunk credentials.

| Operation | MCP tool | Key role | Purpose |
| --- | --- | --- | --- |
| [`GET /api/v1/secrets`](https://docs.cumuluslabs.io/reference/secrets.list) | `secrets_list` | viewer | List secret metadata (never values) |
| [`PUT /api/v1/secrets/{secret_id}`](https://docs.cumuluslabs.io/reference/secrets.put) | `secrets_put` | admin | Create a secret or add a new version |
| [`DELETE /api/v1/secrets/{secret_id}`](https://docs.cumuluslabs.io/reference/secrets.delete) | `secrets_delete` | admin | Delete a secret and all its versions |
