Webhooks
Get call results, conversation events and conversions pushed to your server as signed events instead of polling.
webhooks:readwebhooks:write6 endpointsShow
How webhooks work
Register an https endpoint with POST /v1/webhooks and choose its events. NuTalk then POSTs each event as JSON, signed with the endpoint's secret. Endpoints are managed through the API (keys need webhooks:read or webhooks:write); test calls are never sent.
Conversation events carry customer messages, so * does not include them: name them in events, with a key that also has conversations:read. They are sent for the conversations the Conversations API lists (never test threads) and the customer's own messages (never reactions or internal notes).
Call events, and * which includes them, carry each call's numbers and outcome, so they take a key that also has calls:read.
Conversion events carry the workspace's conversions, so * does not include them either: name them, with a key that also has conversions:read, while Conversions is on for the workspace. Test events are never sent.
A campaign's own webhooks (the inbound lead URL and the event webhooks under the campaign's Integrations tab in the dashboard) are configured per campaign and are not the endpoints on this page.
After the endpoints: the events, what each delivery carries and how to verify its signature.
/v1/webhooksList webhook endpoints
Lists the workspace's webhook endpoints with their events and last delivery result (every attempt is in the endpoint's delivery log). Signing secrets are never returned after creation.
webhooks:readPaging is lenient, never refused: a limit of 0 or not a number means 20, above 100 means 100, and a negative offset means 0.
Query parameters
limitinteger · 1–100Results per page. Default: 20.
offsetinteger · at least 0Results to skip. Default: 0.
Plus the errors every request can return (401, 403, 429, 500): Errors.
curl https://www.nutalk.ai/v1/webhooks \ -H "Authorization: Bearer $NUTALK_API_KEY"
{
"success": true,
"data": [
{
"id": "ff19434c-ce6c-4dfd-a07d-7b367ad4396e",
"url": "https://hooks.example.com/nutalk",
"description": "Production CRM sync",
"events": [
"call.completed",
"call.failed"
],
"campaign_ids": null,
"is_active": true,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-10-02T03:44:44.387419+00:00",
"updated_at": "2026-10-02T03:44:44.387419+00:00"
}
],
"pagination": {
"total": 1,
"limit": 20,
"offset": 0,
"has_more": false
}
}/v1/webhooksRegister a webhook endpoint
Registers an https endpoint for events. The response carries the signing secret (whsec_…) once: store it to verify the X-NuTalk-Signature header. A workspace can have up to 20 endpoints.
webhooks:writeevents defaults to ["*"] (every call and campaign event) when omitted, which takes calls:read.
Request body
urlstring · up to 2,000requiredPublic URL starting with https:// (no localhost or private addresses).
eventsstring[] · 1–16 itemsEvents to receive. * is every call and campaign event; call events (and *) carry each call's numbers and outcome, so they take a key that also has calls:read. Conversation events (message.received, conversation.status_changed, conversation.assigned) carry customer messages, so they must be named, with a key that has conversations:read. Conversion events (conversion.recorded, conversion.retracted) must be named too, with a key that has conversions:read, while Conversions is on for the workspace.
descriptionstring · up to 500What the endpoint is for.
campaign_idsuuid[] | null · 0–50 itemsOnly these campaigns' call and campaign events (call.started, call.completed, call.failed, campaign.enrollment.completed): a call outside every campaign, or another campaign's, isn't sent. Conversation and conversion events aren't filtered by campaign. Each must be a campaign of the workspace; null or [] means every campaign.
Errors
400validation_errorThe body is not JSON, or a field is missing or invalid (details.errors lists each field).400validation_errorThe URL does not start with https://, or points to localhost or a private address; or campaign_ids names a campaign that isn't the workspace's.403forbiddenevents names a conversation event and the key lacks conversations:read, a call event or * (the default) and the key lacks calls:read, or a conversion event and the key lacks conversions:read (or Conversions is off for the workspace: details.reason conversions_off).409validation_errorThe workspace already has 20 endpoints.Plus the errors every request can return (401, 403, 429, 500): Errors.
curl -X POST https://www.nutalk.ai/v1/webhooks \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/nutalk",
"events": [
"call.completed",
"call.failed"
],
"description": "Production CRM sync"
}'{
"success": true,
"data": {
"id": "ff19434c-ce6c-4dfd-a07d-7b367ad4396e",
"url": "https://hooks.example.com/nutalk",
"description": "Production CRM sync",
"events": [
"call.completed",
"call.failed"
],
"campaign_ids": null,
"is_active": true,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-10-02T03:44:44.387419+00:00",
"updated_at": "2026-10-02T03:44:44.387419+00:00",
"secret": "whsec_9c1f…"
}
}/v1/webhooks/:idGet a webhook endpoint
Returns one endpoint (without its secret).
webhooks:readPath parameters
iduuidrequiredThe webhook endpoint id.
Errors
404not_foundNo webhook endpoint with this id in your workspace (ids of other workspaces answer the same).Plus the errors every request can return (401, 403, 429, 500): Errors.
curl https://www.nutalk.ai/v1/webhooks/ff19434c-ce6c-4dfd-a07d-7b367ad4396e \ -H "Authorization: Bearer $NUTALK_API_KEY"
{
"success": true,
"data": {
"id": "ff19434c-ce6c-4dfd-a07d-7b367ad4396e",
"url": "https://hooks.example.com/nutalk",
"description": "Production CRM sync",
"events": [
"call.completed",
"call.failed"
],
"campaign_ids": null,
"is_active": true,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-10-02T03:44:44.387419+00:00",
"updated_at": "2026-10-02T03:44:44.387419+00:00"
}
}/v1/webhooks/:idUpdate a webhook endpoint
Changes the URL, events or description, or pauses deliveries with is_active: false. The secret does not change. Deliveries already queued go to the current URL only while the endpoint still names their event.
webhooks:writeCan wait for approvalPath parameters
iduuidrequiredThe webhook endpoint id.
Request body
urlstring · up to 2,000New public URL starting with https://.
eventsstring[] · 1–16 itemsEvents to receive. * is every call and campaign event; call events (and *) carry each call's numbers and outcome, so they take a key that also has calls:read. Conversation events (message.received, conversation.status_changed, conversation.assigned) carry customer messages, so they must be named, with a key that has conversations:read. Conversion events (conversion.recorded, conversion.retracted) must be named too, with a key that has conversions:read, while Conversions is on for the workspace.
descriptionstring | null · up to 500New description, or null.
is_activebooleanfalse: stop deliveries; true: resume.
campaign_idsuuid[] | null · 0–50 itemsOnly these campaigns' call and campaign events (call.started, call.completed, call.failed, campaign.enrollment.completed): a call outside every campaign, or another campaign's, isn't sent. Conversation and conversion events aren't filtered by campaign. Each must be a campaign of the workspace; null or [] means every campaign.
Show 1 more field
Nothing changes yet: the call answers 202 with data.change_request, and runs once an owner or admin approves it in Settings → API keys. Follow it with GET /v1/change-requests/:id.
Errors
400validation_errorThe body is not JSON, or a field is missing or invalid (details.errors lists each field).400validation_errorThe URL does not start with https://, or points to localhost or a private address; or campaign_ids names a campaign that isn't the workspace's.403forbiddenThe key lacks conversations:read (or calls:read, or conversions:read) and either events names a conversation event (or a call event or *, or a conversion event), or the request changes the URL or turns the endpoint back on while its current or new events name one; or the key lacks calls:read and the request changes campaign_ids while the endpoint's events name a call event or *; or events names a conversion event while Conversions is off for the workspace (details.reason conversions_off).404not_foundNo webhook endpoint with this id in your workspace (ids of other workspaces answer the same).Plus the errors every request can return (401, 403, 429, 500): Errors.
curl -X PATCH https://www.nutalk.ai/v1/webhooks/ff19434c-ce6c-4dfd-a07d-7b367ad4396e \
-H "Authorization: Bearer $NUTALK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"is_active": false
}'{
"success": true,
"data": {
"id": "ff19434c-ce6c-4dfd-a07d-7b367ad4396e",
"url": "https://hooks.example.com/nutalk",
"description": "Production CRM sync",
"events": [
"call.completed",
"call.failed"
],
"campaign_ids": null,
"is_active": false,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-10-02T03:44:44.387419+00:00",
"updated_at": "2026-10-02T03:44:44.387419+00:00"
}
}/v1/webhooks/:idDelete a webhook endpoint
Deletes the endpoint; deliveries already queued for it are dropped.
webhooks:writeWaits for approvalPath parameters
iduuidrequiredThe webhook endpoint id.
Unless its key was set not to wait, nothing is removed yet: the call answers 202 with data.change_request, and runs once an owner or admin approves it in Settings → API keys. Follow it with GET /v1/change-requests/:id.
Errors
404not_foundNo webhook endpoint with this id in your workspace (ids of other workspaces answer the same).Plus the errors every request can return (401, 403, 429, 500): Errors.
curl -X DELETE https://www.nutalk.ai/v1/webhooks/ff19434c-ce6c-4dfd-a07d-7b367ad4396e \ -H "Authorization: Bearer $NUTALK_API_KEY"
{
"success": true,
"data": {
"id": "ff19434c-ce6c-4dfd-a07d-7b367ad4396e",
"deleted": true
}
}/v1/webhooks/:id/deliveriesList an endpoint's deliveries
The endpoint's delivery log, newest first: every attempt to deliver an event in the last 30 days, which attempt it was, what the endpoint answered and how long it took. Ids and codes only: never the body sent or the endpoint's answer.
webhooks:readoutcome is delivered (a 2xx), failed (retried, up to 5 attempts) or blocked (never retried). error_code: http_status, timeout, network_error, private_address, invalid_url. reference_id is what the event reports (the call, the message, the change): one delivery's attempts share it.
Path parameters
iduuidrequiredThe webhook endpoint id.
Query parameters
limitinteger · 1–100Results per page. Default: 20.
offsetinteger · at least 0Results to skip. Default: 0.
eventcall.started | call.completed | call.failed | campaign.enrollment.completed | message.received | conversation.status_changed | conversation.assigned | conversion.recorded | conversion.retractedOnly this event's attempts.
outcomedelivered | failed | blockedOnly attempts that ended this way.
Errors
400validation_errorA query parameter is invalid (details.errors lists each one).404not_foundNo webhook endpoint with this id in your workspace (ids of other workspaces answer the same).Plus the errors every request can return (401, 403, 429, 500): Errors.
curl "https://www.nutalk.ai/v1/webhooks/ff19434c-ce6c-4dfd-a07d-7b367ad4396e/deliveries?outcome=failed" \ -H "Authorization: Bearer $NUTALK_API_KEY"
{
"success": true,
"data": [
{
"id": "9b5d2f7c-4e0a-4b6d-9f8c-3a2e0d9b7f54",
"event": "conversion.recorded",
"reference_id": "a8f7e6d5-c4b3-4a29-8817-0f6e5d4c3b2a",
"attempt": 2,
"outcome": "delivered",
"http_status": 200,
"error_code": null,
"duration_ms": 184,
"attempted_at": "2026-10-25T23:43:12.501+00:00"
}
],
"pagination": {
"total": 1,
"limit": 20,
"offset": 0,
"has_more": false
}
}Events
| Event | Status | When |
|---|---|---|
call.completed | Sent | A call that was answered ended, on any phone line: about 30 seconds after it closes, with its duration and the credits charged. |
call.failed | Sent | A call ended without connecting: no answer, busy, voicemail, refused, canceled or failed. About 30 seconds after it closes. failure_reason says why (invalid_number, declined, no_answer…) and failure_detail says it in a sentence, as on GET /v1/calls/:id. |
call.started | Sent | A call began: an inbound call came in, or an outbound call was answered. About 5 seconds after; not for an inbound call refused at once. Not sent yet for outbound calls from an AiBox or Zoom Phone line, whose answer doesn't change the call's status before it ends: those send only call.completed or call.failed. |
campaign.enrollment.completed | Sent | An enrollment finished: its status became completed, failed, exhausted, rejected or removed, however it got there (a call's outcome, the workflow, a teammate, the do-not-contact rules). Carries campaign_id, enrollment_id, status, previous_status, finished_at, and failure_reason and failure_detail (why it stopped, or nulls), and none of the contact's data: read the enrollment with GET /v1/campaigns/:id/enrollments/:enrollment_id, which takes campaigns:read. Never for a test run. |
message.received | Sent | A customer's message was stored in a conversation, on any channel of the inbox (WhatsApp, SMS, Instagram, Messenger, Apple Messages and website chat). Carries the conversation and the message. |
conversation.status_changed | Sent | A conversation was resolved, reopened, archived or unarchived. Carries what changed (previous, current) and the conversation. |
conversation.assigned | Sent | A conversation was assigned, reassigned or unassigned (assignee_id null), including a new conversation that starts assigned to its channel's operator (previous_assignee_id null). Carries both assignees and the conversation. |
conversion.recorded | Sent | A conversion went live: recorded, recorded again after it was taken back, or an outcome the AI made that passed its checks. Carries the event as GET /v1/conversions shows it. Only while Conversions is on for the workspace. |
conversion.retracted | Sent | A live conversion was taken back (a rolled-back booking, a status changed, a deal that left its won stage). Carries the event, with retracted_at and retracted_reason. |
Each call sends each event at most once, whatever its phone line (a call that a late answer reopens keeps the events it already sent). Test calls and website widget sessions send none. For transcripts and summaries, fetch the call with GET /v1/calls/:call_id when the event arrives. A conversation event's data.conversation is read when the event is sent, so it can already show later changes; previous, current and the assignee ids describe the change itself.
An endpoint can listen to some campaigns only (campaign_ids when you create or update it): its call and campaign events are then only those of these campaigns, never a call outside every campaign. Conversation and conversion events carry no campaign and are sent as the endpoint subscribes to them. Changing the campaigns of an endpoint that receives call events takes calls:read, and an event already queued goes out only while its campaign is still one of them.
{
"id": "evt_2c51d7e0-8f3a-4b6e-9a41-6f0d3e2b7c18",
"event": "message.received",
"created_at": "2026-10-02T12:15:09.120Z",
"data": {
"conversation": {
"id": "5f0c2b1e-8d3a-4c6e-9b7f-2a4d6e8c0b13",
"channel": {
"id": "9f19ef32-eaef-452c-bebc-14d9759cdb64",
"name": "Support WhatsApp",
"type": "whatsapp"
},
"customer": {
"name": "Ana",
"handle": "5215512345678",
"handle_type": "phone"
},
"contact": {
"id": "9595b646-ab97-4485-b1b8-e8b58dfcdd19",
"name": "Ana López"
},
"status": "active",
"archived": false,
"assignee": null,
"awaiting_reply": true,
"…": "…"
},
"message": {
"id": "0e6a4c2b-7f1d-4b3e-8a5c-9d2f4b6e8a10",
"direction": "inbound",
"sender": {
"type": "customer",
"user_id": null,
"name": null
},
"text": "¿Siguen ahí?",
"attachments": [],
"created_at": "2026-10-02T12:15:08.634668+00:00",
"stored_at": "2026-10-02T12:15:08.902115+00:00",
"…": "…"
}
}
}Delivery
Each delivery is a POST with these headers: Content-Type: application/json, User-Agent: NuTalk-Webhooks/1.0, X-NuTalk-Event (the event name) and X-NuTalk-Signature. The body:
{
"id": "evt_6f3c9a2e-41b7-4f0e-9d8a-2c5b7e1f0a93",
"event": "call.completed",
"created_at": "2026-10-02T14:33:05.120Z",
"data": {
"call_id": "1ba11348-e440-44de-a5cd-5fe62f4d9df6",
"status": "completed",
"outcome": "answered",
"direction": "outbound",
"from_number": "+525512345678",
"to_number": "+14155551234",
"channel_id": "9f19ef32-eaef-452c-bebc-14d9759cdb64",
"campaign_id": "c1a2b3c4-d5e6-4f70-8a91-b2c3d4e5f607",
"campaign_enrollment_id": "7593112e-56ab-4a08-858f-21035eb1bacc",
"started_at": "2026-10-02T14:29:40.000Z",
"answered_at": "2026-10-02T14:29:48.000Z",
"ended_at": "2026-10-02T14:32:40.000Z",
"duration_seconds": 180,
"credits_charged": 16,
"credits_charged_exact": 16.5,
"failure_reason": null,
"failure_detail": null
}
}credits_chargedis a whole number of credits (the charge with its fraction dropped, never rounded up);credits_charged_exactis the exact charge, with up to 2 decimals. Prices are on the pricing page.- Answer with a 2xx within 10 seconds; do the work afterwards. Redirects are not followed.
- A non-2xx answer, a timeout or a connection error is retried: up to 5 attempts in all, about 15 s, 30 s, 60 s and 2 min apart (deliveries run on a one-minute worker, so allow up to a minute more). After the fifth failure the event is dropped. A conversation event takes one more run of that worker first, to build its payload, so allow up to another minute for it.
- The same event can arrive more than once, and every attempt has a new
id: deduplicate onevent+data.call_id(one delivery per event, call and endpoint); formessage.receivedondata.message.id, and for the other conversation events ondata.change_id. Events can arrive in a different order than they happened. last_delivery_statuson the endpoint shows the last result:http_200,http_500… orblocked:…when the URL points to a private address (never retried).- Every attempt of the last 30 days is in the endpoint's delivery log, GET /v1/webhooks/:id/deliveries: the event, what it reports, which attempt it was, what your server answered and how long it took. For a conversion event, deduplicate on
data.change_id.
Verify the signature
X-NuTalk-Signature looks like t=1790911190123,v1=5f8c…: t is the send time in Unix milliseconds and v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` with the endpoint's secret. Compute it over the raw body (before parsing JSON), compare in constant time, and reject old timestamps.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string, before JSON.parse).
// header: the X-NuTalk-Signature header. secret: the whsec_… from POST /v1/webhooks.
export function verifyNutalkSignature(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
const parts = Object.fromEntries(
String(header || "").split(",").map((part) => part.split("=", 2))
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() - timestamp) > toleranceMs) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const wanted = Buffer.from(expected, "hex");
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}In an Express server, keep the raw body for the check:
import express from "express";
import { verifyNutalkSignature } from "./verify-nutalk-signature.js";
const app = express();
// Keep the raw body: the signature covers the exact bytes NuTalk sent.
app.post("/webhooks/nutalk", express.text({ type: "application/json" }), (req, res) => {
const ok = verifyNutalkSignature(
req.body,
req.get("X-NuTalk-Signature"),
process.env.NUTALK_WEBHOOK_SECRET
);
if (!ok) return res.status(401).send("Invalid signature");
const event = JSON.parse(req.body);
// A delivery can arrive more than once, each attempt with a new event.id:
// deduplicate on event.event + event.data.call_id.
console.log(event.event, event.data.call_id);
res.status(200).send("ok"); // answer within 10 seconds
});