Webhook Triggers v1
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.
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>:
{
"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:
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.
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.