Cloudstation

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.

IntegrationUse response_webhook?Why
Telegram botYesTelegram receives the user message first; your bot must send the AI reply later.
WhatsApp botYesEvolution API sends inbound messages to your service; your service responds when the callback arrives.
Slack or Discord botYesMessaging platforms expect async processing.
CRM ticket automationUsuallyLet the callback post the reply/comment back when done.
n8n/Zapier/Make one-shot workflowOptionalUse it if the workflow has a public callback URL or wait/resume step.
Simple backend requestOptionalYou 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}/messages

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

FieldDescription
conversation_idThe external thread ID from your original request. Use it to route the reply back to the right chat/user.
adw_idOpaque thread reference string for tracing.
agent_idAgent/wrapper ID that produced the reply, when available.
message.contentAssistant response text to send back to the user.
message.sender_typeAlways agent for agent-produced replies.
message.event_categoryMessage classification. Final user-facing responses are message events.
message.event_typeMore specific event type.
message.timestampUnix timestamp in milliseconds.
delivery_idUnique delivery identifier. Use this for duplicate suppression.
message_idID of the assistant message being delivered. (In the send response, message_id is the deprecated mirror of conversation_uuid.)
project_idProject that produced the callback.
platformSource platform from the original conversation.
typeEvent 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_idULID of the underlying event in the durable event log.
sequencePer-conversation monotonically increasing sequence number.
api_versionVersion 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 POST with Content-Type: application/json.
  • Uses the per-conversation response_webhook first.
  • Falls back to the project-level webhook URL if configured.
  • Treats any 2xx status as delivered.
  • Never retries 4xx responses — they are treated as a permanent rejection.
  • Waits up to 10 seconds for your response; a slower response counts as a network failure.
  • Retries 5xx responses and network failures with growing delays: 1m, 5m, 30m, 2h, 8h, then every 8h, 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 2xx status.
  • Do slow platform calls after minimal validation.
  • Be idempotent because retries can happen.
  • Dedupe by delivery_id or event_id.
  • Return 4xx only for permanent errors.
  • Return 5xx only 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-e07fc1f90ae7

Signature format:

v1=hex(HMAC_SHA256(webhook_secret, timestamp + "." + raw_request_body))

Verification requirements:

  1. Read the raw request body before JSON parsing.
  2. Read X-CloudStation-Timestamp.
  3. Reject stale timestamps, commonly older than 5 minutes.
  4. Recompute HMAC-SHA256 using your webhook secret.
  5. Compare signatures with a timing-safe comparison.
  6. Dedupe X-CloudStation-Delivery-ID or body delivery_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:

  1. X-CloudStation-Delivery-ID header
  2. delivery_id body field
  3. event_id body field
  4. adw_id + conversation_id as 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:3000

If 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.com

A webhook URL must be an HTTP or HTTPS URL. file:// pages, private laptop ports, and firewalled local URLs cannot receive callbacks.

Troubleshooting

SymptomLikely causeFix
Agents API returns 401Missing or invalid API key/client IDCheck x-api-key and x-client-id.
Agents API returns 422Invalid response_webhook URLUse a full HTTP or HTTPS URL.
You get accepted but no callbackWebhook URL is unreachableCheck public URL, logs, firewall, and DNS.
Callback signature failsRaw body was not preservedConfigure your framework to keep raw request bytes.
Duplicate messages sent to userDelivery retried after timeout or 5xx, or run.completed sent as a replyDedupe by delivery_id and skip run.completed repeats.
Callback received but user sees nothingPlatform send failedLog platform API response and retry platform send safely.
Bot replies to wrong chatBad conversation_id mappingUse platform chat/user ID as conversation_id.

Next Steps