Channel webhooks
Everything that happens on your channels arrives as signed HTTP events. Register an endpoint once; consume idempotently.
Endpoints
curl -s -X POST https://api.agentsky.dev/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 itevents defaults to ["message.received"]. GET /v1/channels/webhooks lists endpoints; DELETE /v1/channels/webhooks/{id} removes one; PATCH /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 manages the same endpoints — the signing secret is shown exactly once, at creation:

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 |
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, app_id, thread_id, message_id, text, conversation, author, optional attachments, 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 |
The signed app_id comes from the registered connection, and conversation.is_dm comes from the platform SDK. Use these fields for app identity and private/group handling; user-supplied metadata is not an identity proof. Attachments contain file_id, type, name, mime_type and size_bytes, scoped through the Files API. A media-only message can have empty text. Inbound files total at most 32 MiB per message; paired WhatsApp accepts at most 20 MiB. A failed download produces retry guidance rather than forwarding an incomplete message.
Events remain in the connection's workspace, including disconnect notifications. Personal legacy connections and the owner's personal workspace share one scope. Workspace-scoped event streams cannot read another workspace; owner-wide API tokens retain their management view.
Local development: the SSE mirror
curl -N https://api.agentsky.dev/v1/channels/events/stream \
-H "Authorization: Bearer $AST_TOKEN"GET /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 renders the same stream live:

AgentSky