Documentation / API
Webhooks
Let calmo.cloud call your own endpoint when something happens in a coding-agent session: what it sends, how to verify it, and how to act on it through the API.
On the Webhook page, team admins can point calmo.cloud at an endpoint of their own. Each webhook listens to one source. With the source Agent sessions, your endpoint gets a signed request whenever something happens in a coding-agent session: its status changes, the agent needs a decision, its pull request changes, it shares files, or it cannot start. A script, a ticket system or a chat bot of your own can then react — and act on the session through the REST API.
This page covers the Agent sessions source. It is offered once coding agents are enabled for your team. The page's other sources send a request when a record of that type (a server, an Odoo service, an uptime monitor or an SSH key) is created, updated, deleted or restored; they are not described here.
Coding-agent events are not sent to notification destinations, so they never end up in Slack or Microsoft Teams by themselves. Whoever started a session in the panel is also told in the panel's notification bell when the agent needs a decision or a reply, and when the session fails or cannot start — that needs no webhook.
Setting up a webhook #
Only team owners and admins can see and change webhooks.
- Open Developer > Webhook in the sidebar. The Webhooks button on the list of agent sessions leads there too.
- Click Add New Webhook and fill in:
- Name and Description — for you, to recognise the webhook later.
- Url to Notify — the address of your endpoint. It has to start with
https://and be reachable from the internet (see Addresses calmo.cloud sends to). - Model — choose Agent sessions.
- Header — optional headers of your own that every request carries, for example an API key your endpoint expects. See The request for the names you cannot use.
- Events — at least one of the events below.
- Secret — the secret the requests are signed with; see Choosing a secret.
- Click Create.
For Agent sessions, the fields Method, Data option and Verify SSL? are not shown: every request is a POST with the JSON body described below, and the TLS certificate of your endpoint is always verified.
A team can have up to 10 webhooks with the source Agent sessions. To add another, delete one first. Webhooks with other sources do not count towards this limit.
A webhook cannot be edited. To change its address, events or secret, delete it and create a new one. View shows its settings, Logs lists its delivery attempts (see Logs), and Send test event sends it a test request (see Send test event).
Events #
| Event | In the body | Sent when |
|---|---|---|
| Status changed | status_changed |
A session starts, works, waits for a reply, ends, fails or is cancelled |
| Decision requested | decision_requested |
The agent asks for permission to use a tool — for example to push its work to GitHub — or asks a question |
| Decision resolved | decision_resolved |
A permission is allowed or denied, a question is answered, or a request expires or is withdrawn |
| Start failed | start_failed |
The sandbox or the agent of a session could not be started; the session stays pending |
| Pull request changed | pull_request_changed |
The session's pull request is opened, reopened, closed or merged |
| Files shared | files_shared |
The agent hands files back |
Each fact is sent as exactly one event, so subscribing to several never announces the same thing twice. Status changed is sent for every status; if you only care about some, look at data.to and ignore the rest.
Which sessions a webhook hears about #
A webhook hears about the sessions on the Odoos your team hosts: your team's own sessions, and the sessions a partner team starts on an Odoo you share with it. Sessions your team starts on an Odoo that another team shares with you go to that team's webhooks, not to yours — the person who started them still gets the bell in the panel.
The request #
Every event is a POST with a JSON body and these headers:
| Header | Content |
|---|---|
Content-Type |
application/json |
User-Agent |
calmo.cloud-Webhooks/1 |
Signature |
The HMAC-SHA256 of the raw body, in hexadecimal, keyed with the webhook's secret — see Verifying signatures |
webhook-id |
The ID of the event, the same as id in the body. It stays the same on every retry. |
webhook-timestamp |
When this attempt was sent, in Unix seconds. Every attempt gets a new one. |
webhook-signature |
v1, followed by a Base64 signature following the Standard Webhooks specification |
Your own headers from Header are sent as well. They cannot replace calmo.cloud's: the names Host, Content-Type, Content-Length, Transfer-Encoding, Connection, User-Agent, Signature and every name starting with webhook- are refused (in any spelling), and so are names that are not valid header names and values containing a control character such as a line break.
Answer with any 2xx status within 10 seconds (calmo.cloud waits 3 seconds for the connection). What your endpoint sends back is neither read nor stored — only the status counts. If your processing takes longer, accept the request first and do the work afterwards, for example in a queue.
The body #
The body is a JSON object with these keys:
| Key | Type | Content |
|---|---|---|
id |
string | The ID of the event. It is the same on every retry and at every webhook of your team — use it to recognise repeats. |
module |
string | Always AgentSession |
event |
string | The event, e.g. decision_requested. A test event uses webhook.test. |
version |
integer | Version of the body format, currently 1 |
occurred_at |
string | When it happened, ISO 8601 in UTC with microseconds |
team |
object | {uuid, slug} of your team |
agent_session |
object or null | The session as it stood when calmo.cloud prepared the request (see below); null only in a test event |
timeline_event |
object or null | {seq, type, created_at} of the event in the session's timeline behind this one, or null when there is none — for example for a pull request change, a start failure or an expired decision |
data |
object | What happened. It is different for every event, see Events in detail. |
links |
object | panel: the session in the panel. api: the API calls for this session, see Acting through the API. |
content_omitted |
boolean | true when parts of data were left out to keep the body under 64 KiB, see Size limit |
A complete example — the agent finished its turn and waits for a reply:
{
"id": "01997f2a-6c3e-7d41-b2a9-4e5f6a7b8c9d",
"module": "AgentSession",
"event": "status_changed",
"version": 1,
"occurred_at": "2026-09-25T14:03:12.482913Z",
"team": {
"uuid": "9d3f6c2a-4b1e-4f7a-8c2d-5e6f7a8b9c0d",
"slug": "acme"
},
"agent_session": {
"uuid": "5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"title": "Fix invoice rounding",
"status": "waiting_for_input",
"is_terminal": false,
"is_resumable": false,
"model": "claude-sonnet-5",
"permission_mode": "default",
"branch": "18.0",
"work_branch": "agent/5b0e9c7d",
"change_status": "unpushed",
"pull_request": null,
"repository": {
"uuid": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
"full_name": "acme/odoo-addons"
},
"source_service": {
"uuid": "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
"name": "ACME Live"
},
"started_via": "panel",
"usage": {
"tokens_in": 184230,
"tokens_out": 9412,
"cost_usd": "0.6939"
},
"last_event_seq": 214,
"expires_at": "2026-09-26T13:41:05Z"
},
"timeline_event": {
"seq": 214,
"type": "session.status",
"created_at": "2026-09-25T14:03:12.000000Z"
},
"data": {
"from": "running",
"to": "waiting_for_input",
"cause": "runner",
"stop_reason": "end_turn",
"last_error": null,
"error": null
},
"links": {
"panel": "https://calmo.cloud/admin/acme/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"api": {
"session": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"events": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/events?after=213",
"messages": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/messages",
"cancel": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/cancel"
}
},
"content_omitted": false
}
The examples on this page are indented for reading. The body that arrives is compact JSON on one line — verify signatures on those raw bytes, never on JSON you encoded again.
The agent_session snapshot:
| Key | Content |
|---|---|
uuid, title |
The session. title can be null. |
status |
pending, running, waiting_for_input, completed, failed or cancelled — see What the statuses mean |
is_terminal |
true for completed, failed and cancelled |
is_resumable |
true when a message would continue the session: it is completed, or it failed and its sandbox still exists |
model, permission_mode |
The model the agent uses and its permission mode (default, acceptEdits, auto or plan) |
branch, work_branch |
The branch the agent started from and the branch it commits to |
change_status |
Where the agent's changes are: unknown, no_changes, unpushed, pushed, pr_open, pr_closed or merged |
pull_request |
{number, state, url, merged_at} once there is one, else null. state is open, closed or merged. |
repository |
{uuid, full_name} of the repository the agent works on |
source_service |
{uuid, name} of the Odoo the session was started from |
started_via |
panel or api |
usage |
{tokens_in, tokens_out, cost_usd} so far; cost_usd is a decimal string |
last_event_seq |
The seq of the newest event in the session's timeline when the request was prepared, or null before its first event. Not every change adds an event, so several requests can carry the same value — see Retries, duplicates and order. |
expires_at |
Until when the agent's current run may report back, or null |
Everything in the body is identified by UUID. It never contains an internal numeric ID, apart from the panel address of an Odoo service in links.panel for a partner's session.
New keys can be added at any time. Ignore the ones you do not know, and answer events you do not handle with 2xx as well.
Events in detail #
The examples below show event, timeline_event, data and — where they differ — links. The other keys are always there as in the complete example above.
Status changed #
status_changed — the session's status changed.
data key |
Content |
|---|---|
from |
The previous status, null when the session was just created |
to |
The new status |
cause |
Why: created, runner (the agent reported it), auto_resume (the agent finished while a message was still unanswered, so calmo.cloud started it again right away), user_reply (someone replied while it was waiting for input), resume (a message continued a completed or failed session), cancel, runner_lost (the agent stopped without reporting back), sandbox_lost (the session's sandbox was deleted), or null when unknown |
stop_reason |
Why the agent's turn ended, when the agent reported it: end_turn (the agent answered), max_turns (it reached its step limit — reply to let it continue) or interrupted. Otherwise null. |
last_error |
The error calmo.cloud had recorded for the session at that moment, or null |
error |
For to: failed: {message, source} when calmo.cloud knows what went wrong — source is runner when the agent reported the error and control_plane when calmo.cloud found the problem itself. Otherwise null. |
A session whose agent stopped without reporting back:
{
"event": "status_changed",
"timeline_event": null,
"data": {
"from": "running",
"to": "failed",
"cause": "runner_lost",
"stop_reason": null,
"last_error": "The agent stopped unexpectedly. Send a message to continue this session.",
"error": {
"message": "The agent stopped unexpectedly. Send a message to continue this session.",
"source": "control_plane"
}
}
}
Decision requested #
decision_requested — the agent waits for a decision: a permission to use a tool, or an answer to a question. data.kind says which. links.api additionally has resolve for a permission and answer for a question.
For a permission:
data key |
Content |
|---|---|
kind |
permission |
uuid |
The permission request; use it to resolve it |
request_id |
The agent's own ID for the request |
tool_name |
The tool the agent wants to use, e.g. Bash, Edit or WebFetch |
summary |
A short, one-line description of the call: for a shell command the description the agent gave it, or the command itself; for file tools the path; for web tools the address or the search |
gate |
push when the agent wants to push its work to GitHub, tool for any other permission request, null when the agent did not say |
{
"event": "decision_requested",
"timeline_event": {
"seq": 187,
"type": "permission.request",
"created_at": "2026-09-25T13:58:40.000000Z"
},
"data": {
"kind": "permission",
"uuid": "3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f",
"request_id": "b7c8d9e0-f1a2-4b3c-9d4e-5f6a7b8c9d0e",
"tool_name": "Bash",
"summary": "Push the rounding fix to GitHub",
"gate": "push"
},
"links": {
"panel": "https://calmo.cloud/admin/acme/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"api": {
"session": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"events": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/events?after=186",
"messages": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/messages",
"cancel": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/cancel",
"resolve": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/permissions/3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f"
}
}
}
The body never contains the tool's full input — the complete command, the file contents or the edit. You find it in the permission.request event, which is the first event links.api.events returns.
For a question:
data key |
Content |
|---|---|
kind |
question |
uuid |
The question; use it to answer it |
request_id |
The agent's own ID for the question |
questions |
Up to 10 questions, each with question, header, multi_select and up to 20 options of {label, description}. Long texts are shortened. |
{
"event": "decision_requested",
"timeline_event": {
"seq": 202,
"type": "question.request",
"created_at": "2026-09-25T14:10:03.000000Z"
},
"data": {
"kind": "question",
"uuid": "8a9b0c1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d",
"request_id": "e2f3a4b5-c6d7-4e8f-9a0b-1c2d3e4f5a6b",
"questions": [
{
"question": "Which rounding should invoices use?",
"header": "Rounding",
"multi_select": false,
"options": [
{ "label": "Per line", "description": "Round every invoice line, then add them up" },
{ "label": "Globally", "description": "Add up the lines, then round the total once" }
]
}
]
},
"links": {
"panel": "https://calmo.cloud/admin/acme/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"api": {
"session": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c",
"events": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/events?after=201",
"messages": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/messages",
"cancel": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/cancel",
"answer": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/questions/8a9b0c1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d"
}
}
}
Every request is sent: a webhook gets each decision the agent asks for, however many there are.
Decision resolved #
decision_resolved — a permission request or question is closed. links.api has the same resolve or answer link as the request.
data key |
Content |
|---|---|
kind, uuid, request_id |
The request, as in Decision requested |
outcome |
allowed or denied (permission), answered (question), expired (the session ended or restarted before anyone decided), withdrawn (the agent stopped waiting) |
via |
panel or api when someone decided, else null |
{
"event": "decision_resolved",
"timeline_event": {
"seq": 191,
"type": "permission.resolved",
"created_at": "2026-09-25T13:59:02.000000Z"
},
"data": {
"kind": "permission",
"uuid": "3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f",
"request_id": "b7c8d9e0-f1a2-4b3c-9d4e-5f6a7b8c9d0e",
"outcome": "allowed",
"via": "api"
}
}
An expired request has no timeline_event.
Start failed #
start_failed — calmo.cloud could not start the session's sandbox or its agent. The session stays pending.
data key |
Content |
|---|---|
phase |
sandbox or runner |
message |
What went wrong, written by calmo.cloud |
{
"event": "start_failed",
"timeline_event": null,
"data": {
"phase": "runner",
"message": "Could not reach the server to start the agent runner."
}
}
Pull request changed #
pull_request_changed — the session's pull request on GitHub changed.
data key |
Content |
|---|---|
action |
opened, reopened, closed (without merging) or merged |
number, url |
The pull request |
previous_state |
open or closed before the change, null when calmo.cloud had not seen the pull request before |
{
"event": "pull_request_changed",
"timeline_event": null,
"data": {
"action": "merged",
"number": 57,
"url": "https://github.com/acme/odoo-addons/pull/57",
"previous_state": "open"
}
}
Files shared #
files_shared — the agent handed files back.
data key |
Content |
|---|---|
files |
Up to 50 files, each with uuid, name, mime_type, size (bytes) and download_url |
{
"event": "files_shared",
"timeline_event": {
"seq": 240,
"type": "assistant.files",
"created_at": "2026-09-25T14:21:47.000000Z"
},
"data": {
"files": [
{
"uuid": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a",
"name": "rounding-comparison.csv",
"mime_type": "text/csv",
"size": 18234,
"download_url": "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/attachments/d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a"
}
]
}
}
download_url needs an API token. The files are deleted together with the session's sandbox some time after the session ended, so fetch them soon.
Size limit #
A body stays under 64 KiB. If it would be larger, data.questions, then data.summary, then data.files are set to null until it fits, and content_omitted is true. The API still has everything.
Verifying signatures #
Every request carries two signatures, both made with the webhook's secret. Checking one of them is enough:
webhook-signaturefollows the Standard Webhooks specification. It also coverswebhook-idandwebhook-timestamp, so it lets you reject replayed requests. Use this one if you can.Signatureis the hexadecimal HMAC-SHA256 of the raw body, keyed with the secret exactly as you entered it. The page's other webhooks carry the same header, so an endpoint that already checks it for them works here too. It does not cover a timestamp.
Choosing a secret #
We recommend a secret in the Standard Webhooks format — whsec_ followed by Base64 of random bytes — so that the libraries of the specification accept it. You can create one with:
echo "whsec_$(openssl rand -base64 32)"
The key for webhook-signature depends on the secret:
- If the secret starts with
whsec_, the key is the rest of the secret, decoded from Base64. - Any other secret is itself the key, byte for byte (UTF-8). So is a secret that starts with
whsec_but whose rest is not valid Base64 — prefix included.
Signature is always keyed with the secret as it is, whsec_ included.
Checking webhook-signature #
- Take the raw body exactly as it arrived — before any JSON parsing.
- Put together
{webhook-id}.{webhook-timestamp}.{raw body}— the two header values and the body, separated by dots. - Calculate the HMAC-SHA256 of it with the key from above and encode the result in Base64.
- The
webhook-signatureheader holds one or more space-separated signatures of the formv1,<Base64>. Accept the request if one of them equals your result — compared in constant time. - Reject the request if
webhook-timestampis more than 5 minutes away from your own clock, so a recorded request cannot be replayed later. Every retry is signed again with a fresh timestamp, so retries pass this check.
Then use id to drop repeats (see Retries, duplicates and order).
PHP #
<?php
/**
* The key calmo.cloud signs `webhook-signature` with: the Base64-decoded rest
* of a `whsec_` secret, otherwise the secret itself.
*/
function calmoSigningKey(string $secret): string
{
if (str_starts_with($secret, 'whsec_')) {
$key = base64_decode(substr($secret, strlen('whsec_')), true);
if ($key !== false) {
return $key;
}
}
return $secret;
}
/**
* Standard Webhooks: webhook-id, webhook-timestamp and webhook-signature.
*
* @param array<string, string> $headers
*/
function verifyCalmoWebhook(string $payload, array $headers, string $secret, int $toleranceSeconds = 300): bool
{
$headers = array_change_key_case($headers, CASE_LOWER);
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false;
}
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$payload}", calmoSigningKey($secret), true));
foreach (explode(' ', $signatures) as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return true;
}
}
return false;
}
/**
* The Signature header: hex HMAC-SHA256 of the body, keyed with the secret as it is.
*/
function verifyCalmoSignatureHeader(string $payload, string $signature, string $secret): bool
{
return hash_equals(hash_hmac('sha256', $payload, $secret), $signature);
}
$payload = file_get_contents('php://input');
if (! verifyCalmoWebhook($payload, getallheaders(), getenv('CALMO_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($payload, true);
In Laravel, pass $request->getContent() as the payload and array_map(fn ($values) => $values[0], $request->headers->all()) as the headers.
Node.js #
import crypto from 'node:crypto';
import express from 'express';
// The key calmo.cloud signs `webhook-signature` with: the Base64-decoded rest
// of a `whsec_` secret, otherwise the secret itself. A `whsec_` secret whose
// rest is not valid Base64 is also used as it is, prefix included.
export function calmoSigningKey(secret) {
if (secret.startsWith('whsec_')) {
const encoded = secret.slice('whsec_'.length).replace(/[\t\n\r ]/g, '');
const data = encoded.replace(/=+$/, '');
const padding = encoded.length - data.length;
if (/^[A-Za-z0-9+/]*$/.test(data) && data.length % 4 !== 1 && (padding === 0 || (padding <= 2 && (data.length + padding) % 4 === 0))) {
return Buffer.from(data, 'base64');
}
}
return Buffer.from(secret, 'utf8');
}
function sameText(given, expected) {
const a = Buffer.from(given, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Standard Webhooks: webhook-id, webhook-timestamp and webhook-signature.
export function verifyCalmoWebhook(payload, headers, secret, toleranceSeconds = 300) {
const id = headers['webhook-id'] ?? '';
const timestamp = headers['webhook-timestamp'] ?? '';
const signatures = headers['webhook-signature'] ?? '';
if (id === '' || !/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const expected = crypto
.createHmac('sha256', calmoSigningKey(secret))
.update(`${id}.${timestamp}.`)
.update(payload) // the raw body, exactly as received
.digest('base64');
return signatures.split(' ').some((signature) => {
const [version, value = ''] = signature.split(',', 2);
return version === 'v1' && sameText(value, expected);
});
}
// The Signature header: hex HMAC-SHA256 of the body, keyed with the secret as it is.
export function verifyCalmoSignatureHeader(payload, signature, secret) {
const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(payload).digest('hex');
return sameText(signature ?? '', expected);
}
const app = express();
// express.raw() keeps the body as the exact bytes that were signed.
app.post('/webhooks/calmo', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyCalmoWebhook(req.body, req.headers, process.env.CALMO_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204);
// Process `event` here, after answering.
});
Retries, duplicates and order #
Retries. A delivery counts as successful when your endpoint answers with a 2xx status. When the endpoint cannot be reached, its host name cannot be looked up, the connection or TLS handshake fails, it takes longer than 10 seconds, or it answers with 408, 425, 429 or a 5xx status, calmo.cloud tries again after 10 seconds, 1 minute, 5 minutes and 30 minutes — five attempts over about 36 minutes. Any other answer ends the delivery right away: other 4xx statuses, redirects (calmo.cloud does not follow them — enter the final URL instead) and addresses calmo.cloud does not send to. Every attempt is listed in the webhook's Logs.
A delivery that is waiting for its next attempt is dropped when you delete the webhook in the meantime, or when coding agents are no longer available to your team.
Duplicates. Delivery is at least once: when your endpoint processed an event but answered too late, it gets the same event again. The id (and the webhook-id header) is the same on every retry and at every webhook of your team. Remember the IDs you have processed — for a day is plenty — and answer a repeat with 2xx without processing it again.
Order. Events are sent and retried independently of each other, so they can arrive in a different order than they happened: a retried running can arrive after the waiting_for_input that followed it. Do not rely on the order of arrival.
dataalways describes the change the event is about —fromandtofor a status change.agent_sessionis a snapshot taken when calmo.cloud prepared the request and may already be newer thandata; a retry sends the same snapshot again.last_event_seqgrows with every event in the session's timeline, but not every change adds one: when a pull request is closed, reopened or merged, a session is created or its start fails, for example,last_event_seqstays the same or isnull. To keep the newest snapshot, comparelast_event_seqfirst, countingnullas lower than any number, andoccurred_atwhen it is equal. Keep the snapshot that comes out ahead and ignore the others.timeline_event.seqplaces the event in the session's timeline.- When in doubt, ask the API for the current state with
links.api.session.
Logs #
Logs on a webhook's row opens Webhook Logs. For an Agent sessions webhook it lists one row per delivery attempt — retries and test events included:
| Column | Content |
|---|---|
| ID | The id of the event, so all attempts of one event share it |
| Status Code | The HTTP status your endpoint answered with, empty when there was no answer |
| Error Message | Empty when the attempt succeeded, otherwise a short reason: HTTP 500 (the status your endpoint answered with), Timed out, TLS error, Connection failed, DNS lookup failed, Redirect not followed, Blocked destination or Delivery failed |
| Error Type | failed, blocked (the address is not allowed, see below), or empty when the attempt succeeded |
| Attempts | Which attempt this was, from 1 to 5 |
What your endpoint answers is not stored — only its HTTP status.
Send test event #
Send test event on the row of an Agent sessions webhook sends it a test request right away — signed and sent exactly like every other request, but never retried. A notification then tells you what happened: the HTTP status your endpoint answered with and how long it took, or why the delivery failed. The attempt also shows up in the webhook's Logs. You can send up to five test events per minute and webhook.
The test event has the same shape, about no session:
{
"id": "01997f3b-0a1c-7e2d-9f3a-4b5c6d7e8f90",
"module": "AgentSession",
"event": "webhook.test",
"version": 1,
"occurred_at": "2026-09-25T16:20:05.118204Z",
"team": {
"uuid": "9d3f6c2a-4b1e-4f7a-8c2d-5e6f7a8b9c0d",
"slug": "acme"
},
"agent_session": null,
"timeline_event": null,
"data": {},
"links": {
"panel": "https://calmo.cloud/admin/acme/webhooks",
"api": null
},
"content_omitted": false
}
Answer it with 2xx like any other event. data is an empty object here, the same type as in every other event.
Addresses calmo.cloud sends to #
Webhooks are sent from calmo.cloud's own network, so your endpoint has to be a public address on the internet. The same rules apply as for notification destinations: the URL has to start with https:// and must not contain a user name or password, only ports 443, 8443 and 1024–65535 are allowed, and host names such as localhost or ones ending in .local or .internal, addresses in private or otherwise reserved networks, calmo.cloud itself and servers of another team are refused.
The page checks the URL when you create a webhook, whatever its source. For an Agent sessions webhook it is checked again before every delivery, and each delivery goes to exactly the address that was checked. A delivery to an address that is no longer allowed — for example because its host name now points into a private network — is not retried and shows up in the logs as blocked. A host name that cannot be looked up at delivery time — for example because a DNS server did not answer — is not blocked: the attempt fails with DNS lookup failed and is retried like an endpoint that cannot be reached.
What the statuses mean #
| Status | Meaning |
|---|---|
pending |
The session is starting: it was just created, a message continued it, or calmo.cloud restarted it by itself |
running |
The agent is working — or waits for a decision (see below) |
waiting_for_input |
The agent's turn is over and it waits for your message; stop_reason says why |
completed |
The agent waited for a message long enough that its run ended. Not final: a message continues the session with its full context. |
failed |
Something went wrong; data.error says what, when calmo.cloud knows. A failed session can be continued as long as its sandbox exists (is_resumable). |
cancelled |
Someone cancelled the session. This is final. |
Waiting for input is not a decision. waiting_for_input only says that the agent's turn ended. While the agent waits for a permission or an answer, the session stays running and you get decision_requested instead.
Completed is not the end. A completed session comes back as pending (cause: resume) as soon as someone writes to it. Use is_resumable to find out whether a message would continue a session.
A decision stays open until it is resolved. After a decision_requested, the agent waits until one decision_resolved with the same data.uuid closes the request: allowed, denied or answered when someone decided, expired when the session ended or restarted first, and withdrawn when the agent stopped waiting — for example because its turn was interrupted. Until then, the request can be answered in the panel or through the API; afterwards the API refuses it.
Acting through the API #
The links in links.api let a script or another tool react to an event. They need an API token of your team — team admins create them under Developer > API Tokens, and the REST API is included from the Starter plan — sent as Authorization: Bearer <token> together with Accept: application/json.
| Link | Method | What it does |
|---|---|---|
session |
GET |
The session as it is now |
events |
GET |
Up to 500 timeline events, oldest first — starting with the event behind this one, if there is one, and otherwise with whatever happens next. To read on, repeat the call with after set to the last seq you received. |
resolve |
POST |
Allow or deny a permission request: {"decision": "allow"} or {"decision": "deny"} |
answer |
POST |
Answer a question: {"answers": {"<question text>": "<label>"}} — a list of labels for a question that allows several — or a free-text {"response": "…"} |
messages |
POST |
Send the agent a message: {"content": "…"}. A session that waits for input continues right away; a completed one, or a failed one that is_resumable, starts again. |
cancel |
POST |
Cancel the session. A session that has already ended stays as it is. |
curl -X POST "https://calmo.cloud/api/agent-sessions/5b0e9c7d-2f4a-4c1e-9a8b-3d6e7f8a9b0c/permissions/3f4e5d6c-7b8a-4c9d-8e0f-1a2b3c4d5e6f" \
-H "Authorization: Bearer your-api-token" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"decision": "deny"}'
What the API answers when you resolve a permission or answer a question:
- 200 — the decision was recorded. If someone else decided first, you get their decision back unchanged; the response always shows the decision that counts.
- 409 — the agent no longer waits for this decision: it expired, was withdrawn, or the session ended.
- 422 — the request body is not valid, for example answers sent as a list instead of keyed by question text.
The keys of answers are the question texts. The texts in the webhook can be shortened, so take them from the question.request event (links.api.events) when a question is very long.
Security notes #
Check the signature before you trust anything. Verify every request as described above, and only then use its content or follow its links. Send your API token only to calmo.cloud's own address — never to a URL from a request you have not verified.
Text from the agent is untrusted. The session title, data.summary, data.questions, error messages and file names can contain text the agent wrote, and the agent reads code, web pages and Odoo data that someone may have written to steer it. Show such text as plain text, escape it for wherever you display it (HTML, Markdown, chat messages), and never run it, build commands or URLs from it, or follow links in it.
Never approve permission requests automatically. A permission request is the point where a person decides whether the agent may do something it cannot do on its own — such as pushing its work to GitHub (gate: push). A script that allows every request removes that safeguard. The summary is the agent's own description of the call and does not have to match what it will actually run. If you build approve and deny buttons into another tool, show the tool input from the permission.request event and leave the decision to a person.
Keep the secret secret. Store it like a password. If it has leaked, delete the webhook and create a new one with a new secret.
Sessions of a partner team #
When a partner team starts a session on an Odoo your team shares with it, the events go to your webhooks, not the partner's, and team is your team. They contain the session's title, the tools the agent asks for with their summaries, its questions and the names of shared files. You can follow what happens, but not act on it: links.panel opens your Odoo service instead of the session, links.api is null and so is every download_url.