Skip to content

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.

  1. Open Developer > Webhook in the sidebar. The Webhooks button on the list of agent sessions leads there too.
  2. 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.
  3. 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-signature follows the Standard Webhooks specification. It also covers webhook-id and webhook-timestamp, so it lets you reject replayed requests. Use this one if you can.
  • Signature is 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 #

  1. Take the raw body exactly as it arrived — before any JSON parsing.
  2. Put together {webhook-id}.{webhook-timestamp}.{raw body} — the two header values and the body, separated by dots.
  3. Calculate the HMAC-SHA256 of it with the key from above and encode the result in Base64.
  4. The webhook-signature header holds one or more space-separated signatures of the form v1,<Base64>. Accept the request if one of them equals your result — compared in constant time.
  5. Reject the request if webhook-timestamp is 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.

  • data always describes the change the event is about — from and to for a status change.
  • agent_session is a snapshot taken when calmo.cloud prepared the request and may already be newer than data; a retry sends the same snapshot again. last_event_seq grows 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_seq stays the same or is null. To keep the newest snapshot, compare last_event_seq first, counting null as lower than any number, and occurred_at when it is equal. Keep the snapshot that comes out ahead and ignore the others.
  • timeline_event.seq places 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.