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?after_seq=0&limit=100
Authorization: Bearer $COMM_API_KEY| Query param | Default | Meaning |
|---|---|---|
after_seq | 0 | Return events with seq greater than this. Pass the last seq you processed. |
limit | 100 | Max events in the batch. Page by advancing after_seq to the last seq you got. |
type | None | Optional 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.
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 againimport { 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
| Type | Fires when |
|---|---|
message.received | An inbound message arrives on any connection. This is what on_message handles. |
message.sent | A reply or proactive message was delivered out to the channel. |
connection.active | A connection finished provisioning and is live. Watch this instead of polling get_connection(). |
connection.authorized | An OAuth channel (Slack, Discord install, X) completed its authorize flow. |
domain.verified | A 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.
{
"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
}
}| Field | Notes |
|---|---|
customer_id / agent_id | The tenant scope. Default scope when you didn't pass explicit ids to connect_*(). |
message.id | Stable message id. Pass it to reply() / typing(). |
message.conversation_id | Groups messages into a thread. Key your agent's memory on this. |
message.connection_id | Which connection (inbox, Slack app, bot) received it. |
message.channel | Source channel: email, slack, telegram, whatsapp, and so on. |
message.sender | Channel-native sender identifier (email address, Slack user id, phone number, …). |
message.subject / message.html | Populated 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 registrationawait client.setWebhook(
"https://your-app.com/hooks/caspian",
"whsec_your_shared_secret",
);
await client.getWebhook(); // inspect the current registrationEach 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 | |
|---|---|---|
| Setup | None: just run your process. | Register a public HTTPS URL. |
| Best for | Long-running agents, scripts, local dev. | Serverless / event-driven backends. |
| Ordering & recovery | Built in via the seq cursor. | Still reconcile against seq after downtime. |
| Latency | Poll interval (default ~1s). | Delivered as it happens. |