ConvoAI
Menu

Webhooks

Webhooks

ConvoAI sends an HTTP POST to your server when something happens: the agent replies, a conversation is resolved, an appointment is booked or moved. Register an endpoint once per agent and receive every event you subscribe to.

How it works#

When a subscribed event occurs, ConvoAI posts a JSON body describing it to each active webhook registered for that agent. Your server should answer with a 2xx status within 10 seconds.

  1. 01

    Event happens

    in ConvoAI

  2. 02

    Signed POST

    JSON + X-ConvoAI-Signature

  3. 03

    Your server

    verifies, answers 2xx

Webhooks can also be created from the dashboard, on your agent's Integrations page.

Registering a webhook#

Webhooks belong to an agent. An agent can have several, each subscribed to its own set of events.

  • POST/agents/{agent_id}/webhooks/Register a webhook

Request body

FieldTypeRequiredDescription
urlstringRequiredEndpoint that receives events. Must start with https:// and be publicly reachable; addresses on private networks or localhost are refused at delivery time.
eventsstring[]RequiredNon-empty list of event types to subscribe to. Names must match exactly; there is no wildcard.
secretstringOptionalKey used to sign payloads with HMAC-SHA256. If omitted, a 64-character hex secret is generated.
descriptionstringOptionalLabel for the webhook, returned as name. Defaults to “Webhook” plus today's date.
activebooleanOptionalDeliver events immediately. Default true.
bash
curl -X POST https://convoai.cloud/api/v1/agents/42/webhooks/ \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/convoai",
    "events": ["message.sent", "conversation.resolved", "appointment.created"],
    "description": "Production webhook"
  }'
json · 201 Created
{
  "id": 9,
  "agent_id": 42,
  "name": "Production webhook",
  "url": "https://your-server.com/webhooks/convoai",
  "events": ["message.sent", "conversation.resolved", "appointment.created"],
  "active": true,
  "created_at": "2026-05-11T14:30:00+00:00",
  "secret": "5f1c…"
}
Store the secret now. It is returned only in this response; reads of the webhook never include it.

Managing webhooks

  • GET/agents/{agent_id}/webhooks/List webhooks
  • GET/agents/{agent_id}/webhooks/{webhook_id}/Retrieve a webhook
  • PATCH/agents/{agent_id}/webhooks/{webhook_id}/Update url, events, active or description
  • DELETE/agents/{agent_id}/webhooks/{webhook_id}/Delete a webhook (204)
  • POST/agents/{agent_id}/webhooks/{webhook_id}/test/Send a test event

Event types#

Every delivery shares one envelope. The event name is also sent in the X-ConvoAI-Event header.

json · envelope
{
  "event": "appointment.created",
  "agent_id": 42,
  "data": { … }
}
EventSent whendata
message.sentThe AI agent sends a reply to a WhatsApp messageconversation_id, contact_phone, content, sent_by_ai, action, intent
conversation.resolvedA conversation is resolved from the dashboard inboxconversation_id
appointment.createdAn appointment is created in the dashboard agendaappointment_id, title, start_time, contact_phone
appointment.updatedAn appointment is rescheduled in the dashboardappointment_id, title, start_time, contact_phone

Payloads

json · message.sent
{
  "event": "message.sent",
  "agent_id": 42,
  "data": {
    "conversation_id": 1287,
    "contact_phone": "+15551234567",
    "content": "Of course! Could you share your order number?",
    "sent_by_ai": true,
    "action": "respond",
    "intent": "order_status"
  }
}
json · appointment.created / appointment.updated
{
  "event": "appointment.created",
  "agent_id": 42,
  "data": {
    "appointment_id": 77,
    "title": "New-patient exam",
    "start_time": "2026-05-15 10:00:00+00:00",
    "contact_phone": "+15551234567"
  }
}

Note that start_time in appointment events uses a space between date and time rather than T; parse it leniently.

json · conversation.resolved
{
  "event": "conversation.resolved",
  "agent_id": 42,
  "data": { "conversation_id": 1287 }
}
You can also subscribe to message.received, conversation.opened and contact.created. These names are accepted, but ConvoAI does not currently send deliveries for them.

Request headers#

