# Webhook Triggers v1

> Start a Live agent version from signed JSON events, with delivery receipts and safe retries.

> **Note: Availability**
>
> Webhook triggers v1 are live in the REST API, in the Agents MCP (the `triggers_v1_*`
> tools in `tools/list`) and in the Python SDK from `flymyai` 1.2.0rc15
> (`python -m pip install 'flymyai>=1.2.0rc15'`, then `client.triggers_v1`).

A webhook starts your agent's **Live saved version** with a JSON event. Publish a
version and provide defaults for its required variables first. Events run without
interactive questions. They use your agent's tools and billing account.
The Live instructions must complete the run through the normal `save_result`
tool. A final text reply without saving a result fails an unattended run.

In the app, open the agent's **Run it** tab and choose **On an event** to create,
edit, pause or delete a webhook, see how to send to it and inspect its recent
events (the same **Webhooks** panel is also under **For developers**). Creating a
webhook generates its signing secret and shows it once: store it in your sender,
for example as `FLYMY_WEBHOOK_SECRET`, and keep it private. The API never returns
the secret; replacing it rotates it immediately. Keep the webhook URL private too.

This is a generic signed webhook receiver. It does not register provider
subscriptions or accept Composio's signature protocol directly. A provider with
a different delivery format needs an adapter that verifies its signature and
then signs the FlyMy request.

## Create through the API or SDK

Management uses `/api/v1/agents/triggers/` on the agents backend. Authenticate
with `X-API-Key`, an owner OAuth bearer or your app session JWT. The agent must
belong to that account; a bound customer deployment cannot manage triggers.

```python
import os
import secrets
from uuid import uuid4
from flymyai import AgentClient

client = AgentClient(api_key=os.environ["FLYMYAI_API_KEY"])
signing_secret = secrets.token_urlsafe(32)  # Store securely in your sender.
create_key = str(uuid4())                  # Persist for this logical operation.
trigger = client.triggers_v1.create(
    os.environ["FLYMYAI_AGENT_ID"],
    secret=signing_secret,
    idempotency_key=create_key,
    rate_limit_per_hour=30,
    config={"filters": {"contains": "order"}},
)
webhook_url = trigger.webhook_url          # Private; do not log it.
```

`AsyncAgentClient.triggers_v1` provides the same methods with `await`.
For REST, POST this JSON with `Idempotency-Key: <create_key>`:

```json
{
  "agent_task": "your-agent-uuid",
  "kind": "webhook",
  "secret": "your-random-signing-secret",
  "rate_limit_per_hour": 30,
  "config": {"filters": {"contains": "order"}}
}
```

The response has `schema: "flymy.trigger.v1"`, a trigger `uuid`, `webhook_url`,
`signature_required: true`, state and counters. Creating a binding does not
validate all requirements of the Live version; a delivery can still be gated.

Send a stable `Idempotency-Key` for each management write. It is required on
create. Repeating the same operation, key and body returns its stored result;
a changed body returns `409 idempotency_conflict`. Replays have the
`Idempotent-Replayed: true` header. A lost response is not proof of failure.
Keep the original request and key to recover its result; use a new key only
for a new intentional action. The SDK and MCP require keys on all writes.

## Sign and deliver an event

Send a JSON **object**, at most 64 KiB, to the returned
`/api/v1/hooks/w/{routing_key}/` URL. Sign the exact transmitted bytes:

```python
import hashlib
import hmac
import json
import time
import httpx

event_id = "order-123-created"  # Stable across retries of this delivery.
body = json.dumps({"order_id": "123", "event": "order.created"},
                  separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
signature = hmac.new(
    signing_secret.encode("utf-8"),
    f"{timestamp}.{event_id}.".encode("utf-8") + body,
    hashlib.sha256,
).hexdigest()
response = httpx.post(webhook_url, content=body, headers={
    "Content-Type": "application/json",
    "X-Event-Id": event_id,
    "X-Webhook-Timestamp": timestamp,
    "X-Signature": f"sha256={signature}",
})
response.raise_for_status()
receipt = response.json()
```

The timestamp must be within 300 seconds of the server clock. Recalculate the
timestamp and signature when retrying later, but retain the same event ID and
raw body. The signature covers `timestamp + '.' + event_id + '.' + raw_body` and
uses a hexadecimal SHA-256 digest, not base64.

Check your signer against this test vector before sending real events. A live
endpoint answers it with `404`, because its timestamp is long past.

| Input | Value |
| --- | --- |
| Signing secret | `v1-known-answer-signing-secret` |
| `X-Webhook-Timestamp` | `1767225600` |
| `X-Event-Id` | `evt-0001` |
| Raw body (exact bytes) | `{"event":"order.created","order_id":"123"}` |
| Expected `X-Signature` | `sha256=c6b61ecb748dc913f386a40db25c46a31e945ac050d124cfcea5909213c65566` |

