Skip to content

Webhooks

Webhooks are available from the Pro plan upward; reading the REST API — change feeds and fetching an object by ID — requires Business.

Endpoints are created in Agente → Integrazioni → Webhooks in the Talki app. An endpoint must be an https:// URL on port 443 whose host resolves only to public addresses; anything else is refused at registration and again on every delivery. The signing secret (whsec_…) is shown once, and belongs in the same secret store as an API key.

Serving many accounts? Use app events: an OAuth app receives the events about calls, appointments, contacts, services, resources, locations, documents and account settings of every account that installed it, along with its own install and access events, at one URL.

{
"id": "evt_04d8e87897db4ecab459a456ea55e754",
"type": "call.ended",
"created_at": "2026-09-22T15:04:06+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "voice",
"credential_id": null,
"idempotency_key": null
},
"data": {
"object": {
"id": "6718188a-b1f4-46e6-9776-c78a536e1ed9",
"location_id": "0b7d4e19-8a2c-4f61-9d3e-5c1a7b2f8e40",
"caller": {
"contact_id": "8fbb25f2-4f3b-4496-8aea-f0fc66de512d",
"phone_number": "+393331234567",
"display_name": "Giulia Bianchi"
},
"started_at": "2026-09-22T15:02:11Z",
"answered_at": "2026-09-22T15:02:14Z",
"ended_at": "2026-09-22T15:04:05Z",
"duration_seconds": 111,
"summary": "Giulia asked to move her haircut to Friday morning.",
"appointment_ids": [],
"outcome": "rescheduled",
"transfers": [],
"appointment_changes": [
{"appointment_id": "c88d57ee-05d5-4fc3-b258-2f95784aa0d4", "action": "rescheduled"}
],
"unresolved_request_ids": [],
"created_at": "2026-09-22T15:04:06Z",
"read_at": null,
"archived_at": null
}
}
}
Field Meaning
id The event ID, evt_ and 32 hex characters. Equal to X-Talki-Delivery and unchanged on a redelivery: deduplicate on it
type The event type, from the catalogue
created_at When the event was written
api_version The dated version data is shaped for: the endpoint’s version when the event was written
account_id The Talki account the event happened on: the tenant key
livemode false for events from a sandbox, so one receiver can serve both and route on it
origin What caused the change, below
data The payload: see Event payloads

origin.source says where the change came from:

source The change was made by
voice The assistant, during a call
dashboard A person in the Talki web or mobile app
api A credential through the API. credential_id and idempotency_key carry the credential ID and the request’s key, which is how a consumer recognises its own changes
system No one at that moment: Talki applied a rule on its own. A scheduled one, such as the owner’s auto-archive window or the twelve-month data retention; what a payment provider reported, such as assigning or releasing the Talki number when a subscription starts or ends; or moving a tag onto a contact, described below

origin can be null; treat it, and a source you do not recognise, as unknown.

The envelope’s created_at is ISO 8601 with a +00:00 offset and may carry microseconds. Timestamps inside data use the Z form of the REST API and may carry microseconds too. Parse every timestamp as an instant rather than comparing strings.