HeaderValue
Content-Typeapplication/json
X-ConvoAI-EventThe event name, e.g. message.sent
X-ConvoAI-AgentThe agent ID
X-ConvoAI-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body. Present whenever the webhook has a secret.

Verifying signatures#

The signature is an HMAC-SHA256 hex digest of the raw request body, keyed with your webhook secret, prefixed with sha256=.

header
X-ConvoAI-Signature: sha256=7b3f1a8c2e9d4f0b6a5c8e1f2d4a7b9c3e6f0a2b5d8e1c4f7a0b3d6e9f2c5a1b
Verify before you trust the payload. Reject requests with a missing or wrong signature. Compute the HMAC over the exact bytes you received: parsing the JSON and serialising it again produces different bytes and a different signature.
python · django
import hashlib
import hmac
import json

from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

WEBHOOK_SECRET = "your-webhook-secret"


@csrf_exempt
@require_POST
def convoai_webhook(request):
    header = request.headers.get("X-ConvoAI-Signature", "")
    if not header.startswith("sha256="):
        return HttpResponseForbidden()

    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        request.body,  # the raw bytes, not a re-serialised dict
        hashlib.sha256,
    ).hexdigest()

    # compare_digest prevents timing attacks
    if not hmac.compare_digest(expected, header[len("sha256="):]):
        return HttpResponseForbidden()

    payload = json.loads(request.body)
    handle_event(request.headers.get("X-ConvoAI-Event"), payload)
    return HttpResponse(status=200)
node.js · express
const crypto = require("crypto");
const express = require("express");

const app = express();
const WEBHOOK_SECRET = process.env.CONVOAI_WEBHOOK_SECRET;

// express.raw() keeps the body as a Buffer so the signature can be checked
app.post("/webhooks/convoai", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-ConvoAI-Signature") || "";
  if (!header.startsWith("sha256=")) return res.status(403).send("Missing signature");

  const received = Buffer.from(header.slice(7), "hex");
  const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.body).digest();

  // timingSafeEqual throws on length mismatch, so check that first
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.status(403).send("Invalid signature");
  }

  const payload = JSON.parse(req.body.toString("utf8"));
  handleEvent(req.get("X-ConvoAI-Event"), payload);
  res.sendStatus(200);
});
Agent actions are signed differently. Webhooks fired by an agent's automated fire_webhook actions (configured through /actions/) post a different payload, { "event": "action.fire_webhook", "action_name", "agent_id", "contact_id", "contact_name", "conversation_id", "data" }, and send X-ConvoAI-Signature as the bare hex digest, without the sha256= prefix.

Retry policy#

Each delivery is attempted up to three times. A connection error, a timeout (10 seconds) or an HTTP status of 400 or above counts as a failure.

AttemptWait after the previous failure
1—
22 seconds
3 (final)4 seconds

After the third failure the delivery is marked failed and not retried. Deliveries for different webhooks run in parallel in the background, so don't rely on events arriving in order.

Process events idempotently. Deliveries carry no event ID, so build idempotency from the data itself, for example the conversation_id or appointment_id plus the event name, so a retried delivery doesn't double-process.

Testing#

Send a synthetic event to a registered URL without waiting for a real conversation.

bash
curl -X POST https://convoai.cloud/api/v1/agents/42/webhooks/9/test/ \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "message.sent"}'

event_type is optional and defaults to message.received. The test body is signed the same way, but its shape differs from live deliveries:

json · test delivery body
{
  "id": "test_9c1e4b7a2d3f5e60",
  "type": "message.sent",
  "created_at": "2026-05-11T14:35:22.000000+00:00",
  "agent_id": 42,
  "livemode": false,
  "data": { "test": true, "message": "ConvoAI test event" }
}
  • Test deliveries also send X-ConvoAI-Delivery (a random ID) and User-Agent: ConvoAI-Webhook/1.0, but not X-ConvoAI-Agent.
  • Read the event name from the X-ConvoAI-Event header; it is present on both test and live deliveries.
  • The test is sent once, with no retries. The API answers { "delivered": true, "http_status": 200 }, or { "delivered": false, "error": "…" } if your server could not be reached or answered with an error status.

For local development, expose your machine with a tunnel such as ngrok and register the tunnel's HTTPS URL.