Skip to main content

Webhook Triggers v1

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.

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.

InputValue
Signing secretv1-known-answer-signing-secret
X-Webhook-Timestamp1767225600
X-Event-Idevt-0001
Raw body (exact bytes){"event":"order.created","order_id":"123"}
Expected X-Signaturesha256=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 resultMeaning
202, status: acceptedThe receipt is durable. Agent admission and execution happen later.
200, status: duplicateSame retained event ID and raw bytes; no extra run.
200, status: ignoredBinding is paused. Delivery is discarded without a retained receipt.
400 / 413Invalid JSON object / body too large. Correct the request.
404Unknown/revoked route or invalid/stale signature. Check setup.
409The same event ID was used with different raw bytes. Reconcile the sender.
429Pending 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 toolSDK method on triggers_v1REST suffix after /api/v1/agents/triggers/
triggers_v1_listlist(agent_id=...)GET ?task=<agent UUID>
triggers_v1_getget(id)GET {uuid}/
triggers_v1_createcreate(agent_id, secret=..., idempotency_key=...)POST
triggers_v1_updateupdate(id, idempotency_key=..., ...)PATCH {uuid}/
triggers_v1_pausepause(id, idempotency_key=...)POST {uuid}/pause/
triggers_v1_activateactivate(id, idempotency_key=...)POST {uuid}/activate/
triggers_v1_deletedelete(id, idempotency_key=...)DELETE {uuid}/
triggers_v1_eventsevents(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.