Event When it is emitted data.object
call.ringing An inbound call reaches the assistant, which is about to pick up; not sent for a call Talki refuses or places itself None; the call’s details are in data
call.ended A call ends and its summary is ready Call
call.transferred The assistant transfers a call to a person Call; target_label and transferred_at sit beside object in data
call.updated A call is marked read or unread, archived or returned to the inbox, by hand or by the owner’s auto-archive window; one event per call Call
call.deleted A call is deleted, in the Talki app or with DELETE /v1/calls/{id} Call, as it was; deleted sits beside object, with the unresolved_request_ids and pending_contact_ids deleted with it
appointment.created A new Talki appointment is committed Appointment
appointment.updated An appointment is edited, moved, or restored; also, with call_id null, for each appointment booked by a call that is deleted 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
contact.deleted A curated contact is deleted, by a person, an app, or the data retention after twelve months without a call Contact, as it was
pending_contact.created The assistant hears a new caller’s name, waiting for the owner PendingContact
pending_contact.updated A later call refreshes a pending contact PendingContact
pending_contact.deleted A pending contact is approved or discarded, or discarded by the data retention after twelve months; also, with outcome: "discarded", for each name proposed by a call that is deleted PendingContact, as it was; outcome (approved or discarded) sits beside object
tag.created, tag.updated, tag.deleted A tag is created, renamed, put on or taken off a contact, or deleted; tag.updated also when a contact carrying it is deleted Tag
caller_rule.created, caller_rule.updated, caller_rule.deleted A VIP or blocked number is added, changed or removed CallerRule
unresolved_request.updated A request is resolved, dismissed or reopened, or its answer changes UnresolvedRequest
document.created A document is added to the knowledge base: a text note, an upload or an imported website, by a person, an app or an answer taught to the assistant; one event per document, each copy at another location included Document
document.updated A document’s title, content or enabled changes, it turns processing again, or its indexing ends ready or failed Document
document.deleted A document is deleted, by a person or an app, by deleting the last of its pages that was read, or, seven days after it failed, as a file upload or website import that never held anything Document, as it was
account.updated A writable field of the account, onboarding_completed or phone_number changes, at the main location; or call_auto_archive_days, the account’s own setting, changes Account, at the main location
working_hours.updated The main location’s weekly opening hours change WorkingHours
closure.created, closure.updated, closure.deleted A closure or special opening is added, changed or removed Closure
service.created, service.updated, service.deleted A service is added, changed, switched off or on, or deleted Service; as it was for service.deleted
resource.created, resource.updated, resource.deleted A resource is added, changed, gains or loses a service, or is deleted Resource; as it was for resource.deleted
location.created, location.updated A location is added; its name, order, Talki number or line changes Location
location.deactivated, location.reactivated A location is switched off or back on, by the owner or by the plan Location
location.settings_updated A location other than the main one changes the assistant settings it keeps for itself Account, at that location
location.working_hours_updated The weekly hours of a location other than the main one change WorkingHours, of that location
usage.threshold_reached A call leaves the account with 25% or less of its minutes: the plan’s included minutes plus any extra minutes None; usage counters are in data
usage.extra_credits_low The extra-minutes balance falls below the floor the owner set None; balances are in data
ping The account owner chooses Invia test None; data.message confirms the test

account.updated, working_hours.updated and closure.updated fire for a change made in the Talki app, through the API or by Talki, and not for a save that leaves the published fields as they were. account.updated also fires when Talki assigns or releases the account’s phone_number. See Configure the assistant.

Each change to a location’s configuration arrives under one type only. The main location’s settings, number, line and hours are on account.updated and working_hours.updated, and so is call_auto_archive_days; the location.* types cover the rest, the switches of a location other than the main one included. See Work with locations.

A tag the owner puts on a person’s address-book entry in the Talki app, before that person is a contact, moves onto the contact once it exists. That sends tag.updated shortly after contact.created, with origin.source system.

An AI-inferred contact name is a pending_contact.* event, never a contact.* one, until a person approves it.

A booking the assistant makes on a call arrives as appointment.created while the call is still running, with call_id null. call_id is set when the call is recorded, and that change sends no appointment.updated: take the link from the appointment_ids of the call’s call.ended, or from the appointments feed. A reschedule on a call keeps the appointment’s ID and arrives as appointment.updated.

A deleted call leaves the call log and the change feed, and call.deleted is its only trace. The unresolved requests it left are deleted with it and send no event of their own: remove those in data.deleted.unresolved_request_ids. Each pending contact it proposed sends pending_contact.deleted with outcome discarded, and each appointment it booked sends appointment.updated with call_id null. The call.ended of a call deleted right after it was recorded can arrive after its call.deleted: do not recreate a call you have seen deleted.

