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://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 it

events 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:

The developer console's webhook endpoints page showing the one-time whsec_ signing secret

Delivery format

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

HeaderMeaning
X-Asteroids-Eventthe event type
X-Asteroids-Deliveryunique delivery id — your dedupe key
X-Asteroids-Timestampunix seconds, signed
X-Asteroids-Signaturev1=<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

EventWhen
message.receivedan 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.reactionan end user reacted to a message
interaction.actiona button/select was tapped (platform spinners are acknowledged automatically)
command.receiveda slash command arrived on a connected surface
channel.connecteda connect link was claimed — carries your metadata
channel.needs_reauththe platform surface needs re-authorization
channel.disconnectedthe surface was disconnected
channel.unmapped_conversationa message arrived on a connected surface with no active binding — bind or reply via the threads API
turn.refuseda session-bound turn was refused (for example, out of credits)
delivery.faileda 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

bash
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:

The developer console's Events tab streaming channel events live