# Channel webhooks

> Everything that happens on your channels arrives as signed HTTP events. Register an endpoint once; consume idempotently.

## Endpoints

```bash
curl -s -X POST https://agentsky.dev/api/v1/channels/webhooks \
  -H "Authorization: Bearer $AST_TOKEN" -H 'content-type: application/json' \
  -d '{"url": "https://your.app/hook", "events": ["message.received", "channel.connected"]}'
# -> returns the whsec_ signing secret ONCE — store it
```

`events` defaults to `["message.received"]`. `GET /api/v1/channels/webhooks` lists endpoints; `DELETE /api/v1/channels/webhooks/{id}` removes one; `PATCH /api/v1/channels/webhooks/{id}` re-enables a failure-disabled endpoint (keeping its secret) or re-picks events. The URL must be https (http is allowed for localhost during development).

The [developer console](/developer/webhooks) manages the same endpoints — the signing secret is shown exactly once, at creation:

![The developer console's webhook endpoints page showing the one-time whsec_ signing secret](/guides/channels/webhooks/endpoint-secret.png)

## Delivery format

Every delivery is a POST with a JSON body `{id, type, createdAt, data}` and these headers:

| Header | Meaning |
|---|---|
| `X-Asteroids-Event` | the event type |
| `X-Asteroids-Delivery` | unique delivery id — your dedupe key |
| `X-Asteroids-Timestamp` | unix seconds, signed |
| `X-Asteroids-Signature` | `v1=<hex>` — HMAC-SHA256 over `{timestamp}.{rawBody}` with your `whsec_` secret |

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, timestamp, rawBody, signatureHeader) {
  const expected = "v1=" + createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}
```

Failed deliveries retry at 0s/5s/30s. After ~20 consecutive failures the endpoint is disabled and events stop until you re-create it. Retries reuse the delivery id — consume idempotently by `X-Asteroids-Delivery`.

## Event catalog

| Event | When |
|---|---|
| `message.received` | an inbound message on a webhook-bound conversation — carries `connection_id`, `thread_id`, `message_id`, `text`, `author`, and your connection `metadata` |
| `message.reaction` | an end user reacted to a message |
| `interaction.action` | a button/select was tapped (platform spinners are acknowledged automatically) |
| `command.received` | a slash command arrived on a connected surface |
| `channel.connected` | a connect link was claimed — carries your `metadata` |
| `channel.needs_reauth` | the platform surface needs re-authorization |
| `channel.disconnected` | the surface was disconnected |
| `channel.unmapped_conversation` | a message arrived on a connected surface with no active binding — bind or reply via the threads API |
| `turn.refused` | a session-bound turn was refused (for example, out of credits) |
| `delivery.failed` | a proactive delivery could not be posted |

## Local development: the SSE mirror

```bash
curl -N https://agentsky.dev/api/v1/channels/events/stream \
  -H "Authorization: Bearer $AST_TOKEN"
```

`GET /api/v1/channels/events/stream` mirrors your webhook events as server-sent events — same payloads, no public URL or tunnel needed while developing. The [developer console's Events tab](/developer/events) renders the same stream live:

![The developer console's Events tab streaming channel events live](/guides/channels/events/live-stream.png)
