Platform

Webhooks & events

Everything that happens to your project (an inbound message, a connection going live, a domain verifying) lands on one ordered event stream. You can pull from it (what listen() does under the hood) or have Caspian push each event to your own URL.

Most agents never touch this page: client.listen() already polls the stream and dispatches message.received to your on_message handler. Read on if you want a custom loop, a durable cursor across restarts, or push delivery instead of polling.

The event stream

Every event carries a strictly increasing seq, a monotonic cursor scoped to your project. You read events by asking for everything after a cursor you hold. Nothing is ever skipped or reordered, so persisting the last seq you handled is all you need for exactly-once processing across restarts.

GET /v1/events
GET /v1/events?after_seq=0&limit=100
Authorization: Bearer $COMM_API_KEY
Query paramDefaultMeaning
after_seq0Return events with seq greater than this. Pass the last seq you processed.
limit100Max events in the batch. Page by advancing after_seq to the last seq you got.
typeNoneOptional filter, e.g. message.received. Omit for all types.

The response is a JSON array ordered by seq. Each item is an event:

[
  {
    "seq": 148,
    "type": "message.received",
    "data": { ... }
  }
]

Reading events from the SDK

events() is the raw stream reader. Advance your own cursor to page through everything, or poll it on an interval for a custom loop.

poll.py
from caspian_sdk import CommClient

client = CommClient()

cursor = 0                                  # load this from your own store on restart
while True:
    batch = client.events(after_seq=cursor, limit=100)
    for event in batch:
        cursor = event["seq"]               # advance only after handling
        if event["type"] == "message.received":
            msg = event["data"]["message"]
            print(msg["channel"], msg["sender"], msg["text"])
    # persist `cursor` here, then sleep and poll again
poll.ts
import { CommClient } from "caspian-sdk";

const client = new CommClient();

let cursor = 0;                                   // load from your own store on restart
for (;;) {
  const batch = await client.events({ afterSeq: cursor, limit: 100 });
  for (const event of batch) {
    cursor = event.seq;                           // advance only after handling
    if (event.type === "message.received") {
      const msg = event.data.message;
      console.log(msg.channel, msg.sender, msg.text);
    }
  }
  // persist `cursor` here, then sleep and poll again
}

Just want messages dispatched to a handler? Use listen(). It manages the cursor, dedupes within a run, shows a typing indicator, and never dies on a handler exception. Filter to one type with client.events(type="message.received") when you only care about inbound.

Event types

TypeFires when
message.receivedAn inbound message arrives on any connection. This is what on_message handles.
message.sentA reply or proactive message was delivered out to the channel.
connection.activeA connection finished provisioning and is live. Watch this instead of polling get_connection().
connection.authorizedAn OAuth channel (Slack, Discord install, X) completed its authorize flow.
domain.verifiedA custom email domain passed DNS verification and can send.

The message.received shape

The data object holds the tenant scope plus the message itself. This is exactly what the SDK unpacks into the Message your handler receives.

message.received: data
{
  "customer_id": "cus_9f...",
  "agent_id": "agt_2a...",
  "message": {
    "id": "msg_c3d1...",
    "conversation_id": "conv_71b8...",
    "connection_id": "conn_04a2...",
    "channel": "slack",
    "sender": "U08ABCDEF",
    "subject": null,
    "text": "hey, can you pull my last invoice?",
    "html": null
  }
}
FieldNotes
customer_id / agent_idThe tenant scope. Default scope when you didn't pass explicit ids to connect_*().
message.idStable message id. Pass it to reply() / typing().
message.conversation_idGroups messages into a thread. Key your agent's memory on this.
message.connection_idWhich connection (inbox, Slack app, bot) received it.
message.channelSource channel: email, slack, telegram, whatsapp, and so on.
message.senderChannel-native sender identifier (email address, Slack user id, phone number, …).
message.subject / message.htmlPopulated on channels that carry them (email); null elsewhere.

Push webhooks

Instead of (or alongside) polling, register a URL and Caspian POSTs each event to it as it happens. Set it once with set_webhook(). Pass a secret so you can verify that deliveries actually came from Caspian.

client.set_webhook(
    url="https://your-app.com/hooks/caspian",
    secret="whsec_your_shared_secret",
)

client.get_webhook()   # inspect the current registration
await client.setWebhook(
  "https://your-app.com/hooks/caspian",
  "whsec_your_shared_secret",
);

await client.getWebhook();   // inspect the current registration

Each delivery is an HTTP POST whose body is a single event, the same { seq, type, data } shape as a stream item. Respond 2xx to acknowledge.

!

Your endpoint must be reachable over HTTPS and answer quickly. The seq is still the source of truth for ordering and gaps. If your endpoint was down, re-read the stream with events(after_seq=cursor) to catch up on anything you missed. Push is a convenience, not a replacement for the cursor.

Choosing between them

Polling / listen()Push webhooks
SetupNone: just run your process.Register a public HTTPS URL.
Best forLong-running agents, scripts, local dev.Serverless / event-driven backends.
Ordering & recoveryBuilt in via the seq cursor.Still reconcile against seq after downtime.
LatencyPoll interval (default ~1s).Delivered as it happens.

Next steps

Messaging →
Replies, proactive sends, and threading.
Quickstart →
Install, get a key, run your first handler.