Skip to content

Webhooks

Webhooks are available from the Pro plan upward. Programmatic recovery and canonical re-fetch require Business.

Create endpoints in Agente → Integrazioni → Webhooks in the Talki dashboard. Each endpoint must be a public HTTPS URL. The signing secret is shown once; keep it in the same server-side secret store as an API key.

{
"id": "evt_04d8e87897db4ecab459a456ea55e754",
"type": "call.ended",
"created_at": "2026-09-15T09:18:03+00:00",
"api_version": "2026-06-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"origin": {
"source": "voice",
"credential_id": null,
"idempotency_key": null
},
"data": {
"object": {
"id": "38d23d84-02d2-4418-b169-3608aa04a8f8",
"caller": {
"contact_id": "8fbb25f2-4f3b-4496-8aea-f0fc66de512d",
"phone_number": "+393331234567",
"display_name": "Giulia Bianchi"
},
"started_at": "2026-09-15T09:14:31Z",
"answered_at": "2026-09-15T09:14:34Z",
"ended_at": "2026-09-15T09:18:02Z",
"duration_seconds": 208,
"summary": "Giulia asked to move her haircut to Friday morning.",
"appointment_ids": [],
"created_at": "2026-09-15T09:18:03Z"
}
}
}

Use id as the delivery deduplication key and account_id as the tenant key. origin reports whether the change came from the voice assistant, the dashboard, or—in a future write API—a credential. It can be null on older redeliveries.

For resource events, data.object is the canonical public object. Event-only notifications such as ping and usage.threshold_reached do not have an object.

Event When it is emitted data.object
call.ended A call ends and its summary is ready Call
call.transferred The assistant transfers a call to a person Call, plus target_label and transferred_at
appointment.created A new Talki appointment is committed Appointment
appointment.updated An appointment is edited, moved, or restored Appointment
appointment.cancelled An appointment is cancelled Appointment with status: "cancelled"
contact.created A curated contact is added Contact
contact.updated A curated contact changes Contact
usage.threshold_reached The account reaches 75% of included minutes None; usage counters are in data
ping The account owner chooses Invia test None; data.message confirms the test

An AI-inferred contact name does not emit a contact event until a person approves it. A voice reschedule cancels the old appointment and creates a new ID, so it produces appointment.cancelled followed by appointment.created.

Content-Type: application/json
User-Agent: Hubtec-Webhooks/1.0
X-Hubtec-Signature: t=1789463883,v1=4a3d…
X-Hubtec-Event: call.ended
X-Hubtec-Delivery: evt_04d8e87897db4ecab459a456ea55e754

Header names are case-insensitive. X-Hubtec-Delivery equals the envelope id.

  • Delivery is at least once. Persist the event ID before applying side effects.
  • Any 2xx acknowledges the event. Redirects and every non-2xx response fail.
  • Network failures and non-2xx responses are retried after 10 seconds, 1 minute, 10 minutes, 1 hour, and 6 hours—six attempts including the first.
  • Events can arrive out of order. Use object timestamps and make updates idempotent; never infer sequence from arrival order.
  • A 410 Gone response disables the endpoint immediately.
  • Twenty consecutive terminally failed deliveries disable the endpoint. One success resets the failure counter.
  • Delivery attempts time out after 10 seconds. Acknowledge quickly and move expensive work to your own queue.
  • Delivery logs and stored payloads are retained for 30 days.

Talki signs the exact bytes sent in the request body. Parse X-Hubtec-Signature, then compute HMAC-SHA256 over:

<timestamp>.<raw request body>

Use the complete whsec_… secret, compare digests in constant time, and reject timestamps older than five minutes. Verify before parsing JSON or queuing the event.

import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyTalkiWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => part.split('=', 2)),
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
throw new Error('stale webhook')
}
const expected = createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest()
const received = Buffer.from(parts.v1 ?? '', 'hex')
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
throw new Error('invalid webhook signature')
}
return JSON.parse(rawBody.toString('utf8'))
}

Pass a Buffer captured before your framework’s JSON middleware modifies the body.

import hashlib
import hmac
import json
import time
def verify_talki_webhook(raw_body: bytes, signature_header: str, secret: str):
parts = dict(part.split("=", 1) for part in signature_header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > 300:
raise ValueError("stale webhook")
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected):
raise ValueError("invalid webhook signature")
return json.loads(raw_body)

For missed call events, re-query GET /v1/calls with created_after, a one-minute safety overlap, and cursor pagination. Deduplicate on call ID. See Core concepts.

Appointment and contact REST recovery endpoints are not released yet. Inspect the 30-day delivery log and redeliver failures from the dashboard. If the gap is older than that, contact Talki support before attempting a manual reconciliation.