The service and resource events cover what GET /v1/services and GET /v1/resources return, for a change made in the Talki app or through the API. A write sends one event for each service or resource it changed, those it changed in passing included: a new service’s own resource arrives as resource.created, and deleting a service sends service.deleted, resource.deleted for its own resource and resource.updated for each other resource it drew on. A resource event can name a service whose service.created has not arrived yet.

A new note, upload or website import arrives as document.created with status processing. The assistant uses it only once a document.updated reports it ready; that event, and one reporting failed, has origin.source system. See Manage the knowledge base.

Subscribing to * includes event types added later, so ignore a type you do not recognise.

A resource event’s data holds one key, object: the object named in the catalogue, exactly as the REST API returns it, so its fields are those of the object’s schema in the API reference. After a .deleted event it is the object as it was just before the delete.

Some events carry more than object, and call.ringing, the usage events and ping carry no object at all. Their fields are below. A field may be added to any data without a new dated version, so ignore one you do not recognise.

Sent while an inbound call rings, for every call the account receives, with origin.source voice. Never sent for a call refused before it is answered, or for a call Talki places itself. It carries no data.object, because the Call does not exist until the call ends: its fields sit directly in data.

Field Type Meaning
call_id string The id the Call will have: data.object.id on the call.ended that follows. GET /v1/calls/{id} answers 404 until then
location_id string The location the call is for, the same as the Call’s location_id
caller.contact_id string or null The Talki contact this number resolves to, or null
caller.phone_number string or null The caller’s number in E.164; null when it was withheld
caller.display_name string or null A name the owner entered or imported; null otherwise
caller.vip boolean Whether the number is on the VIP list of the call’s location (GET /v1/caller_rules). Always false when the number was withheld
started_at string When the call arrived, RFC 3339 UTC. The Call’s started_at is the same

Some rings never become a Call and are followed by no call.ended: the caller hangs up before the pickup, nothing is said on the call, or the call could not be recorded. A ring is in no feed, so use it to alert and build your record from call.ended. A ring can arrive late, or after its call.ended: do not alert for a call_id that has already ended.

{
"id": "evt_1d7f4a9c3e6b4208b5c1f8a2d6e0937c",
"type": "call.ringing",
"created_at": "2026-09-22T15:02:11.871342+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "voice",
"credential_id": null,
"idempotency_key": null
},
"data": {
"call_id": "6718188a-b1f4-46e6-9776-c78a536e1ed9",
"location_id": "0b7d4e19-8a2c-4f61-9d3e-5c1a7b2f8e40",
"caller": {
"contact_id": "8fbb25f2-4f3b-4496-8aea-f0fc66de512d",
"phone_number": "+393331234567",
"display_name": "Giulia Bianchi",
"vip": true
},
"started_at": "2026-09-22T15:02:11.204518Z"
}
}

One event per transfer, sent when the call ends, beside the call as object:

Field Type Meaning
target_label string The destination the assistant transferred to, by the name the owner gave it in Talki (e.g. Reception). Never the number behind it. Compare it case-insensitively
transferred_at string or null When the call was handed over, RFC 3339 UTC, possibly with microseconds
object Call The call, with the same transfer in transfers
{
"id": "evt_7a41c2e9d05b4f38a6e1b9c3d2f07e15",
"type": "call.transferred",
"created_at": "2026-09-22T15:04:06+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "voice",
"credential_id": null,
"idempotency_key": null
},
"data": {
"target_label": "Reception",
"transferred_at": "2026-09-22T15:03:30.512417Z",
"object": {
"id": "6718188a-b1f4-46e6-9776-c78a536e1ed9",
"outcome": "transferred",
"transfers": [
{"target_label": "Reception", "at": "2026-09-22T15:03:30.512417Z"}
],
"…": "…"
}
}
}

Beside the call as it was, deleted lists what was deleted with it:

