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.
- 01
Event happens
in ConvoAI
- 02
Signed POST
JSON + X-ConvoAI-Signature
- 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
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | Required | Endpoint that receives events. Must start with https:// and be publicly reachable; addresses on private networks or localhost are refused at delivery time. |
| events | string[] | Required | Non-empty list of event types to subscribe to. Names must match exactly; there is no wildcard. |
| secret | string | Optional | Key used to sign payloads with HMAC-SHA256. If omitted, a 64-character hex secret is generated. |
| description | string | Optional | Label for the webhook, returned as name. Defaults to “Webhook” plus today's date. |
| active | boolean | Optional | Deliver events immediately. Default true. |
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"
}'{
"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…"
}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.
{
"event": "appointment.created",
"agent_id": 42,
"data": { … }
}| Event | Sent when | data |
|---|---|---|
| message.sent | The AI agent sends a reply to a WhatsApp message | conversation_id, contact_phone, content, sent_by_ai, action, intent |
| conversation.resolved | A conversation is resolved from the dashboard inbox | conversation_id |
| appointment.created | An appointment is created in the dashboard agenda | appointment_id, title, start_time, contact_phone |
| appointment.updated | An appointment is rescheduled in the dashboard | appointment_id, title, start_time, contact_phone |
Payloads
{
"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"
}
}{
"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.
{
"event": "conversation.resolved",
"agent_id": 42,
"data": { "conversation_id": 1287 }
}message.received, conversation.opened and contact.created. These names are accepted, but ConvoAI does not currently send deliveries for them.Request headers#
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-ConvoAI-Event | The event name, e.g. message.sent |
| X-ConvoAI-Agent | The agent ID |
| X-ConvoAI-Signature | sha256= 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=.
X-ConvoAI-Signature: sha256=7b3f1a8c2e9d4f0b6a5c8e1f2d4a7b9c3e6f0a2b5d8e1c4f7a0b3d6e9f2c5a1bimport 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)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);
});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.
| Attempt | Wait after the previous failure |
|---|---|
| 1 | — |
| 2 | 2 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.
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.
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:
{
"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) andUser-Agent: ConvoAI-Webhook/1.0, but notX-ConvoAI-Agent. - Read the event name from the
X-ConvoAI-Eventheader; 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.
