Webhook Events
These are outbound webhook events: your server gets notified when runs complete. For inbound webhooks that start runs, see Webhook Triggers.
Register endpoints for signed HTTP POSTs when runs complete, fail, or need input. Failed deliveries retry automatically.
Register a Webhook
from m8tes import M8tes
client = M8tes()
webhook = client.webhooks.create(
url="https://your-app.com/webhooks/m8tes",
events=["run.completed", "run.failed", "run.cancelled"],
# user_id="customer_123", # optional: only that end-user's runs deliver here
)
# Save the secret; it is only returned on creation
print(webhook.secret) # "a1b2c3d4e5f6..."End-user scope: omit
user_idfor account-level endpoints (only account-level runs deliver). Passuser_idso only that end-user's runs deliver — scopes never mix, and there is no fallback to the account URL.
Important: The
secretis only shown once, on creation. Store it securely for signature verification.
Available Events
The balance.* events let you react before runs start failing (auto-top-up, page on-call). Each carries balance_micros, balance_usd, and currency; set the threshold with client.billing.set_alert_threshold(low_balance_threshold_cents=...).
Payload Format
{
"id": "evt_abc123",
"type": "run.completed",
"created_at": "2026-01-15T10:05:00Z",
"data": {
"id": 42,
"teammate_id": 1,
"status": "completed",
"output": "Done! Closed 5 tickets.",
"needs_reply": false,
"needs_reply_count": null,
"headline": "closed all open tickets",
"delivery_channel": "email",
"error": null,
"error_code": null,
"user_id": null
}
}
teammate_idis the wire name for the agent id: JSON request and response bodies keep it. The SDK acceptsagent_id(canonical) andteammate_idalike.
output is the agent's closing message, clean prose.
On run.completed, envelope fields ride along:
delivery_channel: how the agent chose to reach the owner (email,slack, ornone) viaset_run_delivery. Whenslackornone, the platform suppresses the completion email (first-party scheduled tasks only).needs_reply:truewhen the agent needs a decision (needs_reply_countis how many). Surface the output, collect a reply, continue with a follow-up message.headline: one-line outcome summary (may benull), capped at 200 characters. Escape it like any untrusted string; never interpolate into HTML or commands unescaped.
On run.failed, error says why in plain language and error_code is the machine-readable code (the same values as error_code on the run). Branch on error_code, show error.
Treat run.failed as the latest state, not a permanent one. When the failure was a transient infrastructure error, the platform may automatically retry or resume the same run, so a run.failed can be followed by run.started and a terminal run.completed for the same run id. Key your handler on the most recent event.
For run.awaiting_input, data.status is awaiting_approval. When the pause is a question (AskUserQuestion), data.questions carries the full question list, so no separate runs.permissions() call is needed. See the payload example in Human-in-the-Loop. Tool-approval pauses omit questions.
For run.message_received:
data.idis the TARGET run;data.message_idis the queued message.data.sender_kindis"api"(reply while mid-turn),"agent"(another agent's run;data.sender_run_idanddata.sender_teammate_idare set), or"platform".
The message delivers as the run's next turn when the current one ends; run.started fires for that turn. Every queued message resolves to exactly one of: a delivery turn (run.started), or run.message_cancelled with a reason (owner cancelled [agent senders only; your own replies still deliver], sandbox expired, account disabled, or undelivered for 24 hours).
Cancellation payload (run.cancelled)
Cancellations fire their own run.cancelled event. Until mid-2026 they arrived as run.failed with data.status: "cancelled"; webhooks created before then should add run.cancelled to their subscription.
{
"id": "evt_def456",
"type": "run.cancelled",
"created_at": "2026-01-15T10:06:00Z",
"data": {
"id": 43,
"teammate_id": 1,
"status": "cancelled",
"output": null,
"error": null,
"user_id": null
}
}Handling Connection Expiry
When a connected app's token expires or is revoked, a connection.expired event fires. Use it to notify the affected user and trigger a reconnect flow. App triggers that use the expired connection are disabled automatically; reconnecting re-enables the flow. See Webhook Triggers.
Payload and handling code: re-initiate OAuth on expiry
{
"id": "evt_abc123",
"type": "connection.expired",
"created_at": "2026-01-15T10:05:00Z",
"data": {
"integration_id": 42,
"provider": "gmail",
"kind": "email",
"account_id": "conn_abc123"
}
}# re-initiate oauth for the affected end-user
if event["type"] == "connection.expired":
provider = event["data"]["provider"]
conn = client.apps.connect_oauth(
provider,
redirect_uri="https://yourapp.com/callback",
# the payload has no user_id; it carries integration_id/provider/kind/account_id.
# resolve the affected end-user from your own mapping if you scope per user
)
# redirect user to conn.authorization_urlSignature Verification
Verification needs no client and no API key. Pass the raw request body: a parsed-then-restringified object will not match, because re-serializing does not preserve key order or spacing.
Each request includes Webhook-Id, Webhook-Timestamp, and Webhook-Signature headers, where the signature is v1=HMAC-SHA256(secret, "{id}.{timestamp}.{body}"). Use the SDK helper to verify:
from m8tes import Webhooks
is_valid = Webhooks.verify_signature(
body=request.body,
headers=request.headers,
secret=your_webhook_secret,
tolerance_seconds=300, # reject payloads older than 5 minutes
)Manage Webhooks
page = client.webhooks.list() # secrets are masked
client.webhooks.update(webhook.id, events=["run.started", "run.completed"])
client.webhooks.update(webhook.id, active=False) # pause deliveries
client.webhooks.delete(webhook.id)
for d in client.webhooks.list_deliveries(webhook.id).data: # delivery history
print(d.event_type, d.status, d.attempts)Retry Behavior
Delivery is attempted up to 3 times with exponential backoff (2s, 4s delay); any 2xx counts as success. After all attempts the delivery is marked failed, visible via list_deliveries(). Pending deliveries are also retried if the server restarts.
Next: Webhook Triggers · Streaming · Scheduling