Webhooks
Create outbound webhook subscriptions, fire test deliveries, and inspect or replay the delivery log.
Subscribe an HTTPS endpoint to platform events. Every payload is signed with HMAC-SHA256 using the subscription's secret. Deliveries are logged, inspectable, and replayable.
Credential class: dashboard JWT or a cm_ key with webhooks:write
These routes accept either credential.
Authorization: Bearer <jwt_access_token>
# or
Authorization: Bearer cm_your_api_key| Caller | Requirement |
|---|---|
cm_ API key | Must carry the webhooks:write scope. Every endpoint on this page checks it, including the read-only list and delivery endpoints - there is no webhooks:read scope |
| Dashboard JWT | Every endpoint, including the reads, requires owner or admin (403 Only owners/admins can manage webhooks otherwise). Delivery payloads carry customer data, so the log is not open to every member |
Base URL for every example: https://api.callmissed.com. Errors are {"detail": "..."}.
Event types
Pass these in the events array. An unrecognised value returns 422 listing the valid set. Duplicates are dropped, and the array may hold at most as many entries as the catalogue below (28).
| Category | Events |
|---|---|
| Conversations & messages | conversation.started, conversation.ended, message.received, message.sent, message.status (a WhatsApp message you sent reached sent, delivered, read or failed, see delivery status) |
| Voice sessions | voice_session.started, voice_session.ended, voice_session.failed |
| Telephony call lifecycle | call.started, call.completed, call.failed, call.amd_detected (answering-machine detection resolved on an outbound call) |
| Post-call analysis | voice_analysis.completed |
| Outbound campaigns | campaign.started, campaign.completed, campaign.failed |
| Metric alerts | voice_alert.triggered |
| Billing | budget.alert, budget.exceeded, credits.low |
| Keys | api_key.expired |
| Invoices & payments | invoice.created, payment.succeeded, payment.failed |
| Customer payment links | payment_request.partially_paid, payment_request.paid, payment_request.expired, payment_request.cancelled — a payment link an agent sent to your customer changed status in your own connected Razorpay account |
Voice session end reasons
A voice session fires exactly one of voice_session.ended or voice_session.failed, once, whichever way it ends: the agent or caller hanging up, a DELETE, the managed voice agent socket closing, or the session timing out. The event is sent only after the end has been saved. If two end signals arrive at the same moment, the first one decides end_reason and duration_seconds, and the later one changes nothing.
end_reason | Session status | Event |
|---|---|---|
agent_error, start_failed, and the managed voice agent's start-up errors (invalid_settings, concurrency_limit, model_unavailable, non_settings_message_before_settings) | failed | voice_session.failed |
timeout (ran past its maximum duration and nothing reported the end), token_expired (never connected) | timeout | voice_session.failed |
Any other reason, for example caller_hangup, participant_disconnect, client_disconnect, agent_closed, max_duration, no_input, handoff, budget_exhausted, api_delete | completed | voice_session.ended |
Delivery format
Every event is a POST with this JSON body:
{
"event": "message.received",
"data": { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user" },
"timestamp": "2026-08-04T09:12:00.512301+00:00"
}and these headers:
Content-Type: application/json
X-CallMissed-Event: message.received
X-CallMissed-Delivery: <delivery id>
X-CallMissed-Signature: sha256=<hex digest>X-CallMissed-Delivery is the id you see in the delivery log; use it to de-duplicate, because a retry or a replay can deliver the same event more than once.
A response with a status below 300 counts as delivered. Anything else, or no response within 10 seconds, is retried: up to 5 attempts in total, waiting 2, 4, 8 and 16 seconds between them. The delivery log records the attempt count, the last status code and the last error.
A Slack Incoming Webhook URL (https://hooks.slack.com/services/...) receives the event formatted as Slack message blocks instead of the JSON body above.
Signing and verification
Every delivery, including the test delivery, carries the X-CallMissed-Signature header above.
The digest is HMAC-SHA256(secret, raw_request_body). Compute it over the raw bytes you received, before any JSON parsing, and compare with a constant-time function.
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", header)The full secret is returned only once, in the POST create response. Every later read masks it as first4 + "****" + last4. Store it when you create the subscription.
URL rules
The url is validated on create, on update, before a test delivery, and on every real delivery attempt. Endpoints resolving to private, loopback, link-local, or shared address space are rejected. Use a public HTTPS URL.
GET /api/v1/webhooks
Lists your tenant's subscriptions, newest first.
| Parameter | Type | Required | Constraints |
|---|---|---|---|
limit | integer | No | 1 <= limit <= 200, default 50 |
curl https://api.callmissed.com/api/v1/webhooks \
-H "Authorization: Bearer cm_your_api_key"[
{
"id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff",
"url": "https://hooks.acme.com/callmissed",
"secret": "Xk4p****9dQz",
"events": ["conversation.started", "message.received"],
"is_active": true,
"bot_id": null,
"created_at": "2026-07-19T08:00:00Z"
}
]403 when a cm_ key lacks webhooks:write, or a JWT caller is not owner/admin; 422 when limit is outside 1 .. 200.
POST /api/v1/webhooks
Creates a subscription and generates its secret. Returns 201.
| Field | Type | Required | Constraints |
|---|---|---|---|
url | string | Yes | 1-2048 chars. Must pass the URL rules above |
events | string[] | Yes | 1-27 entries, each from the event table |
bot_id | UUID | null | No | Scope the subscription to one agent. Omit for a workspace-wide subscription |
curl -X POST https://api.callmissed.com/api/v1/webhooks \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.acme.com/callmissed",
"events": ["conversation.started", "message.received", "payment.succeeded"],
"bot_id": null
}'{
"id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"tenant_id": "a0b1c2d3-4455-6677-8899-aabbccddeeff",
"url": "https://hooks.acme.com/callmissed",
"secret": "Xk4pR7tYb2Lm8sNcVhJ3wQeZ1aFgD6uO9dQz",
"events": ["conversation.started", "message.received", "payment.succeeded"],
"is_active": true,
"bot_id": null,
"created_at": "2026-08-04T10:40:00Z"
}This response is the only place the full secret appears.
| Status | Cause |
|---|---|
400 | The URL failed validation |
403 | Key missing webhooks:write, or JWT caller is not owner/admin |
404 | Agent not found when bot_id is not an agent in your tenant |
422 | Unknown event type, empty events, or url over 2048 chars |
PUT /api/v1/webhooks/{webhook_id}
Partial update. Every field is optional; omitted fields are unchanged. Returns the subscription with a masked secret.
| Field | Type | Required | Constraints |
|---|---|---|---|
url | string | null | No | 1-2048 chars. Re-validated when present |
events | string[] | null | No | Replaces the whole list. An empty array [] subscribes to every event |
is_active | boolean | null | No | Pause or resume deliveries |
bot_id | UUID | null | No | Re-scope to an agent in your tenant |
clear_bot_id | boolean | No | Default false. Send true to widen a scoped subscription back to the whole workspace. Takes precedence over bot_id |
curl -X PUT https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8 \
-H "Authorization: Bearer cm_your_api_key" \
-H "Content-Type: application/json" \
-d '{"events": ["message.received"], "is_active": false}'404 Webhook not found; 404 Agent not found for a foreign bot_id; 403, 400, and 422 as for create.
DELETE /api/v1/webhooks/{webhook_id}
Returns 204 with an empty body. Writes a webhook.delete audit event.
curl -X DELETE https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8 \
-H "Authorization: Bearer cm_your_api_key"403 for insufficient permission; 404 when the subscription is not in your tenant.
POST /api/v1/webhooks/{webhook_id}/test
Sends a real signed POST to the configured URL and reports the result. No request body. The stored URL is re-validated first, so a hostname repointed at a private address after creation is rejected.
The delivered payload:
{
"event": "test",
"timestamp": "2026-08-04T10:45:12.004921+00:00",
"data": { "message": "This is a test webhook delivery from CallMissed" }
}curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/test \
-H "Authorization: Bearer cm_your_api_key"{
"success": true,
"status_code": 200,
"latency_ms": 214,
"error": null
}success is true only for a 2xx from your endpoint. On a network failure the response is still 200 with success: false, status_code: null, and a short error such as Connection failed or Request timed out. The 10-second request timeout applies. The test is not retried and is not written to the delivery log.
403 for insufficient permission; 404 when the subscription is not in your tenant.
GET /api/v1/webhooks/{webhook_id}/deliveries
Delivery log for one subscription, newest first.
| Parameter | Type | Required | Constraints |
|---|---|---|---|
limit | integer | No | 1 <= limit <= 200, default 50 |
curl "https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries?limit=50" \
-H "Authorization: Bearer cm_your_api_key"[
{
"id": "d5e6f708-1920-4a3b-8c4d-5e6f708192a3",
"webhook_id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"event": "message.received",
"status_code": 500,
"attempts": 3,
"success": false,
"error": "HTTP 500",
"created_at": "2026-08-04T09:12:00Z",
"delivered_at": null
}
]status_code and error are null when not applicable; delivered_at is null until a delivery succeeds. The payload body is not in this list response - fetch one delivery for that.
403 when a cm_ key lacks webhooks:write; 404 when the subscription is not in your tenant; 422 when limit is outside 1 .. 200.
GET /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}
Full inspector view of one delivery, including the stored payload.
curl https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries/d5e6f708-1920-4a3b-8c4d-5e6f708192a3 \
-H "Authorization: Bearer cm_your_api_key"{
"id": "d5e6f708-1920-4a3b-8c4d-5e6f708192a3",
"webhook_id": "w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"event": "message.received",
"status_code": 500,
"attempts": 3,
"success": false,
"error": "HTTP 500",
"payload": {
"event": "message.received",
"data": { "conversation_id": "c0ffee00-1111-2222-3333-444455556666", "role": "user" }
},
"created_at": "2026-08-04T09:12:00+00:00",
"delivered_at": null
}| Status | Cause |
|---|---|
403 | Key missing webhooks:write, or JWT caller is not owner/admin |
404 | Webhook not found or Delivery not found |
422 | Either path id is not a valid UUID |
POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/replay
Re-sends a past delivery's payload against the subscription's current URL and secret. No request body. A new delivery row is created; the original is preserved. Returns 202.
curl -X POST https://api.callmissed.com/api/v1/webhooks/w1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8/deliveries/d5e6f708-1920-4a3b-8c4d-5e6f708192a3/replay \
-H "Authorization: Bearer cm_your_api_key"{
"delivery_id": "a7b8c9d0-e1f2-4304-8516-27a8b9c0d1e2",
"status": "queued"
}status: "queued" means the replay was accepted, not that it succeeded. Poll the deliveries list for the new row's outcome. Writes a webhook.delivery.replay audit event.
| Status | Cause |
|---|---|
400 | Webhook is inactive - re-enable it with PUT first |
403 | Key missing webhooks:write, or JWT caller is not owner/admin |
404 | Webhook not found or Delivery not found |