Field Type Meaning
object Call The call as it was
deleted.unresolved_request_ids array of strings The unresolved requests deleted with the call. They send no event of their own: delete them from your copy
deleted.pending_contact_ids array of strings The pending contacts deleted with the call. Each also sends pending_contact.deleted with outcome discarded
{
"id": "evt_2c8e5b1a7f3d4096b4a2e6c0d9f1a8b3",
"type": "call.deleted",
"created_at": "2026-09-23T08:12:40+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "api",
"credential_id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90",
"idempotency_key": "del-6718188a"
},
"data": {
"object": { "id": "6718188a-b1f4-46e6-9776-c78a536e1ed9", "…": "…" },
"deleted": {
"unresolved_request_ids": ["6c1f9a74-1d0b-4c8e-9a3d-2f5b7e0c4d13"],
"pending_contact_ids": []
}
}
}

Beside the pending contact as it was:

Field Type Meaning
object PendingContact The pending contact as it was
outcome string approved: it became a contact, which sends its own contact.created or contact.updated. discarded: the owner or an app discarded it, the data retention removed it, or the call that proposed it was deleted
{
"id": "evt_e41b7d2c9a6f4835b0c3d8e2f1a7b964",
"type": "pending_contact.deleted",
"created_at": "2026-09-22T18:30:02+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "dashboard",
"credential_id": null,
"idempotency_key": null
},
"data": {
"object": { "id": "d58a2f17-6c4b-4e93-a1f0-7b3c9e2d5a68", "…": "…" },
"outcome": "approved"
}
}

usage.threshold_reached and usage.extra_credits_low carry no data.object: their fields sit directly in data. Both are checked when a call ends, so origin.source is voice, and neither is sent on a plan with no minute cap. An account’s webhooks receive them, and so does the app of a partner that pays for the account; no other app does.

Each fires once per crossing. usage.extra_credits_low then stays quiet until a purchase lifts the balance back over the floor or the owner changes the floor. When both would fire on the same call, only usage.extra_credits_low is sent, and usage.threshold_reached is not sent later for that crossing.

Seconds are the exact figures, named as in GET /v1/usage; the minute fields are whole-minute views of them for display. Compute with the seconds.

Sent by the call that leaves 25% or less of the allowance: the plan’s included seconds plus the extra seconds.

Field Type Meaning
used_seconds integer Seconds used in the current billing period, this call included: plan and extra minutes together
included_seconds integer Seconds included in the plan for the period, without extra minutes
allowance_seconds integer Everything the account may spend in the period: included_seconds plus the extra seconds it held when the period began and any added since. Equal to included_seconds when it has none
remaining_seconds integer allowance_seconds minus used_seconds, never below 0
percent_used number used_seconds as a percentage of allowance_seconds, to one decimal place. Above 100 when a call ran past the allowance
extra_seconds integer Extra seconds still unspent after this call: 0 when the account has none
minutes_used integer used_seconds rounded to the nearest whole minute
minutes_included integer Minutes included in the plan, without extra minutes
minutes_remaining integer remaining_seconds in whole minutes, rounded down
seconds_used, seconds_included, seconds_allowance, seconds_remaining, extra_seconds_balance integer The same values as used_seconds, included_seconds, allowance_seconds, remaining_seconds and extra_seconds, under their old names, which still work

remaining_seconds, minutes_remaining and percent_used measure against the allowance, while included_seconds and minutes_included are the plan alone. With extra minutes, minutes_remaining is therefore more than minutes_included minus minutes_used.

{
"id": "evt_9b2e6d1f4a8c4e07b3d5f2a1c6e8b940",
"type": "usage.threshold_reached",
"created_at": "2026-10-14T11:27:42+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "voice",
"credential_id": null,
"idempotency_key": null
},
"data": {
"used_seconds": 16245,
"included_seconds": 18000,
"allowance_seconds": 21600,
"remaining_seconds": 5355,
"extra_seconds": 3600,
"percent_used": 75.2,
"minutes_used": 271,
"minutes_included": 300,
"minutes_remaining": 89,
"seconds_used": 16245,
"seconds_included": 18000,
"seconds_remaining": 5355,
"extra_seconds_balance": 3600,
"seconds_allowance": 21600
}
}

