# Webhooks

Source: https://developers.talki.it/webhooks/

Event catalogue, envelope, signatures, retries and recovery.

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](/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

```json
{
  "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](#event-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](/sandbox/), so one receiver can serve both and route on it |
| `origin` | What caused the change, below |
| `data` | The payload: see [Event payloads](#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

| 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](#callringing) 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`](#calltransferred) 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`](#calldeleted) 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`](#pending_contactdeleted) (`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](#usagethreshold_reached) are in `data` |
| `usage.extra_credits_low` | The extra-minutes balance falls below the floor the owner set | None; [balances](#usageextra_credits_low) are in `data` |
| `ping` | The account owner chooses **Invia test** | None; [`data.message`](#ping) 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](/guides/configure-assistant/#keep-a-copy-in-sync).

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](/guides/locations/#keep-a-copy-in-sync).

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](/guides/knowledge-base/#keep-a-copy-in-sync).

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

## Event payloads

A resource event's `data` holds one key, `object`: the object named in the
[catalogue](#event-catalogue), exactly as the REST API returns it, so its
fields are those of the object's schema in the [API reference](/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`

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.

```json
{
  "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`

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` |

```json
{
  "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`

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` |

```json
{
  "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`

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 |

```json
{
  "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

`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](/partner-billing/#usage-events); 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`

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`.

```json
{
  "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`

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 |

```json
{
  "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
  }
}
```

### `ping`

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` |

```json
{
  "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

```http
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 semantics

- 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.

### 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

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

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

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

```text
<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

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**:

```http
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](/sdks/python/#verify-a-webhook) and
[TypeScript](/sdks/typescript/#verify-a-webhook) SDKs implement this check,
rotation included. Without an SDK, use one of the samples below.

### Node.js

```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

```python
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)
```

## 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`, 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.