Use one event-ID header. If several are present, precedence is `Webhook-Id`,
`X-Event-Id`, then `X-Idempotency-Key` (first nonempty value). Without one, the
signature uses an empty event ID and duplicate detection uses the body hash.
Two intentional identical events therefore need different IDs.

| HTTP result | Meaning |
| --- | --- |
| `202`, `status: accepted` | The receipt is durable. Agent admission and execution happen later. |
| `200`, `status: duplicate` | Same retained event ID and raw bytes; no extra run. |
| `200`, `status: ignored` | Binding is paused. Delivery is discarded without a retained receipt. |
| `400` / `413` | Invalid JSON object / body too large. Correct the request. |
| `404` | Unknown/revoked route or invalid/stale signature. Check setup. |
| `409` | The same event ID was used with different raw bytes. Reconcile the sender. |
| `429` | Pending receipt capacity reached. Retry after `Retry-After` with the same event ID/body. |

Successful ingress receipts have `schema: "flymy.trigger-webhook.v1"`.
Duplicate protection lasts while the receipt is retained, normally 30 days.
It guarantees at most one admitted run per retained receipt, not exactly-once
side effects at external tools. Signing proves the sender; event content remains
untrusted input for your agent and its tool policy.

## Read the payload and diagnose a delivery

In the frozen instruction, use `{{ trigger.webhook | tojson }}` to include the
payload. Reserved `trigger.kind` and `trigger.event_id` identify the source and
the FlyMy receipt. Agent defaults cannot replace this reserved variable.

```python
events = client.triggers_v1.events(trigger.uuid)
for event in events:
    if event.execution_public_id:
        run = client.runs.get(event.execution_public_id)
```

The history endpoint `GET /agents/triggers/{uuid}/events/` returns the newest
50 receipts, with `schema: "flymy.trigger-event.v1"`. It excludes raw payloads.
`dispatched` means admitted, not completed; follow `execution_public_id` to see
the run. Other statuses include `received`, `processing`, `filtered`, `gated`,
`rate_limited`, `dropped` and `failed`. Read `detail` for the reason. A missing Live
version, missing required inputs, insufficient balance or changed owner can gate
an event. Fixing the cause later does not replay gated, filtered or rate-limited
events: send the event again with a new event ID, because the same ID answers
`duplicate` while its receipt is retained.

## Manage the binding

| MCP tool | SDK method on `triggers_v1` | REST suffix after `/api/v1/agents/triggers/` |
| --- | --- | --- |
| `triggers_v1_list` | `list(agent_id=...)` | `GET ?task=<agent UUID>` |
| `triggers_v1_get` | `get(id)` | `GET {uuid}/` |
| `triggers_v1_create` | `create(agent_id, secret=..., idempotency_key=...)` | `POST` |
| `triggers_v1_update` | `update(id, idempotency_key=..., ...)` | `PATCH {uuid}/` |
| `triggers_v1_pause` | `pause(id, idempotency_key=...)` | `POST {uuid}/pause/` |
| `triggers_v1_activate` | `activate(id, idempotency_key=...)` | `POST {uuid}/activate/` |
| `triggers_v1_delete` | `delete(id, idempotency_key=...)` | `DELETE {uuid}/` |
| `triggers_v1_events` | `events(id)` | `GET {uuid}/events/` |

MCP writes use `operation_key`, forwarded as `Idempotency-Key`. Keep secrets out
of chat and logs; create through the app or a secure API client when your MCP
client cannot protect sensitive arguments.

Only the signing secret, hourly limit and config can change. `config` replaces
the whole previous object; `{}` clears it. It supports `filters.contains` (up to
1000 characters) and `llm_model_override` (an ID from the agent model catalog).
Omit the secret to keep it; an empty secret is rejected. A new secret applies at
once, so deliveries signed with the old one get `404`. To change the bound agent,
create a new webhook.

`filters.contains` starts a run only for events whose payload contains the text,
ignoring case. Write plain text such as `order.created`, or one key with its value
the way JSON writes it, compact or with a space: `"type":"order.created"`,
`"paid": true`, `"coupon":null`. Name a single key: the stored payload may list
its keys in another order than your sender did. Other events end `filtered`.

The default dispatch limit is 30 per fixed UTC hour (range 1-10000). A sustained
burst above three times the limit auto-pauses the binding (`auto_paused`).
Activating it does not reset the hour's counter, so in the same hour its next
event pauses it again: activate after the hour turns or raise the limit first.
Limits are not spend reservations or concurrency limits. An account can own at
most 100 bindings; each binding can retain at most 1000 pending receipts before
ingress returns `429`.

Pausing affects new admissions and discards deliveries during the pause.
Deleting also removes receipt history. Neither action cancels runs already
admitted; cancel those separately. Event receipt time does not pin the Live
version: admission resolves the version then current.

The `/v1/` routes, `flymy.*.v1` schemas and `triggers_v1_*` names are the versioned
contract. Future breaking changes require a coexisting version.