Sent when a call ends with the extra-minutes balance below the floor the owner set in the Talki app, 10 minutes unless changed. An account with no extra minutes, or whose owner set the floor to 0, never sends it. A floor raised above the current balance sends it at the end of the next call.

Field Type Meaning
extra_seconds integer Extra seconds still unspent after this call
extra_minutes_balance integer extra_seconds in whole minutes, rounded down; can be 0
threshold_minutes integer The owner’s floor, in minutes, that the balance fell below
used_seconds integer Seconds used in the current billing period, plan and extra minutes together
minutes_included integer Minutes included in the plan, without extra minutes
extra_seconds_balance, seconds_used integer The same values as extra_seconds and used_seconds, under their old names, which still work
{
"id": "evt_5f0c3a7e2b9d4816a4e1c7b0d3f5a289",
"type": "usage.extra_credits_low",
"created_at": "2026-10-27T16:08:13+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "voice",
"credential_id": null,
"idempotency_key": null
},
"data": {
"extra_seconds": 505,
"extra_minutes_balance": 8,
"threshold_minutes": 10,
"used_seconds": 21095,
"minutes_included": 300,
"extra_seconds_balance": 505,
"seconds_used": 21095
}
}

Sent by Invia test to the one endpoint it was chosen on, whatever that endpoint subscribes to, with origin.source dashboard. It is signed like any other event, so use it to check your verifier.

Field Type Meaning
message string Always Test event from Talki
{
"id": "evt_0f6b3e8d1c2a4975a8e4d7c1b5f9e026",
"type": "ping",
"created_at": "2026-09-22T14:58:31+00:00",
"api_version": "2026-10-01",
"account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286",
"livemode": true,
"origin": {
"source": "dashboard",
"credential_id": null,
"idempotency_key": null
},
"data": {
"message": "Test event from Talki"
}
}
Content-Type: application/json
User-Agent: Talki-Webhooks/1.0
X-Talki-Signature: t=1790089446,v1=4a3d…
X-Talki-Event: call.ended
X-Talki-Delivery: evt_04d8e87897db4ecab459a456ea55e754

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

  • Delivery is at least once. Persist the event ID before applying side effects.
  • Any 2xx acknowledges; every other response is a failure. Redirects are not followed. The delivery log keeps the response status and the first 500 characters of the body.
  • Failures are retried after about 10 seconds, 1 minute, 10 minutes, 1 hour and 6 hours, each with ±20% random jitter: six attempts including the first.
  • Events can arrive out of order. Order them by the object’s updated_at. Account, WorkingHours, Call, UnresolvedRequest, Tag, PendingContact, Closure and Location have none, so order account.*, working_hours.*, call.*, unresolved_request.*, tag.*, pending_contact.*, closure.* and location.* events (location.settings_updated and location.working_hours_updated included) by the envelope’s created_at. Switching a Document on or off, and some status changes, leave its updated_at as it is, so order document.* events by the envelope’s created_at too. Never order by arrival.
  • A 410 Gone disables the endpoint immediately, without retrying.
  • Twenty consecutive failed deliveries disable the endpoint; a delivery counts once all six attempts are spent. One success resets the counter.
  • An attempt has 10 seconds in total — DNS, connection, TLS and your response together — so acknowledge first and do the work on your own queue.
  • A delivery still undelivered a day after it was due is marked failed as expired.
  • Delivery logs and stored payloads are kept for 30 days; the Talki app shows the latest 50 per endpoint.

When no HTTP response came back, the log records a class instead of a status:

