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.
Envelope
Section titled “Envelope”{ "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 catalogue
Section titled “Event catalogue”| 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.
Event payloads
Section titled “Event payloads”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.
call.ringing
Section titled “call.ringing”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" }}call.transferred
Section titled “call.transferred”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"} ], "…": "…" } }}call.deleted
Section titled “call.deleted”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": [] } }}pending_contact.deleted
Section titled “pending_contact.deleted”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 events
Section titled “Usage events”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.
usage.threshold_reached
Section titled “usage.threshold_reached”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 }}usage.extra_credits_low
Section titled “usage.extra_credits_low”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" }}Delivery headers
Section titled “Delivery headers”Content-Type: application/jsonUser-Agent: Talki-Webhooks/1.0X-Talki-Signature: t=1790089446,v1=4a3d…X-Talki-Event: call.endedX-Talki-Delivery: evt_04d8e87897db4ecab459a456ea55e754Header names are case-insensitive. X-Talki-Delivery equals the envelope id.
Delivery semantics
Section titled “Delivery semantics”- Delivery is at least once. Persist the event ID before applying side effects.
- Any
2xxacknowledges; 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 orderaccount.*,working_hours.*,call.*,unresolved_request.*,tag.*,pending_contact.*,closure.*andlocation.*events (location.settings_updatedandlocation.working_hours_updatedincluded) by the envelope’screated_at. Switching a Document on or off, and somestatuschanges, leave itsupdated_atas it is, so orderdocument.*events by the envelope’screated_attoo. Never order by arrival. - A
410 Gonedisables 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.
Delivery log errors
Section titled “Delivery log errors”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 |
Plan changes
Section titled “Plan changes”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.
Redelivery
Section titled “Redelivery”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.
Signature verification
Section titled “Signature verification”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.
Secret rotation
Section titled “Secret rotation”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.
Node.js
Section titled “Node.js”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.
Python
Section titled “Python”import hashlibimport hmacimport jsonimport 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)Recovery
Section titled “Recovery”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, withoutphoneortag_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_rulesandGET /v1/documentsreturn 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_atandarchived_atagain withGET /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.
