After the conversation
Receive webhooks
Get a signed HTTPS request the moment a call ends, a message arrives or a lead is captured, verify it, and handle retries without doing anything twice.
A webhook subscription sends events from one project to an HTTPS endpoint of yours. Each request is a JSON envelope signed with the subscription's secret. You verify the signature, answer 2xx quickly, and do the work afterwards.
Subscribe
Give the endpoint and, optionally, the events you want. Leave events out to receive all of them. An org-wide key also sends project_id. To receive test traffic instead of live, create a separate subscription with "mode": "test".
curl -X POST https://api.myleadsfactory.tech/api/webhooks \
-H "Authorization: Bearer $MLF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.acmetravel.com/mlf",
"events": ["call.ended", "lead.captured", "conversation.ended"],
"project_id": "'"$MLF_PROJECT_ID"'"
}'{
"data": {
"id": "whsub_eec7f73b05224cc9afcf68b5d3edd16e",
"project_id": "proj_2f9c41d0a8b34e6f9d1c7a5b3e8f0a12",
"url": "https://hooks.acmetravel.com/mlf",
"secret": "whsec_2dbd...",
"events": ["call.ended", "lead.captured", "conversation.ended"],
"disabled_at": null,
"created_at": "2026-10-28T09:30:00.000Z"
}
}Event names must be ones from the list: an unknown name, such as a typo, is refused with 400 invalid_request. The full list is in Webhook events. The URL must be public HTTPS; private addresses and URLs with credentials are refused.
Verify the signature
Every request carries X-Webhook-Signature: sha256=<hex>: an HMAC-SHA256 of the raw request body, keyed with the whole secret string (including whsec_). Compute it over the bytes exactly as received, before any JSON parsing, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyWebhook(rawBody, signature, secret) {
if (typeof signature !== 'string') return false
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'))
const supplied = Buffer.from(signature)
return supplied.length === expected.length && timingSafeEqual(supplied, expected)
}To check your implementation: the body {"event":"call.ended","event_id":"evt_1"} with the secret wh_sub_secret must give sha256=e680c795d95f1ac6e714d6c6fcb1065865aa0a6d63a74f6f304bacd356ddcd03.
Answer fast, then do the work
import express from 'express'
const app = express()
// express.raw keeps the exact bytes the signature was computed over
app.post('/mlf', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyWebhook(req.body, req.get('x-webhook-signature'), process.env.MLF_WEBHOOK_SECRET)) {
return res.sendStatus(401)
}
const event = JSON.parse(req.body.toString('utf8'))
if (await alreadyHandled(event.event_id)) return res.sendStatus(200)
res.sendStatus(200) // acknowledge first
await queue.add(event) // then do the slow work
})Any 2xx counts as delivered; the response body is ignored. Anything else, a timeout (10 seconds) or a redirect counts as a failure and is retried.
What you receive
Every event shares one envelope. Call events carry call_id; chat events carry conversation_id:
{
"schema_version": "1.0",
"event": "call.ended",
"event_id": "evt_6a1c...",
"idempotency_key": "call_5e1a...:call.ended:evt_6a1c...",
"call_id": "call_5e1a7c9d2b4f4e8a9c3d6b0f1e2a4c7d",
"timestamp": "2026-10-28T09:42:10.000Z",
"identity": {
"org_id": "org_...",
"project_id": "proj_...",
"assistant_id": "asst_..."
},
"data": {
"call_type": "outbound",
"ended_reason": "customer_hangup",
"duration_ms": 184000,
"messages": [{ "role": "assistant", "content": "Hi Ana, it's Acme Travel..." }]
}
}Webhook events lists every event and its data.
Retries and duplicates
A failed delivery is retried up to 10 attempts in all, waiting 10 seconds before the first retry and doubling each time (about 85 minutes of waiting overall). Each retry is signed again with the subscription's current secret and sent to its current URL.
The same event can still arrive more than once, for example after a timeout on a request you did handle. Deduplicate on event_id (or idempotency_key), and make handlers safe to run twice. Order is not guaranteed either: use timestamp, and for conversation.analyzed keep the version with the highest through_seq.
Inspect deliveries
GET/api/webhooks/{id}/deliveries returns the latest 100 deliveries for a subscription, newest first, with each one's status (pending, delivered or failed), attempts, last HTTP status and last error. Filter with ?status=failed. Each row is one event for this subscription, updated after every attempt. A delivery also ends failed when its subscription is paused, or when it is still pending after 24 hours. Deleting a subscription deletes its delivery log.
Rotate the secret or pause
PATCH/api/webhooks/{id} takes a new secret, url or events, or "enabled": false to pause deliveries (true resumes; disabled_at shows when it was paused). Retries of earlier events are signed with the new secret too, so accept both secrets for a few minutes while you switch. You can also pass your own secret when you subscribe instead of using the generated one. To stop for good, delete the subscription with DELETE/api/webhooks/{id}.