> ## Documentation Index
> Fetch the complete documentation index at: https://docs.asteroid.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks and Slack

> Push execution events to your own endpoint or into a Slack channel, with filters, signed payloads, and the full event list.

workflows emit events as they run. An **integration** carries those events to somewhere you watch — an
HTTP endpoint you own, or a Slack channel.

Use integrations instead of polling when executions are long or volume is high. Use them when a person
must know that a unattended execution failed.

***

## How events reach you

Integrations work in two layers.

1. **The integration.** You create it once for the organisation. It holds the connection — the
   webhook URL, or the authorised Slack workspace.
2. **The notification.** You attach the integration to a workflow, and choose which events fire it.

One integration can serve many workflows. One workflow can carry several notifications with different
rules.

```mermaid theme={null}
flowchart TD
    Service["Outside service (Slack, your API)"]
    Integration["Integration"]
    NotifA["Notification A"]
    NotifB["Notification B"]
    workflowA["workflow A"]
    workflowB["workflow B"]

    Service <--> Integration
    Integration --> NotifA
    Integration --> NotifB
    NotifA --> workflowA
    NotifB --> workflowB
```

***

## Set up a webhook

<Steps>
  <Step title="Create the integration">
    Go to **Integrations** → **Add Integration** → **Webhook**, then set:

    * **URL** — the endpoint that receives the events.
    * **Headers** — optional headers Asteroid adds to every request.

    Asteroid always sends `POST` with a JSON body.
  </Step>

  <Step title="Attach it to a workflow">
    Open the workflow, go to the **Notifications** tab, and select **Add Notification**. Then configure:

    * **Event rules** — which event types fire the notification.
    * **Field filters** — match on fields inside the payload.
    * **Metadata filter** — fire only when the execution's metadata matches.
    * **Unwrap result** — for `EXECUTION_COMPLETED`, send `payload.result` alone instead of the full
      envelope.
  </Step>

  <Step title="Verify the signature">
    Check `X-Asteroid-Webhook-Signature` against the raw request body before you parse or act on
    anything. See [Verify the signature](#verify-the-signature).
  </Step>
</Steps>

***

## Payload structure

Every request body has the same shape, unless `Unwrap result` is set on an `EXECUTION_COMPLETED` rule. That delivery carries the result object on its own.

```json theme={null}
{
  "type": "execution",
  "event_id": "83b7b49f-1f73-4f86-9d89-6fd8dd2557f3",
  "timestamp": "2026-03-27T10:30:00Z",
  "info": {
    "event": "EXECUTION_COMPLETED",
    "execution_id": "a2c7f3b3-cbc8-4b6a-a5f2-c5f6cb548e95",
    "execution_url": "https://platform.asteroid.ai/executions/a2c7f3b3-cbc8-4b6a-a5f2-c5f6cb548e95",
    "workflow_id": "6bf63aaf-6e3e-4115-8f13-e707d627582f",
    "workflow_name": "Data Extraction workflow",
    "agent_id": "6bf63aaf-6e3e-4115-8f13-e707d627582f",
    "agent_name": "Data Extraction workflow",
    "metadata": {
      "environment": "production"
    },
    "payload": {
      "result": { "slots": [], "nextAvailable": "2026-04-08" },
      "reasoning": "The portal returned an empty calendar for the requested week.",
      "outcome": "no_slots_available"
    }
  }
}
```

<ParamField body="type" type="string" required>
  The category of the notification. `execution` for execution events. Batch events use
  `execution_batch` and a different `info` shape. See [Batch notifications](#batch-notifications).
</ParamField>

<ParamField body="event_id" type="string" required>
  Identifier for this delivery.
</ParamField>

<ParamField body="timestamp" type="string" required>
  ISO 8601 timestamp of the event.
</ParamField>

<ParamField body="info" type="object" required>
  The execution context and the event-specific payload.
</ParamField>

`info` holds:

<ParamField body="info.event" type="string" required>
  The event name. See [Event types](#event-types).
</ParamField>

<ParamField body="info.execution_id" type="string" required>
  Execution UUID.
</ParamField>

<ParamField body="info.execution_url" type="string" required>
  Link to the execution in the platform. For batch events it points at the workflow's batch page.
</ParamField>

<ParamField body="info.workflow_id" type="string" required>
  Workflow UUID.
</ParamField>

<ParamField body="info.workflow_name" type="string" required>
  Workflow display name.
</ParamField>

<ParamField body="info.agent_id" type="string" deprecated>
  Same value as `info.workflow_id`. Use `info.workflow_id`.
</ParamField>

<ParamField body="info.agent_name" type="string" deprecated>
  Same value as `info.workflow_name`. Use `info.workflow_name`.
</ParamField>

<ParamField body="info.metadata" type="object">
  The metadata you attached when you started the execution.
</ParamField>

<ParamField body="info.payload" type="object" required>
  Event-specific data. Its shape depends on `info.event`.
</ParamField>

***

## Event types

Match on `info.event`. The rule picker in the platform lists the same events by name.

### Execution lifecycle

| `info.event` | Payload |
| - | - |
| `EXECUTION_STARTED` | `{}` |
| `EXECUTION_COMPLETED` | `{ result, reasoning, outcome }` |
| `EXECUTION_FAILED` | `{ reason }` |
| `EXECUTION_CANCELLED` | `{ reason, cancelled_by }` |
| `EXECUTION_PAUSED` | `{ reason, paused_by }` |
| `EXECUTION_RESUMED` | `{ reason }` |
| `EXECUTION_AWAITING_CONFIRMATION` | `{ reason }` |

`EXECUTION_CANCELLED` carries the cancel reason. See [Debug your workflows](/operate/debug) for what
each reason means.

### Actions and steps

| `info.event` | Payload |
| - | - |
| `EXECUTION_ACTION_STARTED` | `{ action_id, action_name, arguments, step_number }` |
| `EXECUTION_ACTION_COMPLETED` | `{ action_id, action_name, output, step_number, duration? }` |
| `EXECUTION_ACTION_FAILED` | `{ action_id, action_name, failure, step_number, duration?, os_error? }` |
| `EXECUTION_STEP_STARTED` | `{ step }` |
| `EXECUTION_STEP_PROCESSED` | `{ step }` |

### Execution detail

| `info.event` | Payload |
| - | - |
| `EXECUTION_MESSAGE_ADDED` | `{ message }` |
| `EXECUTION_REASONING_ADDED` | `{ reasoning }` |
| `EXECUTION_FILE_ADDED` | `{ file_id, file_name, mime_type, file_size, source, presigned_url }` |
| `EXECUTION_PLAYWRIGHT_SCRIPT_GENERATED` | `{ node_id, node_name, script, context, generated_at }` |
| `EXECUTION_TRANSITIONED` | `{ to_node, from_node_duration?, transition_type? }` |
| `USER_MESSAGE_RECEIVED` | `{ user_id, message, execution_was, injected_into }` |

### Tests

| `info.event` | Payload |
| - | - |
| `EXECUTION_TEST` | `{ message }` |

***

## Batch notifications

A batch is not an execution, so its events arrive with `type: "execution_batch"` and a batch `info`.
Subscribe with the `Batch Started` and `Batch Completed` rules. Batch events carry no metadata, so a
notification with a metadata filter never fires for them. Started and Completed can arrive out of
order for a very small batch.

```json theme={null}
{
  "type": "execution_batch",
  "event_id": "5a0e1b1e-7d3d-4c1e-9a2a-4f4c9a3d2b10",
  "timestamp": "2026-03-27T10:30:00Z",
  "info": {
    "event": "EXECUTION_BATCH_COMPLETED",
    "batch_id": "0f5b2d1c-6a7e-4b2f-8c3d-1e2f3a4b5c6d",
    "batch_name": "march-invoices",
    "batch_url": "https://platform.asteroid.ai/workflows/6bf63aaf-6e3e-4115-8f13-e707d627582f/batch",
    "workflow_id": "6bf63aaf-6e3e-4115-8f13-e707d627582f",
    "workflow_name": "Data Extraction workflow",
    "agent_id": "6bf63aaf-6e3e-4115-8f13-e707d627582f",
    "agent_name": "Data Extraction workflow",
    "payload": { "triggered_count": 120, "cancelled_count": 0 }
  }
}
```

`event_id` is stable for a batch event. A redelivery repeats it, so you can deduplicate on it.

| `info.event` | Payload |
| - | - |
| `EXECUTION_BATCH_STARTED` | `{ item_count }` |
| `EXECUTION_BATCH_COMPLETED` | `{ triggered_count, cancelled_count }` |

See [Batch executions](/operate/batches).

### Example: a failed action

```json theme={null}
{
  "type": "execution",
  "event_id": "f0df4bd4-fdf8-445b-b870-1cd6ed329f39",
  "timestamp": "2026-03-27T16:10:05Z",
  "info": {
    "event": "EXECUTION_ACTION_FAILED",
    "execution_id": "a2c7f3b3-cbc8-4b6a-a5f2-c5f6cb548e95",
    "execution_url": "https://platform.asteroid.ai/executions/a2c7f3b3-cbc8-4b6a-a5f2-c5f6cb548e95",
    "workflow_id": "6bf63aaf-6e3e-4115-8f13-e707d627582f",
    "workflow_name": "Data Extraction workflow",
    "agent_id": "6bf63aaf-6e3e-4115-8f13-e707d627582f",
    "agent_name": "Data Extraction workflow",
    "payload": {
      "action_id": "node-123",
      "action_name": "click",
      "failure": "Timeout waiting for selector",
      "step_number": 7,
      "duration": 1432,
      "os_error": {
        "message": "Target page, context or browser has been closed"
      }
    }
  }
}
```

***

## Verify the signature

Asteroid signs every delivery. Check the signature against the raw request body before you parse or
act on anything.

Every request carries these headers:

| Header | Purpose |
| - | - |
| `X-Asteroid-Webhook-Signature` | The signature to verify. |
| `X-Asteroid-Webhook-Key-Id` | The `kid` of the key that produced it. |
| `X-Asteroid-Signature` | Deprecated. See [the legacy header](#the-legacy-header). |

Each signature is the SHA-256 digest of the raw body bytes, signed with RSA PKCS#1 v1.5, then Base64
encoded. The key set names that algorithm `RS256`.

<Warning>
  Verify against the **raw** request bytes, before you parse the JSON. Any reserialisation changes the
  bytes and breaks the check.
</Warning>

### Fetch the public keys

Public keys are published as a JSON Web Key Set. The endpoint is public and needs no credentials.

```
GET https://odyssey.asteroid.ai/.well-known/jwks.json
```

```json theme={null}
{
  "keys": [
    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "k2", "n": "…", "e": "AQAB" },
    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "k3", "n": "…", "e": "AQAB" }
  ]
}
```

Select the key whose `kid` matches `X-Asteroid-Webhook-Key-Id`.

Cache the set and refetch when a delivery names a `kid` you do not hold. A new key appears in the set
before we sign anything with it. A receiver that refetches on an unknown `kid` survives every rotation
with no code change and no downtime.

<Warning>
  Do not hardcode a single key. That is what the deprecated header forces, and it is why rotating it
  breaks receivers.
</Warning>

### Example

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const JWKS_URL = "https://odyssey.asteroid.ai/.well-known/jwks.json";

  let keysByKid = new Map();

  async function publicKeyFor(kid) {
    if (!keysByKid.has(kid)) {
      const response = await fetch(JWKS_URL);
      if (!response.ok) {
        throw new Error(`JWKS fetch failed: ${response.status}`);
      }
      const { keys } = await response.json();
      keysByKid = new Map(
        keys.map((jwk) => [jwk.kid, crypto.createPublicKey({ key: jwk, format: "jwk" })]),
      );
    }
    return keysByKid.get(kid);
  }

  const app = express();

  app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
    try {
      const kid = req.header("X-Asteroid-Webhook-Key-Id");
      const signature = req.header("X-Asteroid-Webhook-Signature");
      if (!kid || !signature) {
        return res.status(401).json({ error: "Missing signature" });
      }

      const publicKey = await publicKeyFor(kid);
      if (!publicKey) {
        return res.status(401).json({ error: "Unknown signing key" });
      }

      const verifier = crypto.createVerify("sha256");
      verifier.update(req.body);
      verifier.end();
      if (!verifier.verify(publicKey, signature, "base64")) {
        return res.status(401).json({ error: "Invalid signature" });
      }

      const event = JSON.parse(req.body.toString("utf8"));
      console.log(event.info.event, event.info.execution_id ?? event.info.batch_id);

      res.status(200).json({ received: true });
    } catch {
      res.status(500).json({ error: "Verification failed" });
    }
  });

  app.listen(3000);
  ```

  ```python Python theme={null}
  import base64

  import requests
  from cryptography.exceptions import InvalidSignature
  from cryptography.hazmat.primitives import hashes
  from cryptography.hazmat.primitives.asymmetric import padding, rsa
  from flask import Flask, request

  JWKS_URL = "https://odyssey.asteroid.ai/.well-known/jwks.json"

  keys_by_kid = {}


  def public_key_for(kid):
      if kid not in keys_by_kid:
          jwks = requests.get(JWKS_URL, timeout=10).json()
          keys_by_kid.clear()
          keys_by_kid.update({jwk["kid"]: to_public_key(jwk) for jwk in jwks["keys"]})
      return keys_by_kid.get(kid)


  def to_public_key(jwk):
      def decode(value):
          padded = value + "=" * (-len(value) % 4)
          return int.from_bytes(base64.urlsafe_b64decode(padded), "big")

      return rsa.RSAPublicNumbers(decode(jwk["e"]), decode(jwk["n"])).public_key()


  app = Flask(__name__)


  @app.post("/webhook")
  def webhook():
      kid = request.headers.get("X-Asteroid-Webhook-Key-Id")
      signature = request.headers.get("X-Asteroid-Webhook-Signature")
      if not kid or not signature:
          return {"error": "Missing signature"}, 401

      public_key = public_key_for(kid)
      if public_key is None:
          return {"error": "Unknown signing key"}, 401

      try:
          public_key.verify(
              base64.b64decode(signature),
              request.get_data(),
              padding.PKCS1v15(),
              hashes.SHA256(),
          )
      except InvalidSignature:
          return {"error": "Invalid signature"}, 401

      event = request.get_json()
      info = event["info"]
      print(info["event"], info.get("execution_id") or info.get("batch_id"))

      return {"received": True}, 200
  ```
</CodeGroup>

### The legacy header

<Warning>
  `X-Asteroid-Signature` is deprecated. Move to `X-Asteroid-Webhook-Signature`.
</Warning>

`X-Asteroid-Signature` predates key ids. It names no key, so it is always signed with one fixed key.
That key cannot be rotated without breaking every receiver that pinned it.

Nothing breaks today. We still send the header on every delivery, and we will announce a removal date
well before we stop. Until then, treat it as read-only history:

* New integrations verify `X-Asteroid-Webhook-Signature` only.
* Existing integrations migrate, then stop reading `X-Asteroid-Signature`.

The `GET /integrations/webhook/public-key` endpoint needs a platform session. Its top-level
`publicKeyPem` and `keyId` describe this legacy key only. The same response also returns a `keys`
array with every signing key in the ring. Prefer the unauthenticated JWKS endpoint.

### Migrate off the legacy header

The payload, the algorithm, and the raw-bytes rule are unchanged. You are only changing which header
you read and where the key comes from.

<Steps>
  <Step title="Read the key id">
    Read `X-Asteroid-Webhook-Key-Id` from the request.
  </Step>

  <Step title="Look the key up">
    Fetch `https://odyssey.asteroid.ai/.well-known/jwks.json` and select the key with that `kid`.
    Cache the set, and refetch on a `kid` you do not hold.
  </Step>

  <Step title="Verify the new header">
    Verify `X-Asteroid-Webhook-Signature` against the raw body with that key. Your existing
    verification call stays the same, because the algorithm has not changed.
  </Step>

  <Step title="Drop the old header">
    Stop reading `X-Asteroid-Signature` and delete the pinned public key from your configuration.
  </Step>
</Steps>

Test the change with **Test integration** in the platform. It sends a real signed delivery. See
[Test the integration](#test-the-integration).

***

## Filter what you receive

Most teams want the failures, not the running commentary.

### Subscription modes

| Mode | Behaviour |
| - | - |
| **Subscribe to all** | Every event type, including any type added later. |
| **Custom rules** | The event types you pick, with optional filters on each. |

### Field filters

Add one or more field filters to an event type. Filters on one event type combine with **OR**: the
notification fires when any of them match.

Two filters — `outcome equals failure` and `outcome equals cancelled` — mean "tell me when the
outcome is either one".

### Metadata filters

A metadata filter matches the key-value pairs you attached when you started the execution. All pairs must
match, so the filter combines with **AND**.

| Run metadata | Filter | Result |
| - | - | - |
| `environment: production` | `environment: production` | Sent |
| `environment: staging` | `environment: production` | Skipped |
| `environment: production, region: us-east` | `environment: production` | Sent. Extra keys are ignored. |
| `environment: production` | `environment: production, team: payments` | Skipped. `team` is missing. |

Leave the metadata filter empty and the notification fires for every execution.

Metadata filtering runs after event-type filtering. An execution must match the event rules first, then the
metadata filter.

<Tip>
  Attach metadata on every execute call and one workflow can feed several channels. Send production
  failures to your on-call channel, and staging failures nowhere.
  See [Call a workflow from your code](/integrate/call-a-workflow).
</Tip>

### A worked example

One workflow, two environments, two webhooks.

| Integration | Events | Metadata filter |
| - | - | - |
| Production alerts | `EXECUTION_FAILED`, `EXECUTION_COMPLETED` | `environment = production` |
| Staging alerts | `EXECUTION_FAILED` | `environment = staging` |

A production failure fires the first. A staging failure fires the second. A staging run that
completes fires neither.

***

## Slack

The Slack integration posts the same events into a channel, formatted for people to read.

### Install it

You need a Slack workspace where you can install apps.

<Steps>
  <Step title="Add the integration">
    In the platform, go to **Integrations** → **Add Integration** → **Slack**. Slack asks you to
    authorise the app. Review the permissions, pick the workspace, and select **Allow**.
  </Step>

  <Step title="Set the default channel">
    Find the new Slack integration in the list and open its **Edit** dialog. Pick a **Default
    Channel** and save. The Asteroid bot joins the channel you pick.

    <Note>
      The dropdown lists public channels. To use a private channel, invite the Asteroid bot to it in
      Slack first. The channel then appears in the dropdown, after you refresh the page.
    </Note>
  </Step>

  <Step title="Attach it to a workflow">
    Open the workflow, go to the **Notifications** tab, and select **Add Notification**. Pick the Slack
    integration, set the event rules, and save.
  </Step>
</Steps>

### What a Slack message contains

* **Status** — a colour and an icon for the event type.
* **Context** — the workflow name, and a link to the execution in the platform.
* **Detail** — depends on the event:
  * `EXECUTION_COMPLETED` — a snippet of the result and the workflow's reasoning.
  * `EXECUTION_FAILED` — the error and the reason.
  * `EXECUTION_PAUSED` — the reason, such as the question the workflow asked.

Some messages carry a button. A human-in-the-loop request lets you open the execution straight from
Slack.

***

## Test the integration

Select **Test integration** in the platform. Asteroid sends an `EXECUTION_TEST` payload.

```json theme={null}
{
  "type": "execution",
  "event_id": "2ec89fb9-c2b5-4f6a-9fe9-c8f4f9dd6f66",
  "timestamp": "2026-03-27T10:30:00Z",
  "info": {
    "event": "EXECUTION_TEST",
    "execution_id": "00000000-0000-0000-0000-000000000003",
    "execution_url": "",
    "workflow_id": "00000000-0000-0000-0000-000000000001",
    "workflow_name": "Test workflow",
    "agent_id": "00000000-0000-0000-0000-000000000001",
    "agent_name": "Test workflow",
    "payload": {
      "message": "This is a test notification from Asteroid. If you're seeing this, your integration is working correctly! 🎉"
    }
  }
}
```

<Tip>
  Point a new webhook at [webhook.site](https://webhook.site) for the first smoke test. Then move to
  your real endpoint with signature verification switched on.
</Tip>

***

## Build a reliable endpoint

<AccordionGroup>
  <Accordion title="Answer with 2xx quickly">
    Return `2xx` as soon as you have the body. Do the work afterwards, in your own queue. A slow
    endpoint turns into a failed delivery.
  </Accordion>

  <Accordion title="Expect the same event twice">
    Delivery is at-least-once. Build your handler so a repeat is harmless.

    Dedupe on your own business identifiers. `event_id` is generated per delivery and changes across
    retries, so it cannot carry the whole job.
  </Accordion>

  <Accordion title="Know the retry budget">
    Asteroid makes up to three send attempts, with a short backoff, before it marks a delivery
    failed.
  </Accordion>

  <Accordion title="Sweep for what you missed">
    Run a periodic sweep with `GET /executions` alongside your webhook handler. It catches anything
    that arrived while your endpoint was down. See [Executions and statuses](/concepts/executions).
  </Accordion>

  <Accordion title="Log the context">
    Log `info.event`, `info.execution_id`, `info.workflow_id`, and your own outcome. That is enough to
    reconstruct any delivery later.
  </Accordion>
</AccordionGroup>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="No events arrive">
    * Check the endpoint is reachable from the internet and accepts `POST`.
    * Check the notification's event rules cover the events you expect.
    * Check the metadata filter values match the execution's metadata exactly.
  </Accordion>

  <Accordion title="Signature verification fails">
    * Verify against the raw request bytes, not parsed JSON.
    * Use RSA PKCS#1 v1.5 with SHA-256.
    * Check you picked the key whose `kid` matches `X-Asteroid-Webhook-Key-Id`.
    * Refetch the key set. A delivery naming a `kid` you do not hold means your cache is stale.
    * If you still verify the deprecated `X-Asteroid-Signature`, see
      [Migrate off the legacy header](#migrate-off-the-legacy-header).
  </Accordion>

  <Accordion title="The same event arrives twice">
    * Handle at-least-once delivery in your own logic.
    * Dedupe on stable execution fields, not on `event_id` alone.
    * Return `2xx` once you recognise a duplicate.
  </Accordion>

  <Accordion title="Slack messages go nowhere">
    * Check the integration has a default channel.
    * For a private channel, invite the Asteroid bot in Slack first.
    * Check the integration is attached to the workflow on its Notifications tab.
  </Accordion>
</AccordionGroup>

***

## Next

<CardGroup cols={2}>
  <Card title="Call a workflow from your code" icon="code" href="/integrate/call-a-workflow" horizontal>Attach the metadata your filters match on</Card>
  <Card title="Batch executions" icon="layers" href="/operate/batches" horizontal>Batch events and why webhooks beat polling</Card>
  <Card title="Executions and statuses" icon="activity" href="/concepts/executions" horizontal>The statuses behind each event</Card>
  <Card title="Debug your workflows" icon="bug" href="/operate/debug" horizontal>What to do when an event says the execution failed</Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.