Agents API Webhooks
Receive asynchronous agent replies using response_webhook callbacks, with the full delivery, retry, and signature verification contract.
Agents API webhooks let your application receive the assistant's reply when it is ready. This is the recommended pattern for messaging bots and external systems where the user expects a reply in Telegram, WhatsApp, Slack, email, a CRM, or another channel.
Instead of keeping an HTTP request open or polling repeatedly, your service sends a message with response_webhook. The API immediately accepts the request, processes the message in the background, then POSTs each assistant reply, and a run.completed event when the task ends, to your webhook URL.
When to Use response_webhook
Use webhooks when your integration is event-driven.
| Integration | Use response_webhook? | Why |
|---|---|---|
| Telegram bot | Yes | Telegram receives the user message first; your bot must send the AI reply later. |
| WhatsApp bot | Yes | Evolution API sends inbound messages to your service; your service responds when the callback arrives. |
| Slack or Discord bot | Yes | Messaging platforms expect async processing. |
| CRM ticket automation | Usually | Let the callback post the reply/comment back when done. |
| n8n/Zapier/Make one-shot workflow | Optional | Use it if the workflow has a public callback URL or wait/resume step. |
| Simple backend request | Optional | You can just inspect the returned task_id if no direct reply is needed. |
End-to-End Flow
External user sends a message
A user sends a message to your Telegram bot, WhatsApp number, Slack app, or custom channel.
Your integration calls the Agents API
Your backend sends the user message to:
POST /v1/projects/{project_id}/conversations/{conversation_id}/messagesThe request includes message, platform, and response_webhook.
The API accepts immediately
The response returns status: "accepted", an adw_id, the conversation_id, and a task_id.
The agent processes the message
The message is routed to the selected agent and becomes a task. Track its progress with Tasks & Events.
The API calls your webhook
When the assistant reply is ready, a POST is sent to your response_webhook URL.
Your integration replies to the user
Your service verifies the callback, extracts message.content, and sends it back to the original platform.
Request Example
curl -X POST 'https://charlie-back.cloud-station.io/v1/projects/PROJECT_ID/conversations/telegram-123456789/messages' \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'x-client-id: YOUR_CLIENT_ID' \
-d '{
"message": "Can you help me understand pricing?",
"platform": "telegram",
"response_webhook": "https://your-bot.example.com/cloudstation-webhook",
"sender": {
"id": "123456789",
"name": "Jane Customer"
},
"external_message_id": "telegram:123456789:42"
}'Immediate response:
{
"status": "accepted",
"adw_id": "abc12345",
"conversation_id": "telegram-123456789",
"conversation_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"message_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"task_id": "b3f1c9a2-4e5d-4f6a-8b7c-9d0e1f2a3b4c"
}Store or log adw_id and task_id for debugging. message_id is deprecated — it mirrors conversation_uuid and is kept only for backward compatibility. Your user-facing reply will arrive later at response_webhook.
Callback Payload
The API posts this JSON body to your response_webhook URL:
{
"conversation_id": "telegram-123456789",
"adw_id": "abc12345",
"agent_id": "agent-uuid-or-wrapper-id",
"message": {
"content": "Yes — here is how pricing works...",
"sender_type": "agent",
"event_category": "response",
"event_type": "assistant",
"timestamp": 1783407290000
},
"delivery_id": "abc12345:7c9e6679-7425-40de-944b-e07fc1f90ae7",
"message_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"project_id": "prj_6efcfced-a176-4e4c-a989-9767c16fddf7",
"platform": "telegram",
"type": "message.assistant",
"event_id": "01J8X9K3QYRZTS9V0N7M6P5C2A",
"sequence": 42,
"api_version": "2026-07-01"
}Important fields:
| Field | Description |
|---|---|
conversation_id | The external thread ID from your original request. Use it to route the reply back to the right chat/user. |
adw_id | Opaque thread reference string for tracing. |
agent_id | Agent/wrapper ID that produced the reply, when available. |
message.content | Assistant response text to send back to the user. |
message.sender_type | Always agent for agent-produced replies. |
message.event_category | Message classification. Final user-facing responses are message events. |
message.event_type | More specific event type. |
message.timestamp | Unix timestamp in milliseconds. |
delivery_id | Unique delivery identifier. Use this for duplicate suppression. |
message_id | ID of the assistant message being delivered. (In the send response, message_id is the deprecated mirror of conversation_uuid.) |
project_id | Project that produced the callback. |
platform | Source platform from the original conversation. |
type | Event type, matching the durable event log: message.assistant for each assistant reply, run.completed when the task ends (it repeats the final reply with the same message_id and carries task_id). |
event_id | ULID of the underlying event in the durable event log. |
sequence | Per-conversation monotonically increasing sequence number. |
api_version | Version of the payload shape. |
Your webhook should focus on user-facing assistant messages. Ignore empty message.content values, skip a run.completed whose message_id you already sent (or skip run.completed entirely if you send every message.assistant), and safely handle unknown future fields.
Webhook Delivery Behavior
Delivery behavior:
- Sends
POSTwithContent-Type: application/json. - Uses the per-conversation
response_webhookfirst. - Falls back to the project-level webhook URL if configured.
- Treats any
2xxstatus as delivered. - Never retries
4xxresponses — they are treated as a permanent rejection. - Waits up to 10 seconds for your response; a slower response counts as a network failure.
- Retries
5xxresponses and network failures with growing delays:1m, 5m, 30m, 2h, 8h, then every8h, up to 8 attempts total, then dead-letters the delivery.
The event log (/api/events, see Tasks & Events) is the source of truth. Reconcile from it if a webhook delivery is ever missed or dead-lettered.
Your webhook should:
- Respond quickly with a
2xxstatus. - Do slow platform calls after minimal validation.
- Be idempotent because retries can happen.
- Dedupe by
delivery_idorevent_id. - Return
4xxonly for permanent errors. - Return
5xxonly when a retry may help.
Callback Signatures
Callbacks are signed when a project webhook secret is configured.
Signed callbacks include these headers:
X-CloudStation-Timestamp: 1778669193
X-CloudStation-Signature: v1=<hex-hmac-sha256>
X-CloudStation-Delivery-ID: abc12345:7c9e6679-7425-40de-944b-e07fc1f90ae7Signature format:
v1=hex(HMAC_SHA256(webhook_secret, timestamp + "." + raw_request_body))Verification requirements:
- Read the raw request body before JSON parsing.
- Read
X-CloudStation-Timestamp. - Reject stale timestamps, commonly older than 5 minutes.
- Recompute HMAC-SHA256 using your webhook secret.
- Compare signatures with a timing-safe comparison.
- Dedupe
X-CloudStation-Delivery-IDor bodydelivery_id.
Signature verification must use the raw HTTP body. If your framework parses JSON before you compute the HMAC, the bytes may change and signature verification can fail.
Signature Verification: Node/Express
This is the pattern used by the Telegram and WhatsApp bot boilerplates.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.CLOUDSTATION_WEBHOOK_SECRET;
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
type RawBodyRequest = express.Request & { rawBody?: Buffer };
app.use(
express.json({
verify: (req: RawBodyRequest, _res, buf) => {
req.rawBody = Buffer.from(buf);
},
}),
);
function verifyCloudStationSignature(req: RawBodyRequest): boolean {
if (!WEBHOOK_SECRET) return true;
const timestamp = req.get('X-CloudStation-Timestamp');
const signature = req.get('X-CloudStation-Signature');
const rawBody = req.rawBody;
if (!timestamp || !signature || !rawBody) return false;
if (!signature.startsWith('v1=')) return false;
const timestampSeconds = Number(timestamp);
if (!Number.isFinite(timestampSeconds)) return false;
const ageMs = Math.abs(Date.now() - timestampSeconds * 1000);
if (ageMs > SIGNATURE_TOLERANCE_MS) return false;
const expectedHex = createHmac('sha256', WEBHOOK_SECRET)
.update(timestamp)
.update('.')
.update(rawBody)
.digest('hex');
const receivedHex = signature.slice(3);
if (!/^[0-9a-fA-F]{64}$/.test(receivedHex)) return false;
const expected = Buffer.from(expectedHex, 'hex');
const received = Buffer.from(receivedHex, 'hex');
return expected.length === received.length && timingSafeEqual(expected, received);
}
app.post('/cloudstation-webhook', async (req: RawBodyRequest, res) => {
if (!verifyCloudStationSignature(req)) {
return res.sendStatus(403);
}
// Acknowledge quickly. Do idempotency checks before sending to platforms.
res.sendStatus(200);
const payload = req.body;
if (payload?.type === 'run.completed') return; // repeats the last message.assistant
const content = payload?.message?.content;
if (!content || typeof content !== 'string') return;
// Send `content` back to Telegram, WhatsApp, Slack, your CRM, etc.
});Signature Verification: Python/FastAPI
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = 'your-webhook-secret'
SIGNATURE_TOLERANCE_SECONDS = 5 * 60
@app.post('/cloudstation-webhook')
async def cloudstation_webhook(request: Request):
raw_body = await request.body()
timestamp = request.headers.get('X-CloudStation-Timestamp')
signature = request.headers.get('X-CloudStation-Signature')
if WEBHOOK_SECRET:
if not timestamp or not signature or not signature.startswith('v1='):
raise HTTPException(status_code=401, detail='Missing signature')
timestamp_seconds = int(timestamp)
if abs(time.time() - timestamp_seconds) > SIGNATURE_TOLERANCE_SECONDS:
raise HTTPException(status_code=403, detail='Stale signature')
expected = hmac.new(
WEBHOOK_SECRET.encode(),
timestamp.encode() + b'.' + raw_body,
hashlib.sha256,
).hexdigest()
received = signature[3:]
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=403, detail='Invalid signature')
payload = await request.json()
content = payload.get('message', {}).get('content')
if content and payload.get('type') != 'run.completed':
# Send content back to your external platform.
pass
return {'ok': True}Idempotency and Duplicate Suppression
Webhook callbacks may be retried. Your handler must be safe to run more than once.
Recommended dedupe key priority:
X-CloudStation-Delivery-IDheaderdelivery_idbody fieldevent_idbody fieldadw_id + conversation_idas fallback
In memory dedupe is enough for local development. Production bots should use a database, Redis, or platform message store.
Simple in-memory pattern:
const seen = new Map<string, number>();
const DEDUPE_TTL_MS = 5 * 60 * 1000;
function isDuplicate(id: string): boolean {
const now = Date.now();
for (const [key, expiresAt] of seen) {
if (expiresAt <= now) seen.delete(key);
}
const existing = seen.get(id);
if (existing && existing > now) return true;
seen.set(id, now + DEDUPE_TTL_MS);
return false;
}Minimal Bot Client Pattern
This is the core Agents API pattern used by the Telegram and WhatsApp boilerplates. The boilerplates send the same body, plus conversation_id, to the legacy POST /v1/projects/{project_id}/api/message route; both routes behave the same.
class CloudStationAgentClient {
constructor(
private config: {
apiUrl: string;
projectId: string;
apiKey: string;
clientId: string;
webhookBaseUrl: string;
agentTemplateId?: string;
},
) {}
async sendMessage(input: {
conversationId: string;
message: string;
platform: string;
sender?: { id: string; name: string };
externalMessageId?: string;
contentBlocks?: unknown[];
}) {
const body: Record<string, unknown> = {
message: input.message,
platform: input.platform,
response_webhook: `${this.config.webhookBaseUrl.replace(/\/+$/, '')}/cloudstation-webhook`,
};
if (this.config.agentTemplateId) body.agent_template_id = this.config.agentTemplateId;
if (input.sender) body.sender = input.sender;
if (input.externalMessageId) body.external_message_id = input.externalMessageId;
if (input.contentBlocks?.length) body.content_blocks = input.contentBlocks;
const response = await fetch(
`${this.config.apiUrl.replace(/\/+$/, '')}/v1/projects/${this.config.projectId}/conversations/${input.conversationId}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.config.apiKey,
'x-client-id': this.config.clientId,
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
throw new Error(`Agents API error (${response.status}): ${await response.text()}`);
}
return response.json();
}
}Local Development
For local testing, your response_webhook must be reachable from wherever the request is processed.
If you are running everything on your machine, this can work:
WEBHOOK_BASE_URL=http://localhost:3000If you are calling a hosted environment, localhost points to the remote server, not your laptop. Use a public tunnel or deploy the bot:
WEBHOOK_BASE_URL=https://your-bot.example.comA webhook URL must be an HTTP or HTTPS URL. file:// pages, private laptop ports, and firewalled local URLs cannot receive callbacks.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Agents API returns 401 | Missing or invalid API key/client ID | Check x-api-key and x-client-id. |
Agents API returns 422 | Invalid response_webhook URL | Use a full HTTP or HTTPS URL. |
You get accepted but no callback | Webhook URL is unreachable | Check public URL, logs, firewall, and DNS. |
| Callback signature fails | Raw body was not preserved | Configure your framework to keep raw request bytes. |
| Duplicate messages sent to user | Delivery retried after timeout or 5xx, or run.completed sent as a reply | Dedupe by delivery_id and skip run.completed repeats. |
| Callback received but user sees nothing | Platform send failed | Log platform API response and retry platform send safely. |
| Bot replies to wrong chat | Bad conversation_id mapping | Use platform chat/user ID as conversation_id. |