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

Python
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_id for account-level endpoints (only account-level runs deliver). Pass user_id so only that end-user's runs deliver — scopes never mix, and there is no fallback to the account URL.

Important: The secret is only shown once, on creation. Store it securely for signature verification.

Available Events

EventDescriptionDefault
run.startedA run began execution (fires on every attempt, including replies/resumes)No
run.completedA run finished successfullyYes
run.failedA run encountered an errorYes
run.cancelledA run was cancelledYes
run.awaiting_inputA run is waiting for user input or approvalNo
run.message_receivedA message was queued for a run (an agent's send, or a reply that arrived mid-turn)No
run.message_cancelledA queued message became undeliverable (cancelled/expired run, disabled account, 24h staleness)No
connection.expiredA connected app lost authorization (token expired or revoked)No
balance.lowPrepaid token balance fell to/below your warning threshold (prepaid accounts)No
balance.criticalPrepaid balance is almost gone (20% of the threshold); runs will fail soonNo
balance.depletedPrepaid balance hit $0; runs are paused until you add creditNo

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

JSON
{
  "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_id is the wire name for the agent id: JSON request and response bodies keep it. The SDK accepts agent_id (canonical) and teammate_id alike.

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, or none) via set_run_delivery. When slack or none, the platform suppresses the completion email (first-party scheduled tasks only).
  • needs_reply: true when the agent needs a decision (needs_reply_count is how many). Surface the output, collect a reply, continue with a follow-up message.
  • headline: one-line outcome summary (may be null), 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.id is the TARGET run; data.message_id is the queued message.
  • data.sender_kind is "api" (reply while mid-turn), "agent" (another agent's run; data.sender_run_id and data.sender_teammate_id are 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.

JSON
{
  "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
JSON
{
  "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"
  }
}
Python
# 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_url

Signature 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

Python
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

Was this page helpful?