Class Meaning
timeout The 10-second budget ran out
dns_error The host did not resolve; retried
connection_failed No connection could be opened; retried
tls_error The TLS handshake failed; retried
url_rejected The URL is no longer allowed — not port 443, or resolving to a non-public address. Not retried
plan_paused The account’s plan no longer includes webhooks. Not retried
endpoint_disabled The endpoint was disabled before the attempt
expired Still undelivered a day after it was due

If the account moves to a plan without webhooks, its endpoints are paused: the Talki app shows them as In pausa and events in that window are not recorded, so they cannot be redelivered later. Deliveries already queued fail as plan_paused. The pause clears by itself after an upgrade. Recover the gap from the change feeds below.

A redelivery from the Talki app sends the stored event again with a fresh set of six attempts. The envelope is unchanged — same id, same api_version — so your deduplication treats it as the event it is.

The signature covers the exact bytes of the request body. Parse X-Talki-Signature, then compute HMAC-SHA256 over:

<timestamp>.<raw request body>

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

Rotating the secret can keep the previous one signing for up to 7 days (24 hours by default). During that overlap the header carries one v1 per secret:

X-Talki-Signature: t=1790089446,v1=<previous secret>,v1=<current secret>

Accept the request if any v1 matches the secret you hold. That lets you deploy the new secret whenever you are ready: until the overlap ends, both the old and the new configuration verify. The samples below do this; a parser that keeps only one v1 fails for part of every rotation.

The Python and TypeScript SDKs implement this check, rotation included. Without an SDK, use one of the samples below.

import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyTalkiWebhook(rawBody, signatureHeader, secret) {
let t
const signatures = []
for (const part of signatureHeader.split(',')) {
const [key, value] = part.split('=', 2)
if (key === 't') t = value
else if (key === 'v1' && value) signatures.push(Buffer.from(value, 'hex'))
}
const timestamp = Number(t)
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
throw new Error('stale webhook')
}
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest()
const valid = signatures.some(
(received) => received.length === expected.length && timingSafeEqual(received, expected),
)
if (!valid) throw new Error('invalid webhook signature')
return JSON.parse(rawBody.toString('utf8'))
}

Capture the Buffer before any JSON middleware touches the body.

import hashlib
import hmac
import json
import time
def verify_talki_webhook(raw_body: bytes, signature_header: str, secret: str):
t = None
signatures = []
for part in signature_header.split(","):
key, _, value = part.partition("=")
if key == "t":
t = value
elif key == "v1" and value:
signatures.append(value)
if t is None or abs(time.time() - int(t)) > 300:
raise ValueError("stale webhook")
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(sig, expected) for sig in signatures):
raise ValueError("invalid webhook signature")
return json.loads(raw_body)

Delivery is at least once, not guaranteed: an event can still be lost, for example one emitted during a plan pause. Webhooks give you latency; the change feeds give you completeness:

  • Calls: GET /v1/calls?sync=true.
  • Appointments: GET /v1/appointments.
  • Contacts: GET /v1/contacts, without phone or tag_id. A deleted contact is absent from it, so reconcile against a full walk.
  • Unresolved requests: GET /v1/unresolved_requests.
  • Services: GET /v1/services. A deleted service is absent from it, so reconcile against a full walk from no cursor.
  • Resources: GET /v1/resources. Reconcile deletions the same way.
  • Account settings, locations, weekly hours, closures, tags, pending contacts, caller rules and documents: GET /v1/account, GET /v1/locations, GET /v1/working_hours, GET /v1/closures, GET /v1/tags, GET /v1/pending_contacts, GET /v1/caller_rules and GET /v1/documents return the current state; there is no feed. A deleted document is absent from the list, so reconcile against a full walk.
  • A call’s inbox state is not in the call feed: read read_at and archived_at again with GET /v1/calls.

Store each feed’s next_cursor after every non-empty page, the last one included. After an outage, resume from it and upsert each object by Talki ID. A cursor belongs to one endpoint and cannot be shared.

The delivery log stays available for 30 days, for inspection and manual redelivery.