# Talki Developers — full documentation Generated from https://developers.talki.it. The REST API is in private beta on the Business plan; webhooks are available from Pro upward. Base URL: https://api.talki.it/v1. Dated contract: 2026-10-01 (draft). The OpenAPI document at https://developers.talki.it/openapi.yaml owns field names, types and enum values; where this prose disagrees with it, the schema wins. --- # Overview Source: https://developers.talki.it/overview/ What the private-beta API can read, sync, and change. This API lets another system, usually a CRM, keep its own copy of the calls, appointments and contacts a Talki phone agent handles, and make a narrow set of changes, under its own credential rather than a user's session. You do not need a Talki customer to start. Registering an app issues a [sandbox](/sandbox/): a real Talki account, with a real business in it, that you own and can reset. ## Capabilities | Capability | Surface | Status | |---|---|---| | Validate a key and identify its account | `GET /v1/account` | Private beta | | [Configure the assistant](/guides/configure-assistant/): its settings, voice, weekly hours, closures and special openings | `/v1/account`, `/v1/voices`, `/v1/working_hours`, `/v1/closures` | Private beta | | Read the business's [locations](/guides/locations/), and filter, configure and book by location | `GET /v1/locations`, `?location_id=` | Private beta | | [Set up call forwarding](/guides/configure-assistant/#call-forwarding) and finish onboarding | `/v1/account`, `/v1/forwarding_check`, `POST /v1/account/complete_onboarding` | Private beta | | Read the talk time used and left, and the talk time of past months | `GET /v1/usage`, `GET /v1/usage/history` | Private beta | | Read and search calls or resume a call change feed; mark them read and archive them; delete a call for good | `/v1/calls`, `POST /v1/calls/mark_all_read` | Private beta | | Count calls, read call statistics, export calls as PDF or Excel | `/v1/calls/count`, `/v1/calls/stats`, `/v1/calls/export`, `/v1/calls/{call_id}/export` | Private beta | | Read the calls Talki refused before answering | `/v1/refused_calls` | Private beta | | Sync, create, reschedule, cancel and restore appointments | `/v1/appointments` | Private beta | | Sync, find, upsert and delete curated contacts | `/v1/contacts` | Private beta | | Approve or discard the names the assistant heard | `/v1/pending_contacts` | Private beta | | Label contacts with tags | `/v1/tags` | Private beta | | Manage VIP and blocked numbers, and read the calls the block refused | `/v1/caller_rules` | Private beta | | Sync, create, update and delete services | `/v1/services` | Private beta | | Sync capacity, create shared resources, adjust what is safe to adjust, and delete resources | `/v1/resources` | Private beta | | Follow, resolve and dismiss questions the assistant could not answer, and record the answer given | `/v1/unresolved_requests` | Private beta | | [Teach the assistant](/guides/configure-assistant/#teach-the-assistant-an-answer) the answer to a resolved question | `POST /v1/unresolved_requests/{request_id}/teach` | Private beta | | Read and write the assistant's [knowledge base](/guides/knowledge-base/): text notes, uploaded files and imported websites | `/v1/documents` | Private beta | | Find advisory bookable windows | `GET /v1/availability` | Private beta | | Receive call, appointment, contact, pending-contact, tag, caller-rule, unresolved-request, document, account, hours, closure, service, resource, location and usage events | Signed [webhooks](/webhooks/) | Available from Pro | | Let many Talki customers connect your platform | [OAuth](/oauth/) | Private beta | | Pay for your customers' Talki subscriptions and manage their plans | [Managed billing](/partner-billing/) | Private beta | | Learn when an account installs, disconnects or loses access | [App events](/app-events/) | Private beta | | Build against a Talki account of your own | [`/v1/sandbox`](/sandbox/) | Private beta | The [generated API reference](/reference/) is the complete REST contract. An operation absent from it is not a public endpoint. ## What is not exposed - **Call audio.** - **Token counts and model costs.** - **Scheduling rules and internal state** — service exclusions, external-calendar sync state, unit assignments, the assistant's conversation state. Capacity is the exception, published as [resources](/reference/operations/listresources/). - **Webhook endpoint management.** Endpoints are created in the Talki app only. ## Plans The REST API is a **Business plan** feature. A valid credential on a lower plan fails with `plan_upgrade_required`, so an integration can ask the account owner to upgrade rather than report a generic permission failure. Webhooks are available from **Pro** upward. Reading the REST API — change feeds and fetching an object by ID — requires Business. An account whose subscription your app pays for reaches `/v1` through your app on every plan; see [Managed billing](/partner-billing/#api-access-on-any-plan). ## Base URL ```text https://api.talki.it/v1 ``` JSON over HTTPS. Paths in the reference already include `/v1`. --- # Quickstart Source: https://developers.talki.it/quickstart/ Get a sandbox, make it take a call, and receive the signed webhook it produces. Six steps: a Talki account of your own, a call it took, that call read back, and the signed webhook it produced. You do not need a Talki customer for any of it. Registering an app gets you a *sandbox*: a real Talki account with a real business in it, a salon in Verona with services, opening hours, four weeks of calls and a contact book, that you own and can reset. ## 1. Register an app and take your sandbox key Register your app in the [console](https://console.talki.it). The sandbox is issued at that moment, not when the app is approved; approval decides only whether real businesses may install you. The console shows the sandbox's account ID and mints a key for it. The secret is shown **once**; a lost key is replaced, never recovered. ```text talki_live_… production talki_test_… test mode — every sandbox key is one of these ``` Keep it in a server-side secret store: ```bash export TALKI_API_KEY='talki_test_…' ``` :::note[The sandbox has no phone number] It cannot receive a real call. You trigger one instead — step 5. ::: ## 2. Validate the credential ```bash curl --fail-with-body https://api.talki.it/v1/account \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Accept: application/json" ``` ```json { "id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "business_name": "Salone Bellini", "timezone": "Europe/Rome", "livemode": false, "business_information": null, "instructions": null, "voice": "Sulafat" } ``` Needs `account:read`. `livemode` is `false` because the credential does not act on real data: it is a test-mode key, or an app installed on a sandbox. Every webhook carries the same flag, so one receiver can serve test and production traffic and still tell them apart. :::tip[Use an SDK] The steps below use curl. The same call with the [Python SDK](/sdks/python/) is `talki.account.get()`, and with the [TypeScript SDK](/sdks/typescript/) `await talki.account.get()`; both read `TALKI_API_KEY` from the environment. ::: ## 3. Read the latest calls ```bash curl --fail-with-body 'https://api.talki.it/v1/calls?limit=10' \ -H "Authorization: Bearer $TALKI_API_KEY" ``` Newest first. Follow `next_cursor` while `has_more` is true; on the last page it is null. ## 4. Establish a recovery cursor A durable integration also walks the call change feed: ```bash curl --fail-with-body 'https://api.talki.it/v1/calls?sync=true&limit=100' \ -H "Authorization: Bearer $TALKI_API_KEY" ``` ```json { "data": [], "next_cursor": null, "has_more": false } ``` When `data` is non-empty, store `next_cursor` even if `has_more` is false: with `sync=true` it is your position, not a pointer to more data. Pass it back unchanged on the next poll. A sync cursor is refused in the default list. Appointments, contacts, services, resources and unresolved requests use the same loop without a `sync` flag. See [Core concepts](/concepts/#lists-and-change-feeds). ## 5. Trigger a call in the sandbox Ask the sandbox to have had a call and the post-call flow runs: the call record is written, the appointment is booked under the same capacity rules as any booking, a caller who gives a name is proposed as a pending contact, and **signed webhooks are delivered to your endpoint**. Set up your receiver first — **Agente → Integrazioni → Webhooks** in the Talki app, logged in as the sandbox — and save the `whsec_…` signing secret. :::caution[localhost will not work] Loopback, private, link-local and CGNAT destinations are refused at registration and again at delivery. Use a public HTTPS tunnel while developing. ::: List the available scenarios: ```bash curl --fail-with-body https://api.talki.it/v1/sandbox/scenarios \ -H "Authorization: Bearer $TALKI_API_KEY" ``` ```json [ { "key": "booking_created", "description": "A caller books an appointment. The agent finds a slot and confirms it.", "events": ["call.ringing", "call.ended", "appointment.created"], "duration_seconds": 74 } ] ``` The `events` array is part of the contract: assert that those events arrive. The call can send others too, such as `pending_contact.created`; see [Sandbox](/sandbox/). ```bash curl --fail-with-body -X POST https://api.talki.it/v1/sandbox/calls \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scenario": "booking_created", "caller_phone": "+393480000001"}' ``` Verify `X-Talki-Signature` against the raw body before parsing it. The [Webhooks guide](/webhooks/) has Node.js and Python examples. ## 6. Reset the sandbox ```bash curl --fail-with-body -X POST https://api.talki.it/v1/sandbox/reset \ -H "Authorization: Bearer $TALKI_API_KEY" ``` The history comes from a fixed seed, so it is identical every time and assertions against it hold across runs. Your credentials, your app's installation and the sandbox's services survive. Your webhook endpoints do not: register them again after a reset. See [Sandbox](/sandbox/) for the full scenario list and what each one emits. --- # Authentication Source: https://developers.talki.it/authentication/ Send, store, scope, expire, and roll API keys safely. ## API keys A key belongs to one Talki account. Send it as a bearer token on every request: ```http Authorization: Bearer talki_live_… ``` `talki_live_…` keys act on production data. `talki_test_…` keys belong to a [sandbox](/sandbox/) and make `GET /v1/account` return `"livemode": false`. Account owners mint keys in the Talki app; there is no API for it. A platform serving many accounts uses [OAuth](/oauth/) instead. - The secret is shown once, at creation, and cannot be retrieved later. - A key carries a display name, a non-secret hint, scopes, timestamps, an optional expiry and a revocation time. - Revocation takes effect immediately. ### Format After the prefix a key has 49 characters: 43 random, then a 6-character checksum. Some keys have 43 characters and no checksum; both forms are valid. Store the key as an opaque string, and do not validate its length beyond what your secret store needs. The checksum lets a secret scanner or a paste handler reject a mistyped key without calling Talki: it is the CRC-32 of everything before it — prefix included — written in base62 (`0-9A-Za-z`, most significant digit first, zero-padded to 6). It is not a security control; anyone can compute it. ### Storage Server-side, encrypted at rest, one key per integration and environment. ### Expiry and rolling A key can be given an expiry, at most two years ahead. After it, the key is refused like a revoked one. To replace a key without downtime, the owner **rolls** it in the Talki app: a new key with the same name, scopes and version pin is issued at once, and the old one keeps working for 24 hours by default, or stops immediately if it leaked. Deploy the new key, check it with `GET /v1/account`, and let the old one lapse. A key can be rolled once; roll its replacement next time. ## Scopes Read and write are separate scopes; request only the ones your integration uses. | Scope | Grants | |---|---| | `account:read` | Read the authenticated account, its locations, and its assistant settings, phone setup, usage, voices, working hours, closures and special openings | | `account:write` | Change the assistant's settings and the forwarding number, run a forwarding check, complete onboarding, replace the working hours, and manage closures and special openings | | `calls:read` | List, sync, retrieve, count and export calls, their statistics, and refused calls. An export carries the calls' appointments only with `appointments:read` too | | `calls:write` | Mark calls read or unread, and archive or restore them | | `calls:delete` | Delete calls for good, with their transcript, unresolved requests and pending contacts | | `transcripts:read` | Retrieve the automatic transcript of a call | | `appointments:read` | Sync and retrieve appointments | | `appointments:write` | Create, reschedule, cancel, and restore appointments | | `contacts:read` | Sync and retrieve curated contacts, pending contacts, tags, and VIP and blocked numbers | | `contacts:write` | Upsert and delete contacts, approve or discard pending contacts, manage tags, and manage VIP and blocked numbers | | `services:read` | Sync and retrieve services | | `services:write` | Create a service with a fixed duration, update the safe writable service fields, and delete a service | | `resources:read` | Sync and retrieve capacity: resources, unit classes, service links | | `resources:write` | Create a `shared` resource, rename a resource, set the capacity of a `shared` one, and delete a resource | | `unresolved:read` | Sync and retrieve questions the assistant could not answer | | `unresolved:write` | Resolve, dismiss or reopen those questions, and set their `answer` | | `knowledge:read` | List and retrieve the assistant's knowledge-base documents, their indexing status, the text read from them, a website's pages, and the original uploaded files | | `knowledge:write` | Add, replace, rename, switch off and delete knowledge-base documents and a website's pages, look for a site's pages, and teach the assistant the answer to a resolved question. What it adds, the assistant tells every caller | | `availability:read` | Query bookable windows | `calls:read` does not include `transcripts:read`, `calls:write` does not include `calls:delete`, and neither `unresolved:write` nor `knowledge:read` includes `knowledge:write`. `transcripts:read`, `calls:delete` and `knowledge:write` are opt-in: the account owner grants each on its own, never through a preset, so an integration that reads transcripts, deletes calls or writes the knowledge base asks for the scope explicitly and handles `insufficient_scope` when the owner leaves it out. OAuth apps request the same scopes; see [OAuth apps](/oauth/). On resources, writing is narrower than reading: unit classes and existing service links are read-only, a capacity below what is already booked is refused, and so is deleting the default resource or one a service still needs. See [Configure the assistant](/guides/configure-assistant/) for the settings `account:write` changes. A request outside the key's grants fails with `insufficient_scope`. That is not `plan_upgrade_required`, which means the credential is valid but the account's plan does not include the REST API, nor `plan_feature_unavailable`, which means the plan includes the API but not the feature asked for: a custom `call_opening`, or exporting calls over a date range. ## Failures - Malformed, unknown, expired and revoked keys all return `401 invalid_credentials`, indistinguishably. A request with no credential gets the same code with a message saying none was supplied. - Twenty failed authentications in a minute from one address block **every** request from it for the rest of the minute with `429 ip_rate_limited` — valid keys included. A broken deployment retrying a bad key can lock out a healthy one on the same egress address. - A valid key without the endpoint's scope returns `403 insufficient_scope`. - A valid key on a plan without API access returns `403 plan_upgrade_required`. - A closed account or an inactive subscription has its own stable code. See [Errors and rate limits](/errors/) for the response envelope. ## OAuth Authorization code with PKCE, for platforms that serve many Talki accounts. A token reaches the same account, paths and scopes as a key. See [OAuth apps](/oauth/). --- # OAuth apps Source: https://developers.talki.it/oauth/ Let Talki customers connect your platform — authorization code with PKCE, tokens, refresh, and disconnection. Use OAuth when your platform serves many Talki customers: each one connects your app from a consent screen instead of pasting an API key. The resulting token reaches the same `/v1` paths and scopes as a key. ## Register an app Register it in the [console](https://console.talki.it). You get: - a **client ID**, `talki_cid_…`, which is public; - a **client secret**, `talki_cs_…`, shown once. Keep it server-side: there are no public clients, so every token request authenticates with it. A new app is **pending** until it is reviewed, and only an approved app can be authorized. Adding a redirect URI or a scope, or changing the app's name, website or logo, sends an approved app back to review. Redirect URIs must be `https://` (plain `http://` only for `localhost`, `127.0.0.1` and `::1`), without a fragment, credentials or wildcards. Up to 10. The authorization server metadata is at: ```text https://api.talki.it/.well-known/oauth-authorization-server ``` ## Authorize Send the customer's browser to the consent page: ```text https://app.talki.it/autorizza ?response_type=code &client_id=talki_cid_4Hq2mZ8rVt1LxN3pYw7KcA &redirect_uri=https%3A%2F%2Fcrm.example.com%2Ftalki%2Fcallback &scope=calls%3Aread%20appointments%3Aread &state=Zk3q9… &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 ``` | Parameter | Rule | |---|---| | `response_type` | `code` | | `redirect_uri` | Exactly one of the app's registered URIs, byte for byte | | `scope` | Space-separated; each must be among the app's registered scopes | | `state` | Recommended; up to 500 characters, returned unchanged | | `code_challenge` | Required: the base64url SHA-256 of your `code_verifier` | | `code_challenge_method` | Required: `S256`. `plain` is refused | | `prompt` | Optional; the only accepted value is `consent` | The customer signs in to Talki and approves. They may untick scopes, so read what was granted from the token response, never from your request. The browser returns to your `redirect_uri` with `code`, `state` and `iss`. Check that `state` is yours and `iss` is `https://api.talki.it`. A refusal, or a problem with a parameter other than the client or redirect URI, arrives as `error` and `error_description` instead — `access_denied` when the customer declines. An unknown client or an unregistered redirect URI is never redirected; the customer sees the error on Talki's page. ### Reconnecting Authorizing again adds the new scopes to what the account already granted. To replace the grant with exactly the scopes requested — to narrow it — send `prompt=consent`. Every successful authorization invalidates the installation's earlier tokens, even when the scopes are unchanged. Keep only the newest pair. ## Exchange the code ```bash curl --fail-with-body https://api.talki.it/oauth/token \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://crm.example.com/talki/callback \ -d code_verifier="$CODE_VERIFIER" ``` Authenticate with HTTP Basic, or with `client_id` and `client_secret` in the form body — not both. `redirect_uri` must be sent again and match. ```json { "access_token": "talki_at_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "talki_rt_…", "scope": "appointments:read calls:read", "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "installation_id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90", "livemode": true, "account_name": "Studio Bellini" } ``` A code is valid for 5 minutes and works once: - **Presenting a spent code again revokes every token of that installation.** Treat a duplicate callback as an error, not a retry. - Exchanging a code invalidates any older, unexchanged code for the same app and account; those return `invalid_grant`. Store tokens per `installation_id`. Use the access token exactly like an API key: ```http Authorization: Bearer talki_at_… ``` ## Refresh Access tokens last 1 hour. A refresh token lasts 60 days from when it was issued, and each refresh issues a new one, so an installation in use never expires. ```bash curl --fail-with-body https://api.talki.it/oauth/token \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -d grant_type=refresh_token \ -d refresh_token="$REFRESH_TOKEN" ``` **The refresh token rotates on every use: store the new one before using the new access token.** Presenting a rotated refresh token again revokes every token of the installation, and the customer has to reconnect. One exception covers a lost response: within 60 seconds, the previous refresh token is accepted once more, provided the pair it produced has not been used. `scope` may name a subset of the grant, to get a token pair with fewer scopes. The grant itself does not shrink. ## Token errors `/oauth/token` and `/oauth/revoke` answer in the OAuth shape, not the API envelope: ```json { "error": "invalid_grant", "error_description": "The authorization code is invalid or expired." } ``` | `error` | Meaning | |---|---| | `invalid_client` | Wrong client ID or secret (`401`) | | `invalid_grant` | Code or refresh token expired, spent, revoked, or issued for a different redirect URI or client | | `invalid_scope` | A refresh asked for a scope outside the grant | | `invalid_request` | A parameter is missing or malformed | | `unsupported_grant_type` | Not `authorization_code` or `refresh_token` | | `rate_limit_exceeded` | Too many token requests (`429`, with `Retry-After`) | | `ip_rate_limited` | Too many failed authentications, or requests, from your address (`429`, with `Retry-After`); see [per-address limits](/errors/#per-address-limits) | ## Revoke ```bash curl --fail-with-body https://api.talki.it/oauth/revoke \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -d token="$REFRESH_TOKEN" ``` **Revoking any live token of an installation disconnects the whole installation**: every token stops working and the customer has to reconnect. It always answers `200 {}`, including for a token that is unknown or already dead. ## Disconnection A customer can disconnect your app from the Talki app at any time. After that, API calls with its token return `401 invalid_credentials`, a refresh returns `invalid_grant`, and the app receives [`installation.revoked`](/app-events/). ## List your installations Your server can list every account connected to your app, with the client credentials over HTTP Basic. It lists your app's installations only, and needs no customer's token. ```bash curl --fail-with-body "https://api.talki.it/oauth/app/installations?status=active&limit=100" \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" ``` ```json { "object": "list", "data": [ { "object": "installation", "id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90", "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "business_name": "Studio Bellini", "scopes": ["appointments:read", "calls:read"], "missing_scopes": [], "status": "active", "access": "active", "access_suspended_reason": null, "livemode": true, "test": false, "test_expires_at": null, "created_at": "2026-09-30T08:12:44.120000+00:00", "last_used_at": "2026-10-15T16:40:00+00:00", "revoked_at": null } ], "has_more": true, "next_cursor": "djE6MjAyNi0wOS0zMFQwODoxMjo0NC4xMiswMDowMHw1ZDBlOGEyYy03YjQx…" } ``` | Parameter | Rule | |---|---| | `status` | `active` or `revoked`. Omitted: both | | `livemode` | `true` or `false`. Omitted: both | | `limit` | 1–100, default 25 | | `cursor` | The `next_cursor` of the previous page | Rows come newest connection first. While `has_more` is `true`, request the next page with `cursor` set to `next_cursor`; on the last page `next_cursor` is `null`. To fetch every active account: ```bash cursor="" while :; do page=$(curl --fail-with-body -s -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ "https://api.talki.it/oauth/app/installations?status=active&limit=100${cursor:+&cursor=$cursor}") echo "$page" | jq -r '.data[] | [.account_id, .business_name] | @tsv' [ "$(echo "$page" | jq -r .has_more)" = true ] || break cursor=$(echo "$page" | jq -r .next_cursor) done ``` - `status` is `revoked` once the customer disconnected. A revoked row keeps its dates and scopes, but `business_name` is `null`. - `access` is `suspended` when API calls for the account are refused right now, with the error code in `access_suspended_reason` (for example `subscription_inactive`); `none` on a revoked row. - `missing_scopes` lists the scopes your app is registered for that this customer did not grant. - `last_used_at` is the last API call with this installation's tokens, accurate to a few minutes. Listing does not change it. - `test` is `true` for a test account your app created; `test_expires_at` is when it is erased. The list gives no access to the accounts: reading one still takes its installation's access token and scopes. Errors use the API envelope: `401 invalid_client` for wrong credentials, `400 invalid_parameter` or `invalid_cursor` for a bad query, `409 client_disabled` for an app Talki has disabled. ## Limits - `/oauth/token`: 600 requests per minute per app. Failed client authentication also counts against the per-address budget in [Errors and rate limits](/errors/#rate-limits). - `/oauth/app/installations`: 120 requests per minute per address. - `/oauth/app/usage` ([monthly usage](/partner-billing/#read-usage-from-your-server)): 60 requests per minute per address. - API calls with an access token are limited per **installation**, as an API key is limited per account. - An access token cannot drive the [sandbox](/sandbox/) controls (`/v1/sandbox/*`): they answer `403 sandbox_api_key_required`. Use the sandbox key from the console. The exception is a [test account](/partner-billing/#test-accounts) your app created, which takes fake calls with your app's token. ## Rotate the client secret Rotate it in the console. The previous secret stops working **at once**, unless you ask for an overlap (up to 7 days) to deploy the new one first. The contract version your app is served at is pinned on the app, for all of its installations — see [Versioning](/concepts/#versioning). --- # Sandbox Source: https://developers.talki.it/sandbox/ A Talki account of your own — a seeded business, callable scenarios, and a repeatable reset. A sandbox is a **real Talki account** that belongs to you, served by the same `/v1` contract as production and marked by `livemode: false`. Registering an app in the [console](https://console.talki.it) issues one immediately, with no Talki customer involved. ## What the account contains A hair salon in Verona, seeded with: - four services with different durations and capacities, one of which takes only one booking at a time; - opening hours with a real closing day — Monday and Sunday are shut; - four weeks of answered calls with summaries, in all three inbox states (unread, read, archived); - the appointments those calls booked, past and upcoming, plus one cancelled in each direction; - a contact book where some callers are named and some are bare numbers; - open follow-up questions the assistant could not answer. `display_name` carries only a name the owner curated or imported; one the assistant extracted from a transcript is not published, and that contact stays a bare number. The seed contains both cases. ### Locations The sandbox has `multi_sede: true` and three [locations](/guides/locations/): the main one, which holds the history above, and two branches, **Borgo Trento** (active) and **San Zeno** (inactive). Each branch has its own copy of the main location's services, on a default resource of its own, and a quieter history of calls, bookings, unresolved requests and one closure. San Zeno behaves as a location switched off by a downgrade: its records are there, and booking at it is refused with `location_inactive`. No location has a Talki number. Scenario calls are always made at the main location. If `GET /v1/locations` lists only the main location, `POST /v1/sandbox/reset` adds the two branches. ## Limits **A sandbox has no phone number and cannot be given one.** It cannot receive real calls, is never billed, and sends no email. Its [knowledge base](/guides/knowledge-base/) holds at most 5 MB, with files of at most 1 MB, website imports of at most 5 pages and PDFs of at most 20 pages, or the plan's own limits where lower. A partner's test account has the same caps. ## Trigger a call ```bash curl --fail-with-body -X POST https://api.talki.it/v1/sandbox/calls \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scenario": "booking_created", "caller_phone": "+393480000001"}' ``` Everything downstream is real: the call record, the appointment (subject to the same capacity rules as any booking), a pending contact for a caller who gives a name, and **signed webhook deliveries to your endpoint**. Reuse a `caller_phone` to build a caller with history; vary it to exercise the first-time path. ### Scenarios | `scenario` | What happened | Events | |---|---|---| | `booking_created` | A caller books. The agent finds a slot and confirms it. | `call.ringing`, `call.ended`, `appointment.created` | | `booking_rescheduled` | A caller moves an existing booking. | `call.ringing`, `call.ended`, `appointment.updated` | | `booking_cancelled` | A caller cancels. The slot is released. | `call.ringing`, `call.ended`, `appointment.cancelled` | | `caller_unresolved` | A question the agent cannot answer, taken down for a callback. | `call.ringing`, `call.ended` | | `contact_created` | A first-time caller gives their name. | `call.ringing`, `call.ended` | | `call_no_action` | Someone asks a question and rings off. | `call.ringing`, `call.ended` | | `call_transferred` | The agent hands the caller to a person. | `call.ringing`, `call.ended`, `call.transferred` | `GET /v1/sandbox/scenarios` returns the same list at runtime, so a test suite can enumerate them instead of hard-coding this table. The `events` array is part of the contract: assert that those events arrive. It lists the events the scenario is about, not every event the call sends. A caller who gives a name (`booking_created`, `booking_rescheduled`, `booking_cancelled`, `contact_created`) is also proposed as a contact, as on a real call: `pending_contact.created` for a number with no contact or pending contact yet, `pending_contact.updated` when an existing pending contact's name changes. `call.ringing` carries the same ID in `data.call_id` as the response's `call_id`. It is sent after the scenario's booking step, so an `appointment.*` event can come before it, and a scenario refused with `400` sends nothing. `caller.vip` is true when `caller_phone` is on the main location's VIP list: add it with `POST /v1/caller_rules` to see it. A `caller_phone` that is not a number behaves like a withheld one, and `caller.phone_number` is null. `booking_rescheduled` and `booking_cancelled` act on the next upcoming appointment. If the agenda has none left, they answer `400 sandbox_not_ready`; reset to continue. ## Reset ```bash curl --fail-with-body -X POST https://api.talki.it/v1/sandbox/reset \ -H "Authorization: Bearer $TALKI_API_KEY" ``` The history comes from a **fixed seed**, so it is identical every time and assertions against it hold across runs. A reset rebuilds data only: your API key keeps working, your app's installation survives, the services and the locations are still there, and the branches' history is rebuilt with the rest. The knowledge base is left as it is: delete the documents your tests create. Webhook endpoints are not kept: a reset deletes the sandbox's endpoints and their deliveries, puts the seed's one paused endpoint in their place, and drops the account events still queued for your app. Register your endpoints again after each reset. :::note[Test accounts] An app enabled for headless provisioning can also run these scenarios on the [test accounts](/partner-billing/#test-accounts) it creates, with its own access token. A test account is never reset. ::: ## `livemode` Every webhook envelope carries `livemode`: ```json { "id": "evt_9f2c…", "type": "appointment.created", "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "livemode": false, "origin": { "source": "voice", "credential_id": null }, "data": { } } ``` `false` means the event did not really happen: it came from a sandbox or a test-mode credential. `GET /v1/account` reports the same flag for the credential you are holding, so staging and production can share one receiver and still keep fixtures out of the live system. :::note[`origin.source` on scenario calls] A scenario call has `origin.source: "voice"`. Tell sandbox events apart with `livemode`. ::: ## Going live Same code, same base URL; only the credential changes. Real businesses can install your app once it is approved. --- # Core concepts Source: https://developers.talki.it/concepts/ Resource ownership, identifiers, change feeds, idempotency, and compatibility. ## Public objects Audio and billing are not exposed; see [Overview](/overview/#what-is-not-exposed). Capacity is published as a [resource](#capacity). An object has the same fields in a REST response and in an event's `data.object`. ## Sources of truth - **Talki is the source of truth for Talki appointments.** API writes follow the same rules as bookings made by phone and can be refused with the same conflicts. - **External calendars contribute busy time.** Google and Outlook events can narrow the availability Talki reports. They constrain it and receive mirrors of it; they do not replace Talki's appointment record. - **Availability is advisory.** A window is true when computed, and another caller can still take it. Only a successful `POST /v1/appointments` holds the time. ## Identifiers Talki IDs are opaque and case-sensitive. Do not parse them, infer a type from their shape, or generate one. Store the ID next to your own record, so webhook redelivery and feed recovery update rather than duplicate. Contacts are matched to calls by normalised phone number. `external_id` and `system` on contact upsert are your cross-references; they do not replace Talki's contact identity. ## Locations An account works from one or more locations, exactly one of them the main location. Calls, refused calls, unresolved requests, appointments, services, resources, documents and caller rules carry the `location_id` they belong to, and closures carry it when they apply at one location only. Lists of these records take `?location_id=` to return one location's records, and the account, weekly hours and forwarding check are read and written one location at a time. When `multi_sede` on the account is `false`, there is only the main location. See [Work with locations](/guides/locations/). ## Timestamps Public instants are RFC 3339 UTC and end in `Z`. Appointment writes must send `Z` or an explicit offset; an offsetless value is rejected. `GET /v1/account` returns the account's IANA timezone for local dates and UI. `created_at` and `updated_at` are display and reporting values, **not sync watermarks**: a change can become visible after a later timestamp has already been read. Resume from a feed cursor. ## Lists and change feeds ### Calls `GET /v1/calls` has two modes: - Without `sync`, a newest-first list ordered by `started_at`, for a screen a person reads. `next_cursor` is null on the last page. - With `sync=true`, a commit-ordered feed for backfill and recovery. `next_cursor` is your position and comes back on every non-empty page, including the last. The list filters are refused alongside `sync=true`: `created_after`, `created_before`, `phone`, `contact_id`, `q`, `outcome`, and a `status` other than `all`. `location_id` is allowed with it. A cursor belongs to the mode that issued it. ### Appointments, contacts, services, resources, and unresolved requests These list endpoints are change feeds, apart from [a contact search](#find-a-contact-by-phone-or-tag). Start without `cursor`, upsert each object by Talki ID, store every non-null `next_cursor`, and drive the loop from `has_more`. Keep the filters constant for the length of a walk, and do not filter out cancelled appointments, inactive services or closed unresolved requests: those states are precisely the changes a downstream copy needs. A walk filtered by `location_id` is complete for that location, and its cursor is refused under any other `location_id`, or without one. A feed carries current state, not every intermediate edit, and deletions leave no tombstone in it. A deleted contact sends `contact.deleted`, including one deleted after twelve months without a call. A deleted service sends `service.deleted`, and a deleted resource `resource.deleted`, each with the object as it was. A call deleted with `DELETE /v1/calls/{id}` sends `call.deleted`, which lists the unresolved requests deleted with it. Calls and unresolved requests deleted after twelve months send no event. Walk each feed in full periodically and reconcile the IDs that no longer come back; that also catches a deletion whose event you missed. A change to a resource's unit classes or service links moves the resource itself in the feed. Cursors are opaque and versioned, and belong to the endpoint and filters that issued them. Do not decode one, edit one, or pass it to another endpoint or filter. Each feed's `next_cursor` names that feed, and the other feeds refuse it with `invalid_cursor`. So are refused a cursor of the other kind (a list cursor on a feed, or the reverse) and a change-feed cursor from another `location_id` filter. A list cursor passed to another list is not always refused, and then resumes from a meaningless position. A feed cursor issued up to 2026-11-04 may name no feed. It keeps working until 2027-02-01, and any feed page you read replaces it with one that does. After that date it gets `invalid_cursor`: drop it and walk the feed again from no cursor. ### Find a contact by phone or tag `GET /v1/contacts` with `phone` or `tag_id` is a search rather than the feed, and finds only the contacts the feed publishes: - `phone` takes E.164, or a national number read as Italian. Anything else is refused with `400 invalid_parameter`. - `tag_id` takes a `Tag.id`. An unknown or malformed one finds nothing. With both, a contact must match both. Results are newest contact first, and `next_cursor` is null on the last page, so a search gives you no position to store. A feed cursor is refused by a search, and a search cursor by the feed, with `invalid_cursor`. Sync from the unfiltered feed. ### Documents `GET /v1/documents` is a newest-first list, not a change feed: its cursor pages through the list as it stands. Keep a copy current with the `document.*` events, and walk the whole list to rebuild it or to find deletions you missed; see [Manage the knowledge base](/guides/knowledge-base/#keep-a-copy-in-sync). ## Capacity A resource is a finite thing the business books against: tables, rooms, chairs, seats. A `shared` resource is one fungible pool of `capacity` units. A `units` resource is a set of discrete sized units described by `unit_classes`, and there `capacity` is the size of the largest single unit rather than a total — read `max_party_size` for the largest booking it can hold. A resource belongs to one location, and only services of that location draw on it. A timed service draws on its location's default resource (`is_default`), on a resource of its own, or on resources the business built. A resource of its own carries the service's `owner_service_id`: it is the real per-service cap, and it is deleted with the service. Writing is much narrower than reading: `name`, and `capacity` on a `shared` resource, plus creating a `shared` resource with `POST /v1/resources`, and deleting a resource with `DELETE /v1/resources/{id}`. Unit classes and service links are read-only, and a capacity below what the calendar has already committed is refused with `409 capacity_below_committed`. A delete is refused for the default resource (`409 resource_is_default`) and for the only resource of a service that still needs one (`409 resource_required_by_service`). ## Unresolved requests When a caller asks something the assistant cannot answer, it records the question, says a person will get back to them, and the business works through the list. The object carries the question, a number to call back, and a status that closes: map it to a task. A request moves in the feed again when its status or its `answer` changes. Sync without the `status` filter, and use that filter only for a point-in-time view of the open list. Once a request is resolved, the general answer to its question can be taught to the assistant, which then gives it to every caller who asks; see [Teach the assistant an answer](/guides/configure-assistant/#teach-the-assistant-an-answer). The published `question` is the assistant's own one-line record of what was asked. The conversation itself is the call's [transcript](/reference/operations/getcalltranscript/), under `transcripts:read`. ## Curated contacts The contact feed publishes owner-curated, imported and API-written names only. An AI-inferred candidate is a [pending contact](/reference/operations/listpendingcontacts/) until a person approves it. Names written through the API are trusted and are not overwritten by later transcript extraction. ## Idempotent writes Every appointment create, reschedule, cancellation and restore, every service, resource, closure, tag, caller-rule or document create, and teaching the assistant (`POST /v1/unresolved_requests/{id}/teach`) require an `Idempotency-Key`. Generate one per logical write and reuse it only to retry the same method, path, query and body: a write can commit and still lose its response, and the replay is what tells those two cases apart. Every other write accepts the header without requiring it. Once the first request has a final answer, a repeated key with the same request replays the original status and body and adds `Idempotency-Replayed: true`. An answer a retry could change releases the key instead; see [Retry rules](/errors/#retry-rules). Reusing a key for a different request returns `409 idempotency_key_reuse`. Keys are at most 255 characters and are kept for 24 hours; after that, replay protection is no longer guaranteed. ## Versioning The major generation is `/v1` and it never changes. The contract you are pinned to is a dated version; the only one is `2026-10-01`. See the [changelog](/changelog/). Every credential carries its own pin, and that is what you are served when you send nothing: an API key pins on the key, an OAuth app pins on the app — never per installation, so one integration speaks one version for all of its customers — and a webhook endpoint pins on the endpoint. Send `Talki-Version: 2026-10-01` to override the pin for a single request. That is how you rehearse an upgrade: one request at a time, against the sandbox, before moving the pin for everybody. A response echoes the version it was served at in the same header, so you can confirm it rather than infer it from the payload. It is absent when the request was refused before a version was chosen: `401`, the `402`/`403` account refusals, `unsupported_version` itself, `ip_rate_limited`, and every `/oauth/*` response. A version the API does not serve is refused with `400 unsupported_version`, never downgraded; the message lists the accepted versions. Fields and event types may be added without a path change, so ignore what you do not recognise. Removals, renames, type changes and meaning changes require a dated contract release and a migration notice. --- # Python SDK Source: https://developers.talki.it/sdks/python/ Install the talki package, call the API with typed sync or async methods, and verify webhooks and run OAuth with its helpers. `talki` is the Python client for `/v1`. It is generated from the same OpenAPI document as the [API reference](/reference/), and adds errors, retries, paging, webhook verification and OAuth on top. ```sh pip install --pre talki # or: uv add --prerelease=allow talki ``` Python 3.11 or later. It depends on httpx, attrs and anyio. The package is in beta: `--pre` is needed until 1.0, and its own interface may still change between beta releases. Source, changelog and the full README are on [GitHub](https://github.com/HubtecSrl/talki-python). ## Create a client ```python from talki import Talki with Talki(api_key="talki_test_…") as talki: account = talki.account.get() print(account.business_name, account.livemode) ``` - Pass `api_key=` for an [API key](/authentication/), or `access_token=` for an [OAuth](/oauth/) access token. With neither, `Talki()` reads the key from the `TALKI_API_KEY` environment variable. - `with` closes the client's connections at the end of the block. A server can instead keep one `Talki` for the life of the process, which reuses its connections, and call `talki.close()` when done. - Every request sends `Talki-Version: talki.API_VERSION`, the dated version the package was generated from. `Talki(version="…")` pins another; `version=None` sends none, so the version pinned on the key or app applies. ## Call an operation `Talki` has one attribute per tag of the API reference (`talki.account`, `talki.appointments`, `talki.calls`, …) and one method per operation, named after the operation ID without the tag's noun: `listCalls` is `talki.calls.list`, `getCallTranscript` is `talki.calls.get_transcript`. ```python appointment = talki.appointments.create( body={"client_name": "Ada", "client_surname": "Lovelace", "start_at": "2026-11-02T09:30:00+01:00"} ) talki.appointments.cancel(appointment.id) ``` - Path parameters are positional; query parameters and `body=` are keywords. - A method returns the operation's model, or `None` for `204 No Content`, and raises for an error. - `body=` takes the request model (`talki.models.AppointmentCreate(...)`) or a dict with the API's keys, typed so a type checker catches a wrong key or value. The API validates it and answers a wrong one with `400`. - In an update body, `talki.types.UNSET` (or a key left out of a dict) leaves a field as it is, and `None` sends `null`, which clears a field that allows it. ### Async `AsyncTalki` takes the same arguments and has the same methods, to await: ```python from talki import AsyncTalki async with AsyncTalki(api_key="talki_test_…") as talki: account = await talki.account.get() async for call in talki.calls.list(limit=100): print(call.id) ``` ### Status and headers For the status or a header (`ETag`, `Idempotency-Replayed`, the rate-limit headers, `X-Request-Id`), call the same method on `with_raw_response`. It returns a `RawResponse` with `status_code`, `headers`, `content`, `request_id` and `parse()`: ```python raw = talki.account.with_raw_response.get() account, etag = raw.parse(), raw.headers["ETag"] # Refused with 412 if the account changed since that read. talki.account.update(body={"greeting": "Buongiorno"}, if_match=etag) ``` `talki.with_options(timeout=5, max_retries=0)` returns the same client with other settings for the calls made on it. It shares the original's connections and token renewal. ## Errors A method raises a subclass of `talki.ApiError`, chosen by the error envelope's `type` (see [Errors and rate limits](/errors/)): `InvalidRequestError`, `AuthenticationError`, `PermissionDeniedError`, `ConflictError`, `RateLimitError` (with `retry_after`) and `ServerError`. Each carries `status`, `type`, `code`, `message`, `request_id`, `headers` and `body`. Branch on `code`, and quote `request_id` to [support](/support/). ```python from talki import ConflictError try: talki.appointments.create(body=booking) except ConflictError as error: if error.code == "slot_unavailable": ... ``` An answer with no envelope, such as a proxy's, is classed by its HTTP status, with `code` set to `None`. ## Retries and idempotency `Talki` retries up to `max_retries` times (default 2): - a request that never left (connection refused, connect or pool timeout); - a request answered `429` or `409 request_in_progress`; - a read answered `500`, `502`, `503` or `504`, or whose connection broke after sending; - a write with an `Idempotency-Key` answered `502`, `503` or `504` by a gateway. A write the API itself answered with a 5xx, or whose connection broke after sending, is never retried, since it may have been saved: read the resource before writing again. Waits follow `Retry-After` up to 60 seconds; a longer one raises instead (`RateLimitError` for a `429`). `timeout` applies to each attempt. Every write without an `Idempotency-Key` gets a new one, so the client's own retries are never applied twice. To retry a write yourself, pass your own with `idempotency_key=` (`talki.new_idempotency_key()` makes one). `auto_idempotency=False` turns this off. ## Paging and change feeds A method whose operation takes `cursor=` returns a `talki.pagination.Page`: the first page, with `data`, `has_more` and `next_cursor`. Iterating it yields every row of it and of the pages after it, fetched as the loop reaches them. A list without a cursor (`talki.locations.list()`, `talki.tags.list()`, …) returns its model whole. ```python from talki.pagination import position for call in talki.calls.list(limit=100): ... for page in talki.appointments.list(cursor=stored).iter_pages(): save(page.data) stored = position(page, stored) # store it with the rows ``` A change feed returns `next_cursor` on every non-empty page, the last one included: store every non-null one with the rows and resume from it, as [Core concepts](/concepts/#lists-and-change-feeds) describes. `position(page, stored)` returns the cursor to keep. On `AsyncTalki`, such a method returns an `AsyncPager`: await it for the first page, or `async for` it. ## Knowledge base files A note or a website is a JSON body like any other. A file has methods of its own, which send it as `multipart/form-data`: ```python from pathlib import Path document = talki.documents.upload("price-list.pdf") # a path, bytes or an open file talki.documents.replace_file(document.id, "price-list-2027.pdf") file = talki.documents.download(document.id) Path(file.file_name or "download").write_bytes(file.payload.read()) ``` The file's name becomes the document's title, so bytes need `filename=`. See the [knowledge base guide](/guides/knowledge-base/) for formats, limits and processing. ## Verify a webhook `talki.webhooks` checks `X-Talki-Signature` on the raw body, before parsing, as [Webhooks](/webhooks/#signature-verification) describes: ```python from talki.webhooks import SignatureVerificationError, construct_event, signature_of try: event = construct_event(raw_body, signature_of(headers), secret) except SignatureVerificationError: ... # answer 400 and drop it ``` - `construct_event` returns the envelope as a dict; `verify_signature` only checks. Both take the body as the bytes received, and refuse a signature more than 300 seconds from now. - `signature_of(headers)` finds the header whatever its case. - During a secret rotation either secret verifies; pass a list when you hold more than one. ## OAuth apps `talki.oauth` covers an app installed on Talki accounts: the consent URL with PKCE, the callback, the code exchange, and a token pair that renews itself. ```python import secrets from talki import Talki from talki.oauth import OAuthApp, Pkce, authorization_url, read_callback app = OAuthApp(client_id, client_secret) pkce, state = Pkce.new(), secrets.token_urlsafe(16) url = authorization_url(client_id, redirect_uri, ["calls:read"], state=state, pkce=pkce) # Send the customer's browser to url; then, on the callback: code = read_callback(callback_url, state=state) tokens = app.exchange_code(code, redirect_uri=redirect_uri, code_verifier=pkce.verifier) save(tokens) talki = Talki(tokens=tokens, oauth_app=app, on_token_refresh=save) ``` - With `oauth_app`, `Talki` renews the pair a minute before it expires, and on a `401` with `error="invalid_token"`, then sends the request again. - **The refresh token rotates on every renewal, and presenting a rotated one again revokes the installation.** Keep one `Talki` per installation per process, and store the new pair in `on_token_refresh`, which runs before the pair is used. - After `invalid_grant` (the customer disconnected, or the pair was revoked) or `invalid_client`, every request raises that `OAuthError` without asking again: build a new `Talki` once fixed. - A code works once: never retry `exchange_code` with the same code. ## Logging The client logs to the `talki` logger. Set it to DEBUG to log every answer with its request ID, or INFO for every retry and token renewal; at WARNING and ERROR it reports a token renewal whose outcome is unknown or that was refused. It writes a request by method and path only, never its query, headers, body or credentials. httpx's own logger writes full URLs at INFO, query included: keep it at WARNING when your root level is INFO or lower. `Talki(…, user_agent="acme-crm/2.1")` appends your integration's name to the `User-Agent`. ## Not covered yet - The partner routes (`/oauth/app/*`, `/oauth/managed-accounts`, `/oauth/enrollments`) are not in the OpenAPI document, so not in the package. See [Partners](/partners/) and [Managed billing](/partner-billing/). - Webhook payloads have no generated types: `construct_event` returns a dict. [Event payloads](/webhooks/#event-payloads) lists their shapes. --- # TypeScript SDK Source: https://developers.talki.it/sdks/typescript/ Install @hubtec/talki, call the API with typed methods, and verify webhooks and run OAuth with its helpers, on Node, Bun, Deno and Workers. `@hubtec/talki` is the TypeScript client for `/v1`. It is generated from the same OpenAPI document as the [API reference](/reference/), and adds errors, retries, paging, webhook verification and OAuth on top. ```sh npm install @hubtec/talki@beta ``` Beta releases are published under the `beta` tag, and the package's own interface may still change between them. Source, changelog and the full README are on [GitHub](https://github.com/HubtecSrl/talki-typescript). It has no runtime dependencies: it is built on the global `fetch` and WebCrypto, runs on Node 20 or later, Bun, Deno and Cloudflare Workers, and ships ES modules and CommonJS with their types. ## Create a client ```ts import { Talki } from "@hubtec/talki"; const talki = new Talki({ apiKey: "talki_test_…" }); const account = await talki.account.get(); console.log(account.business_name, account.livemode); ``` - Pass `apiKey` for an [API key](/authentication/), or `accessToken` for an [OAuth](/oauth/) access token. With neither, `new Talki()` reads the key from the `TALKI_API_KEY` environment variable where `process.env` exists. - A `Talki` holds no connection of its own and needs no closing: keep one for the life of the process. - Every request sends `Talki-Version: API_VERSION`, the dated version the package was generated from. `new Talki({ version: "…" })` pins another; `version: null` sends none, so the version pinned on the key or app applies. ## Call an operation `Talki` has one property per tag of the API reference (`talki.account`, `talki.appointments`, `talki.calls`, `talki.workingHours`, …) and one method per operation, named after the operation ID without the tag's noun: `listCalls` is `talki.calls.list`, `getCallTranscript` is `talki.calls.getTranscript`. ```ts const appointment = await talki.appointments.create({ body: { client_name: "Ada", client_surname: "Lovelace", start_at: "2026-11-02T09:30:00+01:00" }, }); await talki.appointments.cancel(appointment.id); ``` - Path parameters are positional. Everything else goes in one object: query parameters under the API's own names (`location_id`, `created_after`), `body` (or `form`, for an upload), and the options `idempotencyKey`, `ifMatch`, `talkiVersion` and `signal`. - A method resolves to the operation's value, or `undefined` for `204 No Content`, and rejects for an error. A file the API returns (an export, a voice sample, a document's original) is a `Blob`. - `body` is a plain object with the API's keys, typed from the spec. Dates are ISO strings; a `Date` is sent as its ISO string. The API validates the body and answers a wrong one with `400`. - Every model and request type is exported (`Call`, `AppointmentCreate`, …), and per method `ListCallsParams`, `GetCallResponse`, `ListCallsRow`. ### Status and headers For the status or a header (`ETag`, `Idempotency-Replayed`, the rate-limit headers, `X-Request-Id`), call the same method on `withRawResponse`. It resolves to a `RawResponse` with `status`, `headers`, `requestId` and `data`: ```ts const raw = await talki.account.withRawResponse.get(); // Refused with 412 if the account changed since that read. await talki.account.update({ body: { greeting: "Buongiorno" }, ifMatch: raw.headers.get("ETag") ?? undefined }); ``` `talki.withOptions({ timeout: 5000, maxRetries: 0 })` returns the same client with other settings (timeout in milliseconds) for the calls made on it. Pass `signal` to any method to abort it, its retries and the waits between them. ## Errors A method rejects with a subclass of `ApiError`, chosen by the error envelope's `type` (see [Errors and rate limits](/errors/)): `InvalidRequestError`, `AuthenticationError`, `PermissionDeniedError`, `ConflictError`, `RateLimitError` (with `retryAfter`, in seconds) and `ServerError`. Each carries `status`, `type`, `code`, `apiMessage`, `requestId`, `headers` and `body`. Branch on `code`, and quote `requestId` to [support](/support/). ```ts import { ConflictError } from "@hubtec/talki"; try { await talki.appointments.create({ body: booking }); } catch (error) { if (error instanceof ConflictError && error.code === "slot_unavailable") { // … } else { throw error; } } ``` An answer with no envelope, such as a proxy's, is classed by its HTTP status, with `code` set to `null`. A request that got no answer rejects with `ConnectionError`, or `RequestTimeoutError` after `timeout`; its `idempotencyKey` is the key it carried. ## Retries and idempotency `Talki` retries up to `maxRetries` times (default 2): - a request that never left (connection refused, address not resolved); - a request answered `429` or `409 request_in_progress`; - a read answered `500`, `502`, `503` or `504`, or whose connection broke or timed out after sending; - a write with an `Idempotency-Key` answered `502`, `503` or `504` by a gateway. A write the API itself answered with a 5xx, or whose connection broke or timed out after sending, is never retried, since it may have been saved: read the resource before writing again. Waits follow `Retry-After` up to 60 seconds; a longer one rejects instead (`RateLimitError` for a `429`). `timeout` applies to each attempt. Redirects are never followed. Every write without an `Idempotency-Key` gets a new one, so the client's own retries are never applied twice. To retry a write yourself, pass your own as `idempotencyKey` (`newIdempotencyKey()` makes one); a write you may abort through your own `signal` needs one to be sent again as the same request. `autoIdempotency: false` turns this off. ## Paging and change feeds A method whose operation takes `cursor` returns a `PagePromise`. Await it for the first `Page`, with `data`, `hasMore` and `nextCursor`; `for await` it for every row of it and of the pages after it, fetched as the loop reaches them. A list without a cursor (`talki.locations.list()`, `talki.tags.list()`, …) resolves to its model whole. ```ts import { position } from "@hubtec/talki"; for await (const call of talki.calls.list({ limit: 100 })) { // … } for await (const page of talki.appointments.list({ cursor: stored }).iterPages()) { await save(page.data); stored = position(page, stored); // store it with the rows } ``` A change feed returns `next_cursor` on every non-empty page, the last one included: store every non-null one with the rows and resume from it, as [Core concepts](/concepts/#lists-and-change-feeds) describes. `position(page, stored)` returns the cursor to keep. Nothing is sent until the list is awaited or iterated. ## Knowledge base files An upload (`talki.documents.create`, `talki.documents.replace`) takes `form` instead of `body`, and is sent as `multipart/form-data`. The file is a `File`, whose name becomes the document's title; `toFile(source, name)` makes one from a `Buffer`, bytes, text, a `Blob` or a stream. ```ts import { readFile, writeFile } from "node:fs/promises"; import { toFile } from "@hubtec/talki"; const document = await talki.documents.create({ form: { file: await toFile(await readFile("price-list.pdf"), "price-list.pdf") }, }); const file = await talki.documents.getFile(document.id); const name = file instanceof File ? file.name : "download"; await writeFile(`./downloads/${name}`, new Uint8Array(await file.arrayBuffer())); ``` A downloaded file's name comes from whoever uploaded it: check it before using it in a path. See the [knowledge base guide](/guides/knowledge-base/) for formats, limits and processing. ## Verify a webhook `@hubtec/talki/webhooks` checks `X-Talki-Signature` on the raw body, before parsing, as [Webhooks](/webhooks/#signature-verification) describes. It uses WebCrypto only and loads nothing else, so it runs on edge runtimes. ```ts import { constructEvent, signatureOf, SignatureVerificationError } from "@hubtec/talki/webhooks"; const body = new Uint8Array(await request.arrayBuffer()); try { const event = await constructEvent(body, signatureOf(request.headers), secret); // event.id, event.type, event.data } catch (error) { if (error instanceof SignatureVerificationError) return new Response(null, { status: 400 }); throw error; } ``` - `constructEvent` resolves to the envelope; `verifySignature` only checks. Both take the body as received, as bytes or as its text decoded unchanged: a body parsed and serialised again fails. Both refuse a signature more than 300 seconds from now. - `signatureOf(headers)` finds the header in a `Headers` or a plain object, whatever its case. - During a secret rotation either secret verifies; pass an array when you hold more than one. ## OAuth apps `@hubtec/talki/oauth` covers an app installed on Talki accounts: the consent URL with PKCE, the callback, the code exchange, and a token pair that renews itself. ```ts import { Talki } from "@hubtec/talki"; import { OAuthApp, authorizationUrl, createPkce, readCallback } from "@hubtec/talki/oauth"; const app = new OAuthApp(clientId, clientSecret); const pkce = await createPkce(); const state = crypto.randomUUID(); const url = authorizationUrl(clientId, redirectUri, ["calls:read"], { state, pkce }); // Send the customer's browser to url; then, on the callback: const code = readCallback(callbackUrl, { state }); const tokens = await app.exchangeCode(code, { redirectUri, codeVerifier: pkce.verifier }); await save(tokens); const talki = new Talki({ tokens, oauthApp: app, onTokenRefresh: save }); ``` - A `TokenSet` is a plain object: store it as JSON and pass it back as `tokens`. It holds secrets, so never log it. - With `oauthApp`, `Talki` renews the pair a minute before it expires, and on a `401` with `error="invalid_token"`, then sends the request again. - **The refresh token rotates on every renewal, and presenting a rotated one again revokes the installation.** Keep one `Talki` per installation per process, and store the new pair in `onTokenRefresh`, which is awaited before the pair is used. Never make a request on the same `Talki` inside it. - After `invalid_grant` (the customer disconnected, or the pair was revoked) or `invalid_client`, every request rejects with that `OAuthError` without asking again: build a new `Talki` once fixed. - A code works once: never retry `exchangeCode` with the same code. ## Logging The client is silent until you give it a level or a logger: ```ts const talki = new Talki({ apiKey, logLevel: "debug" }); // to console const quiet = new Talki({ apiKey, logger: pino() }); // to your logger, from "info" up ``` `debug` logs every answer with its request ID; `info` logs every retry and token renewal. A request is written by method and path only, never its query, headers, body or credentials. `new Talki({ …, userAgent: "acme-crm/2.1" })` appends your integration's name to the `User-Agent`. ## Not covered yet - The partner routes (`/oauth/app/*`, `/oauth/managed-accounts`, `/oauth/enrollments`) are not in the OpenAPI document, so not in the package. See [Partners](/partners/) and [Managed billing](/partner-billing/). - Webhook payloads have no generated types: an event is typed by its `id` and `type` only. [Event payloads](/webhooks/#event-payloads) lists their shapes. --- # 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 . ``` 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=,v1= ``` 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. --- # App events Source: https://developers.talki.it/app-events/ Install, scope, revoke and access events for OAuth apps, and the events about calls, appointments, contacts, services, resources, locations, documents and account settings of every installed account, delivered to the app's own URL. An [OAuth app](/oauth/) can be told when an account installs it, changes what it granted, disconnects it, or loses API access. Without these, an uninstall reaches you only as a `401` on your next call. The same URL can also receive the [account events](#account-events) — calls, appointments, contacts, services, resources, locations, documents and account settings, working hours included — of every account that installed the app, so one receiver serves all your customers. App events are separate from an account's [webhooks](/webhooks/): they go to the **app's** URL, not to one the customer configured, and no customer plan is involved. ## Set up In the [console](https://console.talki.it), set the app's **events URL**. The same rules as a webhook endpoint apply: `https://`, port 443, public addresses only. The signing secret (`whsec_…`) is generated the first time a URL is set and shown once. Changing the events URL does not send the app back to review. ## Installation events Always sent while the app has an events URL; there is nothing to subscribe to. | Event | When | Extra `data` | |---|---|---| | `installation.created` | An account installs the app, or reinstalls it after a disconnect | — | | `installation.scopes_changed` | An account reconnects with a different set of scopes. A reconnect with the same scopes sends nothing | `previous_scopes` | | `installation.revoked` | The installation is disconnected | `reason` | | `installation.access_suspended` | The account can no longer use the API | `reason` | | `installation.access_restored` | It can again | — | Every event carries `data.installation`: ```json { "object": "installation", "id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90", "client_id": "talki_cid_4Hq2mZ8rVt1LxN3pYw7KcA", "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "scopes": ["appointments:read", "calls:read"], "status": "active", "access": "active", "livemode": true, "created_at": "2026-09-15T09:14:31.204518+00:00", "revoked_at": null } ``` | Field | Type | Meaning | |---|---|---| | `object` | string | Always `installation` | | `id` | string | The installation: one account's connection to your app. Equal to the envelope's `installation_id` | | `client_id` | string | Your app's client ID | | `account_id` | string | The Talki account that installed the app | | `scopes` | array of strings | The scopes the account granted, sorted | | `status` | string | `active`, or `revoked` once the installation is disconnected | | `access` | string | `suspended` while the account cannot use the API, otherwise `active` | | `livemode` | boolean | `false` for a [sandbox](/sandbox/) account's installation | | `created_at` | string | When the account first installed the app | | `revoked_at` | string or null | When the installation was disconnected; null while it is active | `created_at` and `revoked_at` are ISO 8601 with a `+00:00` offset and may carry microseconds, like the envelope's `created_at`, not the `Z` form of the REST API. Parse them as instants. `installation.scopes_changed` adds `previous_scopes`, the scopes granted before the reconnect, sorted; the new ones are in `installation.scopes`. ### `reason` On `installation.revoked`: | `reason` | Who disconnected | |---|---| | `customer` | The account owner, from the Talki app | | `partner` | Your app, by calling [`/oauth/revoke`](/oauth/#revoke) with one of its tokens | | `account_deleted` | The account was closed | On `installation.access_suspended`, `reason` is the error code your API calls now receive: `subscription_inactive`, `plan_upgrade_required` or `account_closed`. Stop syncing until `installation.access_restored`; the grant and its tokens survive a suspension. An access event can arrive up to 15 minutes after the change, and is sent only when the state actually changes. ## Account events ### Subscribe In the console, open the app's **Events** and tick the types it should receive. Each type is opt-in, and there is no wildcard: a type added to the catalogue later is not sent until you tick it. A type is sent for an account only while its installation grants the type's scope. A type whose scope the app does not request cannot be ticked. | Event | Scope | |---|---| | `call.ringing` | `calls:read` | | `call.ended` | `calls:read` | | `call.transferred` | `calls:read` | | `call.updated` | `calls:read` | | `call.deleted` | `calls:read` | | `appointment.created` | `appointments:read` | | `appointment.updated` | `appointments:read` | | `appointment.cancelled` | `appointments:read` | | `contact.created` | `contacts:read` | | `contact.updated` | `contacts:read` | | `contact.deleted` | `contacts:read` | | `pending_contact.created`, `.updated`, `.deleted` | `contacts:read` | | `tag.created`, `.updated`, `.deleted` | `contacts:read` | | `caller_rule.created`, `.updated`, `.deleted` | `contacts:read` | | `unresolved_request.updated` | `unresolved:read` | | `document.created`, `.updated`, `.deleted` | `knowledge:read` | | `account.updated` | `account:read` | | `working_hours.updated` | `account:read` | | `closure.created`, `.updated`, `.deleted` | `account:read` | | `service.created`, `.updated`, `.deleted` | `services:read` | | `resource.created`, `.updated`, `.deleted` | `resources:read` | | `location.created`, `.updated`, `.deactivated`, `.reactivated` | `account:read` | | `location.settings_updated`, `location.working_hours_updated` | `account:read` | `data` is the same as on a webhook; see the [event catalogue](/webhooks/#event-catalogue). `usage.*` and `ping` are not offered to apps. A billing partner's app receives `usage.*` for the accounts it pays for without subscribing; see [Managed billing](/partner-billing/#usage-events). The grant is checked when the event is written and again before each attempt. Once an installation is revoked or no longer grants the scope, or the account loses API access, its queued events are not sent. ### Envelope ```json { "id": "evt_3c7a1f0e9d2b4c6a8e5f1b0d7a2c4e6f", "type": "call.ended", "created_at": "2026-09-20T10:41:07.118204+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 }, "installation_id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90", "data": { "object": { "id": "38d23d84-02d2-4418-b169-3608aa04a8f8", "…": "…" } } } ``` - `installation_id` is the connection the event belongs to: look up the customer and its token by it. - `account_id` is the Talki account the event happened on. - `livemode` is `false` for a [sandbox](/sandbox/) installation. Events can arrive out of order, and an account event can arrive before its `installation.created`. Key on `installation_id`, and order events as on [webhooks](/webhooks/#delivery-semantics): by the object's `updated_at`, or the envelope's `created_at` for an object without one, never by arrival. ### Echo detection A change your app makes through the API comes back as an event with `origin.source` `api`, `origin.credential_id` set to your `installation_id`, and `origin.idempotency_key` set to the request's key. Drop the events whose `origin.credential_id` is the installation's own ID. Other apps installed on the same account receive that event with `origin.source` `api` and both `credential_id` and `idempotency_key` null. ### Catching up Events are not written while delivery is paused, while the app has no events URL, or for a type not yet subscribed. After a gap, read each installation's [change feeds](/webhooks/#recovery) from its last stored cursor, with that installation's token, and upsert by Talki ID. ### The same URL as a customer webhook If an account also has a [webhook](/webhooks/) endpoint with the app's events URL, both deliver: the same change arrives twice, with different `evt_` IDs. Delete those endpoints once the app receives the events itself. ## Installation events envelope The same envelope as webhooks, with two differences: `account_id` is the installed account, and `installation_id` is added. `origin` is `null`. ```json { "id": "evt_9b2f6c1e4a7d4e0f8c3b5a2d1e6f7a8b", "type": "installation.revoked", "created_at": "2026-09-20T16:02:11.482913+00:00", "api_version": "2026-10-01", "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "livemode": true, "origin": null, "installation_id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90", "data": { "installation": { "object": "installation", "status": "revoked", "…": "…" }, "reason": "customer" } } ``` `api_version` is the app's pin, on every app event. ## Signatures and delivery Signed and delivered exactly like [webhooks](/webhooks/#signature-verification): the same headers, the same `X-Talki-Signature`, the same retries and timeouts. The key is the app's events secret. Use the same verifier, and accept any `v1`. Rotating the events secret stops the old one **at once** unless you ask for an overlap (up to 7 days) when you rotate. ## When nothing arrives - After 20 consecutive failed deliveries, event delivery to the app **pauses**, and the app's owner is emailed. Only failures at your receiver count: a non-2xx answer (a `410 Gone` included), the 10-second timeout, a connection, TLS or DNS failure, or a URL that no longer resolves to a public address. A delivery counts once all six attempts are spent, and one success resets the counter. Resume from the app's events in the console. - **Events from a pause, or from before an events URL is set, are not sent later.** For account events, [catch up](#catching-up) from the change feeds. For installation events, the next access check after resuming reports the current access state; installs and disconnects from the gap are not sent. Reconcile them by calling the API with each installation's token: a disconnected one answers `401 invalid_credentials`. - A **disabled** app receives nothing. ### Delivery log **Deliveries** on the app, in the console, lists what was sent to the events URL in the last 30 days, newest first, filterable by status and installation. Each row shows the event ID and type, the installation, the attempts, and either your receiver's status with the first 500 characters of its body, or an [error class](/webhooks/#delivery-log-errors). The payload is not shown: fetch the object through the API. Account events add three classes. None is retried, and none counts toward the pause: | Class | Meaning | |---|---| | `installation_inactive` | The installation was revoked, or no longer grants the event's scope | | `event_unsubscribed` | The app no longer subscribes to the event type | | `account_refused` | The account can no longer use the API | **Redeliver** sends the stored event again with a fresh set of six attempts and the same `id`. It answers `409` while the delivery is still being attempted, while the app is paused, disabled or has no events URL, and for an account event that one of the three classes above would stop now. ### Daily digest A receiver that fails for one account while serving the others never pauses the app. Once a day, the app's owner is emailed the installations whose deliveries failed at the receiver, each with a count, the last error and a link to its log. Live and sandbox installations are listed separately, and no digest is sent while the app is paused. A digest covers the events created 36 to 12 hours before it is sent, when every retry has finished, and 61 to 37 hours before for `expired` events. Refusals are not counted. --- # Become a partner Source: https://developers.talki.it/partners/ Offer Talki inside your software — your customers pay Talki, or you pay for them. A partner is software — a CRM, a booking system, a management suite — whose customers connect their Talki account to it through an [OAuth app](/oauth/). For each customer you choose who pays for the Talki subscription: the customer, or you. ## Two ways to bill | | The customer pays Talki | You pay for the customer | |---|---|---| | Subscription | The customer's own, bought on app.talki.it or in the Talki app | Yours; the customer sees «managed by» your app and no prices or payments | | Before connecting | The customer needs a Talki account and a subscription | Nothing: the customer creates the account inside the activation flow | | Plan | The customer's choice | Yours: `base`, `pro` or `business`, set per customer and changeable through the API | | `/v1` and [app events](/app-events/) | Only while the customer is on Business | On every plan, for your app only | | Price to the customer | Talki's list price | Yours; Talki invoices you under the terms agreed with you | | Setup | Register an app | Register an app, then have it enabled for managed billing | Both use the same app, consent screen and tokens. An app enabled for managed billing can still connect customers who pay for themselves: an authorization URL without an `enrollment` is an ordinary connect. ## Customers who pay Talki Register an app in the [console](https://console.talki.it) and follow [OAuth apps](/oauth/). The customer's plan decides what your app can reach: the REST API needs Business, and on a lower plan every call answers `plan_upgrade_required` (see [Plans](/overview/#plans)). When the customer's subscription lapses or drops below Business, your app receives [`installation.access_suspended`](/app-events/). ## Customers you pay for The customer presses a button in your software — «Attiva Talki», for example — and your server asks Talki for a single-use activation. The customer creates a Talki account (or signs in) and approves your app; from that moment the account is on your bill, on the plan you chose, and it appears on your app's Businesses page in the console. What you control: - **The plan of each account**, through `/oauth/managed-accounts`. An upgrade applies at once, a downgrade on the 1st of the next month. - **When management ends.** The customer keeps access, on your bill, until the end of the current month, then becomes an ordinary Talki customer. - **The monthly usage** of every account you pay for, to the second, on the app's Usage page in the console, as a CSV export, and from your server through `GET /oauth/app/usage`. A plan with no minute cap, `unlimited`, is available to apps whose terms include it. Its other limits are those of Business. The full flow, the endpoints and their errors are in [Managed billing](/partner-billing/). ## Apply 1. Register your app in the [console](https://console.talki.it) and submit it for review. The [sandbox](/sandbox/) that comes with it lets you build before you have a customer. 2. Write to [info@talki.it](mailto:info@talki.it) with: - your company and the product your customers use; - the app's client ID (`talki_cid_…`); - whether you will pay for your customers, they will pay Talki, or both; - roughly how many customers you expect to activate, and how quickly; - the scopes you need. 3. For managed billing, the commercial terms are agreed first; the app is then enabled and a default plan is set. Managed billing cannot be switched on from the console. --- # Managed billing Source: https://developers.talki.it/partner-billing/ Activate Talki for your customers on your own bill — activations, plans, ending management, and monthly usage. With managed billing your app pays for the Talki accounts it activates: the customer has no Talki subscription of their own and sees no prices or payments. This page adds to [OAuth apps](/oauth/); everything there still applies. To have an app enabled, see [Become a partner](/partners/#apply). ## Flow 1. The customer presses «Attiva Talki» (or your own label) in your software. 2. Your server mints a single-use **activation** with the app's credentials. 3. You send the customer's browser to the authorization URL you already build, with `&enrollment=…` added. 4. The customer creates a Talki account, or signs in to an existing one, and approves. The consent screen shows that the subscription is managed by your app, and on which plan. 5. The browser returns to your `redirect_uri` with a `code`, as in any OAuth flow. 6. Your server exchanges the code at `POST /oauth/token`. **The exchange puts the account under your management**: from then on the customer does not pay Talki, and the account appears on your app's Businesses page in the console. Without `enrollment`, the same URL is an ordinary connect, and the customer needs a subscription of their own. ## Before you start - The app must be **approved** and **enabled for managed billing**. Enabling is not available from the console. - In the console, set the app's **default plan**: the plan of every activation that names none. Changing it affects only activations minted afterwards, and does not send the app back to review. - Changing the app's name, website, logo, redirect URIs or scopes sends it back to review, and **activations fail while it is in review**. - Every request on this page authenticates with the app's credentials, as at `/oauth/token`: HTTP Basic, or `client_id` and `client_secret` in the JSON body. `GET` accepts HTTP Basic only. Keep the secret server-side. - Errors use the API envelope, `{"error": {"type", "code", "message", "request_id"}}`, not the OAuth shape of `/oauth/token`. ## Mint an activation ```bash curl --fail-with-body https://api.talki.it/oauth/enrollments \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "plan": "pro", "email": "giulia@salonebellini.example", "business_name": "Salone Bellini", "first_name": "Giulia", "last_name": "Bellini", "forwarding_number": "+39 333 000 0000", "external_ref": "cust_4821" }' ``` | Field | Rule | |---|---| | `email` | **Required.** Only a Talki account with this address can consent to the activation, so a forwarded link does not work for anyone else. | | `plan` | `base`, `pro` or `business`; `unlimited` only if it is enabled for your app. Omitted: the app's default plan. The plan is fixed here and does not follow later changes to the default. | | `business_name`, `first_name`, `last_name`, `forwarding_number` | Optional. Pre-fill the details the customer confirms; the customer can correct them. `forwarding_number` is the business's own line, which forwards its calls to Talki. `phone` is its old name and still works, with the same value: if you send both, they must be the same number, or the request is refused with `400 invalid_parameter`. | | `external_ref` | Optional, up to 200 characters. Your own id for the customer, kept on the account's plan history and shown in the usage report. | `201 Created`: ```json { "enrollment": "talki_en_…", "expires_at": "2026-10-15T09:12:00+00:00", "plan": "pro", "external_ref": "cust_4821" } ``` - An activation is valid for **24 hours** and works **once**. Mint a new one each time the customer presses the button; never store or reuse one. - The `enrollment` value is a secret. Put it only in the authorization URL, never in logs, emails or your own pages. The consent page removes it from the address bar as soon as it reads it. - Each app may mint **60 activations per hour**; beyond that the answer is `429` with `Retry-After`. To activate many existing customers at once, plan the rollout with Talki first. ## Send the customer to Talki The authorization URL from [OAuth apps](/oauth/#authorize) — PKCE `S256`, a fresh `code_challenge` per flow — plus `enrollment`: ```text https://app.talki.it/autorizza ?response_type=code &client_id=talki_cid_4Hq2mZ8rVt1LxN3pYw7KcA &redirect_uri=https%3A%2F%2Fcrm.example.com%2Ftalki%2Fcallback &scope=account%3Aread%20calls%3Aread &state=Zk3q9… &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &enrollment=talki_en_… ``` What the customer sees: - **No Talki account:** a two-step signup branded with your app. First what is being activated — your app, «managed by» your app, the plan and the scopes — then a form: business name, first and last name, mobile number, email and password, pre-filled from the activation. Pressing «Crea account e attiva Talki» is the consent. After confirming the email the customer returns to your `redirect_uri` with no further screen, unless the scopes or the plan changed in the meantime, in which case the ordinary consent screen is shown. - **Signup with Google or Microsoft:** the consent screen, which asks for the business name and mobile number when they are missing. - **An existing account:** sign in, then the consent screen. Business name, first name, last name and mobile number are all required before consent completes: the assistant answers with the business name, and the mobile number becomes the account's `forwarding_number`, the business's own line that forwards its calls to Talki. It is not a transfer target: the assistant transfers calls only to the account's `transfer_numbers`, which start empty; set them with `PATCH /v1/account`. On app.talki.it and in the Talki app, the subscription shows as managed by your app, with no prices or payments. The customer uses Talki as any other customer. ## Exchange the code The callback and the exchange are those of [OAuth apps](/oauth/#exchange-the-code). Keep `account_id` from the token response: it identifies the account to `/oauth/managed-accounts`. **Bind `state` and the `code_verifier` to the browser session of the user who pressed the button, and exchange the code only when `state` matches that session.** Looking the verifier up by `state` alone lets whoever receives a forwarded link finish the activation in their own browser, on your bill. On a successful exchange the account is managed by you, gets the Talki number its calls are forwarded to, and receives the plan's minutes. The exchange fails with `invalid_grant` when, between consent and exchange, the activation expired or the account came under another management. Nothing changes then: mint a new activation and send the customer back. ## Create accounts from your server The standard way to bring a customer on board is the flow above: the customer authorizes your app and consents on Talki, with an activation when your app pays. Headless provisioning, `POST /oauth/managed-accounts`, creates the account from your server instead, and the customer sees no Talki screen. It is enabled per app, on request: write to [info@talki.it](mailto:info@talki.it) with the app's client ID and the product it serves. ### Create an account ```bash curl --fail-with-body https://api.talki.it/oauth/managed-accounts \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3b1f0c52-6f0e-4a8e-9d43-0b2f7c1e5a10" \ -d '{ "email": "giulia@salonebellini.example", "business_name": "Salone Bellini", "first_name": "Giulia", "last_name": "Bellini", "forwarding_number": "+39 333 000 0000", "transfer_numbers": [ {"label": "Reception", "phone": "+39 045 000 0000"} ], "plan": "pro", "scopes": ["account:read", "appointments:read", "calls:read"], "external_ref": "cust_4821", "terms": { "version": "2026-10", "accepted_at": "2026-10-22T09:12:00+00:00", "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …" } }' ``` | Field | Rule | |---|---| | `email` | **Required.** An address verified on your platform. An address that already has a Talki account is refused with `409 account_exists`: send that customer through the [activation flow](#flow) instead. | | `business_name`, `first_name`, `last_name` | **Required**, not blank. The assistant answers with the business name. | | `forwarding_number` | **Required.** The business's own line, which forwards its calls to Talki. `transfer_phone` is its old name and still works, with the same value: send one of the two, and if you send both they must be the same number, or the request is refused with `400 invalid_parameter`. | | `transfer_numbers` | Optional, up to 20. Who the assistant may hand a call over to, each as `{"label", "phone"}`, by the rules of `PATCH /v1/account`: labels up to 80 characters and unique ignoring case; phone numbers, a national one read as Italian, none equal to the `forwarding_number`. A list that breaks them is refused with `400 invalid_parameter`, and nothing is created. Omitted: none, and the assistant does not transfer calls until you set them with `PATCH /v1/account`. | | `plan` | As on an activation. Omitted: the app's default plan. | | `monthly_minutes` | Optional. The [monthly minutes](#set-the-monthly-minutes) you sold the customer. Omitted: none, and the plan's minutes apply. | | `scopes` | The scopes the customer accepted; see [below](#show-the-permissions-on-your-own-screen). | | `external_ref` | Optional, up to 200 characters, as on an activation. | | `terms` | **Required.** When the customer accepted Talki's terms on your platform: `version` (your name for the text), `accepted_at` (RFC 3339 with an offset, not in the future), and optionally `ip` and `user_agent`. | | `test` | `true` creates a [test account](#test-accounts). Default `false`. | `Idempotency-Key` is required. A retry with the same key and body answers `200` with `Idempotency-Replayed: true`, the same account and a new token pair; the earlier pair stops working. The same key with a different body is `409 idempotency_key_reuse`, and a retry while the first request is still running is `409 request_in_progress`. `201 Created`: ```json { "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "installation_id": "5d0e8a2c-7b41-4f7e-9c3a-1e2f4b6d8a90", "talki_number": "+390212345678", "plan": "pro", "monthly_minutes": null, "external_ref": "cust_4821", "access_token": "talki_at_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "talki_rt_…", "scope": "account:read appointments:read calls:read", "livemode": true, "account_name": "Salone Bellini", "test": false, "test_expires_at": null } ``` - The token pair is that of an [exchanged code](/oauth/#exchange-the-code): store and refresh it the same way. - `talki_number` is `null` when no number was free. The account is given one later, automatically; see [Manage an account](#manage-an-account). - Creating accounts is limited to 30 an hour per app, replays included (`429 rate_limit_exceeded`). | HTTP | `code` | Meaning | |---|---|---| | 400 | `invalid_parameter` | The body is not a JSON object, or a field is invalid; the message names it | | 400 | `idempotency_key_required` | `Idempotency-Key` is missing | | 400 | `invalid_plan` | Not a plan your app may pay for | | 400 | `invalid_scope` | See [below](#show-the-permissions-on-your-own-screen) | | 400 | `invalid_phone` | `forwarding_number` is not a valid number | | 400 | `cap_above_plan` | `monthly_minutes` is above the minutes the plan includes | | 401 | `invalid_client` | Wrong client ID or secret | | 403 | `provisioning_not_enabled` | Headless provisioning is not enabled for the app | | 403 | `app_not_approved` | The app is in review, suspended or disabled | | 409 | `account_exists` | The address already has a Talki account, or one of your live test accounts | | 409 | `plan_unavailable` | `plan` omitted, and the app's default plan is no longer one it may pay for | | 409 | `test_account_limit` | The app already holds 10 test accounts | | 409 | `idempotency_key_reuse` | The key was used for a different body | | 409 | `request_in_progress` | The first request with this key is still running | | 409 | `installation_revoked` | A replay, for an account that no longer has your app installed | | 429 | `rate_limit_exceeded` | Honour `Retry-After` | | 503 | `api_error` | The account could not be created right now; retry with the same `Idempotency-Key` | ### Show the permissions on your own screen With headless provisioning there is no Talki consent screen, so the permissions are yours to show: - Before the customer accepts, show every scope your app will ask for, in the customer-facing wording below. - Send in `scopes` only the scopes the customer accepted. - Without `scopes`, the account grants your app every scope it is registered for except the opt-in ones, `transcripts:read`, `calls:delete` and `knowledge:write`, which are granted only when listed. An app registered for opt-in scopes alone must send `scopes`. - `scopes` that is empty, repeats a scope, or names one the app is not registered for fails with `400 invalid_scope`, as does a missing `scopes` on an app registered for opt-in scopes alone. The customer-facing wording exists in Italian only. Show it as written to an Italian-speaking customer; in any other language, show a faithful translation of it. «What it allows» is a reference for you, not text to show the customer. | Scope | What it allows | Customer-facing wording (Italian) | |---|---|---| | `account:read` | Assistant settings, locations, hours, closures, the Talki number, the owner's forwarding number and its check, onboarding status and talk-time usage | Vedere i dati della tua attività e tutte le impostazioni dell'assistente: nome, fuso orario, orari di apertura, chiusure, il nome con cui risponde, voce, saluto, lingue, la zona in cui lavori, i numeri a cui passa le chiamate e le tue «Informazioni aggiuntive» e «Istruzioni aggiuntive». Anche il tuo numero Talki, il numero da cui inoltri le chiamate e se l'inoltro funziona, se hai completato la configurazione iniziale e i tuoi minuti: quanti ne hai usati, quanti ne hai a disposizione e quando si rinnovano | | `account:write` | Change assistant settings (turning off transcript storage deletes stored transcripts), hours, closures and the forwarding number; start a forwarding check (places a short test call); complete onboarding | Modificare le impostazioni dell'assistente: nome dell'attività, il nome con cui risponde, voce, saluto, lingue, la zona in cui lavori, i numeri a cui passa le chiamate, orari di apertura, chiusure e aperture straordinarie, le tue «Informazioni aggiuntive» e «Istruzioni aggiuntive», e se prende appuntamenti, raccoglie le richieste, ricorda chi chiama e conserva il testo delle conversazioni (spegnendolo, quello già salvato viene cancellato). Anche cambiare il numero da cui inoltri le chiamate, avviare la verifica dell'inoltro, che fa una breve chiamata di prova al tuo numero, e segnare come completata la configurazione iniziale | | `calls:read` | Call log, summaries, stats and counts, PDF/Excel export of the same, and calls Talki refused before answering (with the caller's number) | Leggere le chiamate ricevute: chi ha chiamato, quando, quanto è durata e il riassunto di ogni conversazione, con le statistiche e l'esportazione in PDF o Excel di questi stessi dati. Anche le chiamate che Talki non ha potuto accettare, ad esempio perché i minuti erano finiti, con il numero di chi chiamava | | `calls:write` | Mark calls read or unread, archive and restore them | Segnare le chiamate come lette o da leggere, e archiviarle o ripristinarle | | `calls:delete` | Permanently delete calls, each with its transcript, the questions it left and the caller names heard on it; appointments stay | Eliminare definitivamente le chiamate ricevute, insieme al testo della conversazione, alle richieste che l'agente non ha saputo risolvere e ai nomi sentiti dall'assistente che non hai ancora aggiunto. Una chiamata eliminata non si può recuperare; gli appuntamenti presi durante la chiamata restano in calendario | | `transcripts:read` | Full text of call conversations | Leggere il testo completo delle conversazioni con i tuoi clienti | | `appointments:read` | Calendar appointments | Vedere tutti gli appuntamenti in calendario | | `appointments:write` | Book, move and cancel appointments | Prendere, spostare e disdire appuntamenti a tuo nome | | `contacts:read` | Contact book, tags, caller names heard on calls, VIP and blocked numbers, blocked call attempts | Vedere la tua rubrica: nomi, numeri e note di ogni contatto, quante volte ha chiamato e le etichette. Anche i nomi che l'assistente ha sentito da chi chiama e che non hai ancora aggiunto, i numeri VIP e bloccati con il nome che hai dato a ognuno, e le chiamate rifiutate perché il numero era bloccato | | `contacts:write` | Create, update and delete contacts, tags and VIP or blocked numbers; approve or discard heard names | Aggiungere, modificare ed eliminare i contatti in rubrica, creare, rinominare ed eliminare etichette e assegnarle, confermare o scartare i nomi sentiti dall'assistente, e aggiungere, cambiare o togliere numeri VIP e bloccati | | `services:read` | Services offered and pricing | Vedere il tuo listino: servizi, durate e prezzi | | `services:write` | Create, modify, deactivate and delete services | Aggiungere servizi al listino, modificarne nomi, durate e prezzi, disattivarli ed eliminarli | | `resources:read` | Resources, capacity and seat availability | Vedere le tue risorse: postazioni, sale, attrezzature | | `resources:write` | Create shared resources, and modify and delete resources | Aggiungere, modificare ed eliminare le risorse: postazioni, sale, attrezzature | | `unresolved:read` | Requests the agent could not resolve | Vedere le richieste che l'agente non ha saputo risolvere: il numero di chi ha chiamato e cosa ha chiesto | | `unresolved:write` | Resolve, dismiss or reopen unresolved requests | Segnare come gestite, scartare o riaprire le richieste che l'agente non ha saputo risolvere | | `knowledge:read` | The assistant's documents (files, text notes, websites and their imported pages), each one's status and the text read from it, and the original files | Vedere i Documenti dell'assistente: i file, i testi e i siti caricati, con le pagine importate, lo stato di ognuno e il testo che ne ha ricavato, e scaricare i file originali | | `knowledge:write` | Add, replace, rename, switch on or off and delete documents (files, text notes, websites and single imported pages), and teach the assistant new answers; the assistant uses what is added with every caller | Gestire i Documenti dell'assistente: aggiungere, sostituire, rinominare, attivare o disattivare ed eliminare file, testi, siti e singole pagine importate, e insegnare nuove risposte. L'assistente userà questi contenuti con chiunque chiami | | `availability:read` | Free and bookable slots | Vedere quando sei libero e quando sei occupato | ## Manage an account ```text GET https://api.talki.it/oauth/managed-accounts/{account_id} PUT https://api.talki.it/oauth/managed-accounts/{account_id} {"plan": "base"} DELETE https://api.talki.it/oauth/managed-accounts/{account_id} POST https://api.talki.it/oauth/managed-accounts/{account_id}/suspend POST https://api.talki.it/oauth/managed-accounts/{account_id}/reactivate POST https://api.talki.it/oauth/managed-accounts/{account_id}/minutes GET https://api.talki.it/oauth/managed-accounts/{account_id}/minutes ``` ```bash curl --fail-with-body -X PUT \ https://api.talki.it/oauth/managed-accounts/f7e39b9a-1ac8-45e4-93da-2600f17cb286 \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"plan": "base"}' ``` `GET`, `PUT`, `DELETE`, `/suspend` and `/reactivate` answer with the account's state: ```json { "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "plan": "pro", "scheduled_plan": "base", "scheduled_at": "2026-10-31T23:00:00+00:00", "monthly_minutes": 80, "minutes_included": 80, "managed_since": "2026-10-07T11:09:43+00:00", "ends_at": null, "test": false, "test_expires_at": null, "talki_number": "+390212345678", "line_status": "verified", "suspended": false, "suspended_at": null, "suspension_reason": null, "extra_seconds_balance": 7230, "extra_minutes_balance": 120, "partner_notifies_customer": false } ``` | Field | Meaning | |---|---| | `plan` | The plan your app pays for | | `scheduled_plan`, `scheduled_at` | A downgrade waiting for the 1st, and when it applies; see [Change the plan](#change-the-plan) | | `monthly_minutes` | The [monthly minutes](#set-the-monthly-minutes) you set; `null` when none is set, or once the customer has taken the account over with their own subscription | | `minutes_included` | The minutes the account's month includes, against which its calls are refused: the plan's, lowered or capped by `monthly_minutes`. `null` when nothing caps them | | `managed_since` | When your app started paying for the account | | `ends_at` | Set once management has ended: access, and your bill, run to this moment; see [End management](#end-management) | | `test`, `test_expires_at` | Set on a [test account](#test-accounts) | | `talki_number` | The Talki number the business forwards its calls to; `null` while none is assigned | | `line_status` | The same value as `line.status` on `GET /v1/account`: whether forwarding to `talki_number` is proven | | `suspended`, `suspended_at`, `suspension_reason` | Whether the service is [suspended](#suspend-and-reactivate-an-account), since when and with which `reason`; `suspended_at` and `suspension_reason` are `null` when it is not | | `extra_seconds_balance`, `extra_minutes_balance` | The [extra minutes](#add-extra-minutes) on the account as they stand now; minutes are rounded down | | `partner_notifies_customer` | `true` while your app [notifies the customer itself](#notify-customers-yourself) | An `account_id` your app does not manage answers `404 not_managed`, whether the account exists or not. Each of these routes allows 120 requests per minute per address. An account created with no free number is given one automatically as soon as one is, unless management has already ended: `talki_number` stops being `null`, and your app receives an `account.updated` [app event](/app-events/) with the account's `phone_number` when it subscribes to that event and holds `account:read`. ### Change the plan - An **upgrade** applies at once. - A **downgrade** applies on the **1st of the next month**, Italian time, and shows in `scheduled_plan` and `scheduled_at` until then. - A new change replaces the scheduled one; changing back to the current plan cancels it. - `unlimited` has no minute cap and Business limits otherwise. It is accepted only when enabled for your app, and ranks above `business`: moving onto it applies at once, moving off it on the 1st of the next month. ### Set the monthly minutes `monthly_minutes` is the number of minutes a month you sold the customer: once the month has used them, and any [extra minutes](#add-extra-minutes), the account's calls are refused, with `reason` `minutes` in `GET /v1/refused_calls`, until its minutes renew. Send it on [create](#create-an-account) or with `PUT`, as a whole number from 1 to 100,000: ```bash curl --fail-with-body -X PUT \ https://api.talki.it/oauth/managed-accounts/f7e39b9a-1ac8-45e4-93da-2600f17cb286 \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"monthly_minutes": 80}' ``` - `PUT` takes `plan`, `monthly_minutes`, or both. `"monthly_minutes": null` removes the allowance; leaving the field out keeps it. - On a plan with a minute cap, `monthly_minutes` can only lower the plan's minutes. Above them the answer is `400 cap_above_plan` and nothing changes. It is checked against the plan in the same request, otherwise against the current plan and a scheduled downgrade. A `plan` sent alone that includes fewer minutes than the allowance already set is `400 cap_above_plan` too: send a lower `monthly_minutes`, or `null`, in the same request. - On a plan with no cap, `unlimited`, `monthly_minutes` becomes the cap. - A change applies at once, both ways, unlike a plan downgrade: raising it lets the next call through, and lowering it below what the month has already used sends the next call to extra minutes, or refuses it when there are none. - `minutes_included` on the account shows the result, and the [usage events](#usage-events) report it; their threshold also counts the account's extra minutes. - An activation carries no `monthly_minutes`: set it with `PUT` after the exchange. An activation for an account you still manage keeps its allowance, and when the activation's plan includes fewer minutes, the exchange fails with `invalid_grant`. - Once management has ended, `monthly_minutes` can no longer change (`409 partner_ending`). The allowance applies until `ends_at`, unless the customer takes the account over with their own subscription first. ### Suspend and reactivate an account Suspend the service of an account, for example when the customer stops paying you: ```bash curl --fail-with-body -X POST \ https://api.talki.it/oauth/managed-accounts/f7e39b9a-1ac8-45e4-93da-2600f17cb286/suspend \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"reason": "non_payment", "note": "Invoice 2026-118 unpaid"}' ``` | Field | Rule | |---|---| | `reason` | **Required.** `non_payment` or `other`. | | `note` | Optional, up to 500 characters. Stored with the suspension; the API never returns it. | While the account is suspended: - its calls are refused, with `reason` `suspended` in `GET /v1/refused_calls`; - `POST /v1/forwarding_check` and `POST /v1/agent_test` answer `409 account_suspended`, and the customer cannot test the assistant or use the assistant chat in the Talki apps; - the customer is told in the Talki apps that the service is suspended and to contact your app. `reason` and `note` are not shown in the Talki apps, but the customer's export of their own data includes them. Everything else keeps working: the customer's login and data, the Talki number, your app's access to `/v1` and its [app events](/app-events/). The plan runs on and the account stays on your bill. `POST .../reactivate` takes no fields and lifts the suspension at once. Both routes are idempotent by state and answer `200` with the account: a second `/suspend` keeps the first suspension's date, `reason` and `note`, and `/reactivate` on an account that is not suspended changes nothing. A suspension holds until you reactivate the account, through the grace month after `DELETE` included. It stops applying when the customer takes the account over with their own subscription, and `/suspend` then answers `409 partner_ending`. ### Add extra minutes Add minutes to an account, for example a pack the customer bought on your platform: ```bash curl --fail-with-body -X POST \ https://api.talki.it/oauth/managed-accounts/f7e39b9a-1ac8-45e4-93da-2600f17cb286/minutes \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9c4d2e71-0b3a-4f65-8e1d-6a7b2c9f0e34" \ -d '{"minutes": 120, "order_ref": "ord_2291"}' ``` | Field | Rule | |---|---| | `minutes` | A whole number from 1 to 100,000. | | `seconds` | A whole number from 60 to 6,000,000. Send exactly one of `minutes` and `seconds`. | | `order_ref` | Optional, up to 200 characters. Your own order id, returned as sent. | `201 Created`, with the account's extra minutes after the grant: ```json { "object": "minute_grant", "id": "3e8b1f2a-6c4d-4a9e-b7f0-1d2c3b4a5e6f", "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "seconds": 7200, "minutes": 120, "order_ref": "ord_2291", "livemode": true, "created_at": "2026-10-20T10:04:11+00:00", "extra_seconds_balance": 7230, "extra_minutes_balance": 120 } ``` `Idempotency-Key` is required. A retry with the same key and body answers `200` with `Idempotency-Replayed: true` and the same grant, with the balance as it stands now; nothing is added twice. The same key with a different body, or for another account, is `409 idempotency_key_reuse`. How the minutes are spent: - They are spent only after the month's `minutes_included`, and carry over from month to month. - On a plan with no cap and no `monthly_minutes` they are never spent; the grant is still recorded. - The customer cannot buy extra minutes on an account you manage; only your server adds them. - Once management has ended, no minutes can be added (`409 partner_ending`). A retry of an earlier grant is still answered until `ends_at`, and with `404 not_managed` after it. - At `ends_at` the remaining minutes are lost, unless the customer has taken the account over with their own subscription: then they stay on the account. `minutes` and `extra_minutes_balance` are whole minutes, rounded down; the `seconds` figures are exact. The grants show in [`GET /oauth/app/usage`](#read-usage-from-your-server) as `minute_grants`. `GET .../minutes` lists the grants your app made to the account, newest first, as `{"object": "list", "data", "has_more", "next_cursor"}`. Query: `limit` (1 to 100, default 25) and `cursor` (a previous page's `next_cursor`). ### End management `DELETE` keeps the customer active, on your bill, **until the end of the current month** (`ends_at`). The customer receives an email saying so, with a reminder to turn off call forwarding, unless your app [notifies its customers itself](#notify-customers-yourself). After `ends_at` the account is an ordinary Talki account and its Talki number is released; unless the customer subscribes, your installation receives [`installation.access_suspended`](/app-events/). Once management has ended, the plan and `monthly_minutes` can no longer change and no minutes can be added. Management also ends, the same way, when the customer disconnects your app, or when your app revokes the installation. **Calling [`/oauth/revoke`](/oauth/#revoke) with any live token revokes the whole installation and therefore ends management** — do not call it when one of your users logs out; discard the token instead. The console's Businesses page offers the plan change and the end of management: the plan picker, the «Managed by you» badge and «End management». An app that manages accounts cannot be deleted until management of each has ended. ## API access on any plan Through your app, a managed account reaches `/v1` and [app events](/app-events/) on every plan, not only Business. The customer's own API keys, and any other app, still need Business. ## Usage events Your app receives `usage.threshold_reached` and `usage.extra_credits_low` for each account it pays for, at its events URL, like [app events](/app-events/). It needs no subscription and no scope for them, and no other app receives them. Their fields are listed in [Usage events](/webhooks/#usage-events). - They are sent only while your app pays for the account and its installation is active. They stop at once when the customer disconnects your app or your app revokes the installation. When you end management with `DELETE`, they keep coming until `ends_at`, or until the customer subscribes on their own if that comes first. - Neither is sent while nothing caps the account's minutes: an account on `unlimited` sends them only when you set its [`monthly_minutes`](#set-the-monthly-minutes). ## Notify customers yourself When your app notifies its customers itself, the customers of the accounts it manages receive no email or push notification from Talki: no welcome email, no notifications about calls or minutes, and no end-of-management email when your app ends management. Tell them from the [app events](/app-events/) your app receives. Messages with no event of their own are not sent either: read refused calls from `GET /v1/refused_calls`, and tell the customer yourself when their calendar stops syncing. It is enabled per app, on request, for an app enabled for managed billing: write to [info@talki.it](mailto:info@talki.it) with the app's client ID and the product it serves. It applies at once to every account the app manages. - It applies while your app manages the account and is installed on it, the grace month after `DELETE` included. Talki's own messages resume when the customer disconnects your app, takes the account over with their own subscription, or reaches `ends_at`. - The end-of-management email is still sent when management ends without your app ending it, for example when the customer disconnects your app. - Always sent, whatever the setting: account security emails such as email confirmation and password reset, legal notices, receipts for purchases the customer makes, service-wide maintenance notices, and notifications about the customer's own webhook endpoints or store subscription. - `partner_notifies_customer` on the [account](#manage-an-account) is `true` while it applies to that account. ## Monthly usage The console's **Usage** page, on an app enabled for managed billing, shows the month's usage of every account you manage: - totals for each plan (duration, calls, businesses), `unlimited` first; - one row per business, with your `external_ref` and its split by plan. Businesses that deleted their Talki account are grouped in one row, and their calls still count; - **Export CSV**: one line per business and plan, with the columns `business_name, external_ref, account_id, plan, seconds, calls, duration`. How usage is counted: - **To the second, never rounded.** Durations display as whole minutes plus the remaining seconds (`12,345 min 17 s`); the CSV's `seconds` column is exact. - **By the month the call was answered, Italian time.** A call belongs entirely to the month and plan it was answered in, even when it ends after midnight on the 1st or after a plan change. - Only calls answered while you managed the account are counted. ### Read usage from your server `GET /oauth/app/usage` returns the same month to your server, with the client credentials over HTTP Basic. It covers the accounts your app pays for only. ```bash curl --fail-with-body "https://api.talki.it/oauth/app/usage?month=2026-10" \ -u "$TALKI_CLIENT_ID:$TALKI_CLIENT_SECRET" ``` ```json { "object": "usage_report", "month": "2026-10", "livemode": true, "plans": [ {"plan": "pro", "partner_only": false, "seconds": 5400, "calls": 31}, {"plan": "unlimited", "partner_only": true, "seconds": 32170, "calls": 263} ], "businesses": [ { "account_id": "f7e39b9a-1ac8-45e4-93da-2600f17cb286", "business_name": "Studio Bellini", "external_ref": "CUST-42", "plans": [ {"plan": "pro", "partner_only": false, "seconds": 5400, "calls": 31}, {"plan": "unlimited", "partner_only": true, "seconds": 30960, "calls": 254} ], "minute_grants": {"seconds": 7200, "count": 1} }, { "account_id": null, "business_name": "Account eliminato", "external_ref": null, "plans": [ {"plan": "unlimited", "partner_only": true, "seconds": 1210, "calls": 9} ], "minute_grants": {"seconds": 0, "count": 0} } ], "minute_grants": {"seconds": 7200, "count": 1} } ``` | Parameter | Rule | |---|---| | `month` | `YYYY-MM`, Italian time. Omitted: the current month, still counting | | `format` | `json` (default) or `csv` | | `livemode` | `true` (default): calls on real accounts. `false`: only the fake calls on your [test accounts](#test-usage) | - Every figure is per plan: `plans` totals each plan across all businesses, and each business lists its own plans. Bill each customer from its plan entries; there is no total across plans. - `format=csv` returns the console's export columns, `business_name, external_ref, account_id, plan, seconds, calls, duration`, one line per business and plan. - Calls are counted as on the Usage page: to the second, whole, in the month and on the plan they were answered on, and only while your app managed the account. - Businesses that deleted their Talki account share one row with `account_id: null`. - A business your app is no longer installed on keeps its `account_id` and `external_ref`, with `business_name: null`. - Usage is recorded from 14 October 2026: earlier months are empty, and so is every month of an app that manages no account. - `livemode` in the answer is the mode you asked for. Test usage is never mixed into the live report. - `minute_grants`, on each business and at the top level, totals the [extra minutes](#add-extra-minutes) your app added in the month, Italian time: `seconds` added and `count` of grants, in the mode you asked for. A business that only had minutes added is listed with empty `plans`. The CSV leaves them out. Errors use the API envelope: `401 invalid_client` for wrong credentials, `400 invalid_parameter` for a bad `month`, `format` or `livemode`, and `409 client_disabled` for an app Talki has disabled. Past 60 requests per minute per address the answer is `429`, with `Retry-After`. ## Errors | HTTP | `code` | Where | Meaning | |---|---|---|---| | 400 | `invalid_parameter` | All | The body is not a JSON object, a field is invalid, or a query or path parameter is malformed; the message names it | | 400 | `idempotency_key_required` | `POST /oauth/managed-accounts`, `/minutes` | `Idempotency-Key` is missing | | 400 | `invalid_plan` | Activations, `PUT` | Not a plan your app may pay for; the message lists them | | 400 | `invalid_phone` | Activations | The `forwarding_number` hint is not a valid number | | 400 | `cap_above_plan` | `POST /oauth/managed-accounts`, `PUT` | `monthly_minutes` is above the minutes the plan includes, or a `plan` includes fewer minutes than the `monthly_minutes` already set | | 401 | `invalid_client` | All | Wrong client ID or secret | | 403 | `not_a_billing_partner` | Activations | The app is not enabled for managed billing | | 404 | `not_managed` | Managed accounts | Your app does not manage that account | | 409 | `partner_ending` | `PUT`, `/minutes`, `/suspend` | Management has ended: the plan and `monthly_minutes` can no longer change and no minutes can be added. On `/suspend`: the customer has taken the account over with their own subscription | | 409 | `idempotency_key_reuse` | `POST /oauth/managed-accounts`, `/minutes` | The key was used for a different request | | 409 | `extra_minutes_limit` | `/minutes` | The account cannot hold that many extra minutes | | 429 | `rate_limit_exceeded` | Activations, managed accounts | Honour `Retry-After` | The customer can meet these on the consent screen. They are explained there, in Italian, and the customer is not sent back to you: | `code` | Meaning | What to do | |---|---|---| | `enrollment_invalid` | The activation expired or was already used | Mint a new one | | `email_mismatch` | The activation is for a different email | The customer signs in with the email you gave, or you mint one with the right email | | `already_subscribed` | The account has its own Talki subscription | The customer cancels it, or uses another account | | `managed_by_another_partner` | Another app already manages the account | The customer uses another account | | `partner_ending` | Management of the account has just ended | It can be activated again from the 1st of the next month | | `plan_unavailable` | The activation's plan is no longer one your app may pay for | Mint a new activation | When an activation expires before the customer completes it, the account they created stays. On their next visit to Talki they are told to go back to your software and press the button again: mint a new activation, and they sign in with the account they already have. ## Test the integration - Your [sandbox](/sandbox/) account can be activated through the existing-account path, with no real business. - With headless provisioning enabled, create [test accounts](#test-accounts) from your server and try plans, suspension, extra minutes, fake calls and the usage report on them. - For the full path with a new customer — signup, confirmation email, plans other than Business — ask Talki for a test environment. ### Test accounts An app enabled for [headless provisioning](#create-accounts-from-your-server) creates a test account by sending `"test": true` in the body of `POST /oauth/managed-accounts`. The request is checked like a real one, `Idempotency-Key` included, and the answer has the same shape, with `"test": true` and `test_expires_at`. A test account: - has no Talki number (`talki_number` is `null`), sends no email and is billed to nobody; - is in test mode: `livemode` is `false` in the token answer, on `GET /v1/account`, in [`/oauth/app/installations`](/oauth/#list-your-installations) and on every event it sends; - is erased within the hour after `test_expires_at`, 7 days after it was created. Your app may hold 10 test accounts whose `test_expires_at` has not passed; one more answers `409 test_account_limit`. Creating accounts, test ones included, is limited to 30 an hour per app (`429 rate_limit_exceeded`). An email address already used for one of your live test accounts answers `409 account_exists`. `GET`, `PUT` and `DELETE /oauth/managed-accounts/{account_id}`, `/suspend`, `/reactivate` and `/minutes` work on a test account as on a real one, so plan changes, monthly minutes, suspension, extra minutes and the end of management can be tried in full. Fake calls are not refused while a test account is suspended: read the suspension from the account. #### Give it calls A test account has no API key. Drive the [sandbox scenarios](/sandbox/#scenarios) on it with the access token your app received for it: ```bash curl --fail-with-body -X POST https://api.talki.it/v1/sandbox/calls \ -H "Authorization: Bearer $TALKI_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"scenario": "call_no_action", "caller_phone": "+393480000001"}' ``` - `POST /v1/sandbox/calls` and `GET /v1/sandbox/scenarios` accept the token of the app that created the account, until `test_expires_at`. Any other token answers `403 sandbox_api_key_required`. - A test account starts with no weekly hours and no services. `booking_created` books 10:00 Italian time on a Tuesday to Saturday one to four weeks ahead. So first set weekly hours that cover 10:00 on those days with `PUT /v1/working_hours` (for example `{"slots": [{"days": [2, 3, 4, 5, 6], "start": "09:00", "end": "18:00"}]}`). Then create a service with a `duration_minutes` and an `initial_capacity` with `POST /v1/services`. `booking_rescheduled` and `booking_cancelled` act on an upcoming booking at the main location. When the account lacks what a scenario needs, the call answers `400 sandbox_not_ready`. - `POST /v1/sandbox/reset` answers `403 forbidden`: a test account is never reset. Create another one instead. #### Test usage A fake call on a test account counts like a real call: - its seconds go to the account's minutes, in `GET /v1/usage` and `GET /v1/usage/history`, and the [usage events](#usage-events) are sent at the same thresholds, with `livemode: false`; - it is in `GET /oauth/app/usage?livemode=false`, on the plan it was answered on. With `format=csv` the file is named `-usage--test.csv`. It never appears in the live report, on the console's Usage page or on an invoice. Minutes added to a test account have `livemode: false` and are counted in `minute_grants` with `livemode=false` only. --- # Create a CRM activity after a call Source: https://developers.talki.it/guides/crm-activity-after-call/ Turn each answered call into a recoverable CRM activity. The smallest complete Talki integration: every finished call becomes an activity attached to a CRM person. ## 1. Subscribe Create a webhook endpoint for `call.ended` and `call.deleted` and save its signing secret. Send a `ping` from the Talki app to prove the route is reachable. ## 2. Accept safely On every request: capture the raw body, [verify `X-Talki-Signature`](/webhooks/#signature-verification), insert the envelope `id` under a unique constraint, and return `204` once the event is durably queued. An `id` you have already seen is a redelivery — answer `204` and do nothing else. ## 3. Resolve the person Read the Call from `data.object`. Prefer `caller.contact_id` once you have mapped it; on first encounter, match `caller.phone_number` in E.164. A withheld number is `null`, so file an unmatched activity rather than invent an identity. `caller.display_name` holds only a name the owner curated or imported, and is legitimately `null` much of the time. ## 4. Upsert the activity Key the activity on the Talki Call `id`. Store the summary, the timing, the caller mapping and whichever `appointment_ids` your CRM needs, and upsert, so redelivery and REST recovery repair the same record instead of adding one. On `call.deleted`, delete the activity, and delete the tasks (step 6) whose request IDs are in `data.deleted.unresolved_request_ids`: the event is the only trace a deleted call leaves, and its unresolved requests are deleted with it without an event of their own. ## 5. Recover missed calls Backfill once with `GET /v1/calls?sync=true&limit=100`. For every page: 1. Upsert each Call by its Talki ID. 2. Store `next_cursor` whenever it is non-null, including on the page where `has_more` becomes false. 3. Continue immediately while `has_more` is true. On startup and on a schedule, send the stored cursor back with `sync=true` and repeat. The cursor is a commit-ordered position, so there is no overlap window to tune. Keep the webhook path running for latency; the feed is what makes the copy complete after downtime. ## 6. Turn open questions into tasks A call where the assistant could not answer a question leaves an unresolved request: a question the assistant recorded, a number to call back, and a status that closes. Walk it with the same loop, on its own cursor: ```bash curl --fail-with-body 'https://api.talki.it/v1/unresolved_requests?limit=100' \ -H "Authorization: Bearer $TALKI_API_KEY" ``` Create a task keyed on the Talki request ID, attached to the person you resolved in step 3, and link it to `call_id` so task and activity point at each other. Sync without the `status` filter: a request that moves to `resolved` or `dismissed` comes back through the feed, and that transition is what closes your task. Needs `unresolved:read`. When your users close the task with an answer every caller should hear, you can also [teach it to the assistant](/guides/configure-assistant/#teach-the-assistant-an-answer), with `knowledge:write`. --- # Mirror a Talki appointment Source: https://developers.talki.it/guides/mirror-appointment/ Keep a CRM calendar in step with Talki bookings and write changes safely. Webhooks for immediate updates, the appointment change feed for backfill and repair. If the integration also writes bookings, idempotency and the event's `origin` are what keep it from feeding itself. ## Authority - Talki decides whether a Talki appointment commits. Mirror the result; do not re-arbitrate it. - Provider calendars can narrow availability, and are not the source of truth for Talki appointments. - The Talki appointment ID is the external unique key. ## Events Subscribe to: - `appointment.created`: upsert the booking. - `appointment.updated`: replace the mirrored fields for the same ID, including a restored appointment. - `appointment.cancelled`: mark the same ID cancelled. A reschedule on a call or through the API keeps the ID and arrives as `appointment.updated`; one in the Talki app can keep the existing ID too. Handle the event types literally. `notes` is the note on the booking: what `POST /v1/appointments` sent as `notes`, or what the assistant wrote on a booking taken on a call. It is null when there is none. Deleting a service sends no appointment event. Its bookings keep their ID, with `service.id` null and `service.name` the name they were booked under: a rename after booking does not carry over, so it can differ from the name you mirrored. Their `updated_at` does not change, so an incremental walk of the `GET /v1/appointments` feed does not return them again; a full walk shows them that way. On `service.deleted`, clear that service's ID on the bookings you mirror. Verify and deduplicate the envelope as in the [call activity guide](/guides/crm-activity-after-call/#2-accept-safely). On API writes, `origin.credential_id` names the key behind the event and `origin.idempotency_key` ties it to your request, so you can either skip your own writes or apply them as idempotent confirmations. ## Backfill and recovery Walk `GET /v1/appointments?limit=100` from no cursor. Cancelled appointments are in the feed and must stay in it during a sync. Upsert by ID, store every non-null `next_cursor`, continue while `has_more` is true. Later polls resume from the stored cursor. It comes back on the final non-empty page because it records your position, not the existence of a next page. `updated_at` is not a watermark. ## Write a booking 1. Read the service from `GET /v1/services/{service_id}`. 2. Query `GET /v1/availability` for the service, local date range, and quantity. 3. Choose a start and end inside a returned window. 4. Send `POST /v1/appointments` with a new `Idempotency-Key`. Availability is advisory: on a booking conflict, refresh the windows and offer another time. Retry a lost or transient response with the same key and the same request, and the first result is replayed instead of booking twice. `PATCH /v1/appointments/{id}` with both `start_at` and `end_at` moves a booking and keeps its ID. A booking of one service with a `duration_minutes` must keep that length or the length it has now, so a booking the owner lengthened in Talki can move as it is. A move that keeps neither is refused with `400 invalid_parameter`, as moving one edge of such a booking normally is. A booking of several services, of a service booked by the day or night, of a deleted service or of none is not held to a length. `DELETE`, with an idempotency key, cancels it: the record stays readable with `status: "cancelled"` and stays in the feed. --- # Configure the assistant Source: https://developers.talki.it/guides/configure-assistant/ Change the assistant's settings, the weekly hours, closures and special openings, create services and resources, and teach the assistant answers, without overwriting the owner's edits. A platform that manages a business's set-up from its own panel can change what the assistant tells callers, how it speaks, when the business takes bookings, and what it offers. The owner edits the same settings in Talki, so read before you write, send the version you read, and keep your copy current with the events at the end of this page. | Setting | Read | Write | |---|---|---| | Business information, instructions, voice | `GET /v1/account` | `PATCH /v1/account` | | Name, opening and greeting, switches, transfer numbers | `GET /v1/account` | `PATCH /v1/account` | | Talki number, forwarding number | `GET /v1/account` | `PATCH /v1/account` (forwarding number) | | Forwarding check | `GET /v1/forwarding_check` | `POST /v1/forwarding_check` | | Onboarding | `GET /v1/account` | `POST /v1/account/complete_onboarding` | | Voices on offer | `GET /v1/voices` | — | | Weekly hours | `GET /v1/working_hours` | `PUT /v1/working_hours` | | Closures and special openings | `GET /v1/closures` | `POST`, `PATCH` and `DELETE /v1/closures` | | Services | `GET /v1/services` | `POST`, `PATCH` and `DELETE /v1/services` | | Resources | `GET /v1/resources` | `POST`, `PATCH` and `DELETE /v1/resources` | | Answers taught to the assistant | `GET /v1/documents?faq=true` | `POST /v1/unresolved_requests/{request_id}/teach` | | [Knowledge base](/guides/knowledge-base/): notes, files, websites | `GET /v1/documents` | `POST`, `PUT`, `PATCH` and `DELETE /v1/documents` | Reading the account, voices, hours, closures and the forwarding check needs `account:read`; writing them needs `account:write`. Writing a service needs `services:write`, a resource `resources:write`, and teaching an answer or writing the knowledge base `knowledge:write`; reading the knowledge base needs `knowledge:read`. The account, the weekly hours and the forwarding check are those of one location: the main one, or the one `?location_id=` names. An account whose `multi_sede` is `false` has only the main location, and the requests on this page need no `location_id`. For an account with several, see [Work with locations](/guides/locations/). ## Business information and instructions `GET /v1/account` returns two texts the owner writes in Talki, under «Informazioni aggiuntive» and «Istruzioni aggiuntive»: - `business_information` — facts the assistant can tell callers: "parking is free in the courtyard", "we do not sell gift cards". Up to 2,000 characters. - `instructions` — short orders about how the assistant behaves on a call: "always use the formal register", "never quote prices for colour treatments". Up to 600 characters. A few short orders work better than a long text: the longer it is, the less likely each order is to be followed. Both are `null` when empty. Neither is the assistant's full set of instructions: each sits inside Talki's own rules for calls, which the API does not return and cannot change. Write facts and orders about this business, as the owner would. `PATCH /v1/account` takes these two, `voice`, and every setting in the next sections. Send the fields you change; an omitted field is left as it is. `null` or `""` clears a text. A body with no writable field is refused with `400 invalid_parameter`. ```bash curl --fail-with-body -X PATCH https://api.talki.it/v1/account \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H 'If-Match: "9c1e4b7a2f0d4c68b35e7a1d9f2c6b40"' \ -H "Content-Type: application/json" \ --data '{"instructions":"Always use the formal register."}' ``` ## Other assistant settings `PATCH /v1/account` also takes the business name (`business_name`), which name the assistant answers as (`answers_as`), the start of the first sentence (`call_opening`) and the line after it (`greeting`), the switches (`multilingual_enabled`, `bookings_enabled`, `unresolved_requests_enabled`, `remember_callers`, `store_transcripts`, `notify_on_every_call`, `notify_on_incoming_call`), the area served (`service_area`), the people a call can be handed to (`transfer_numbers`) and when read calls are archived on their own (`call_auto_archive_days`). The reference describes each one. These behave differently from a plain value: - `call_opening` needs a plan that includes a custom opening, or the write is refused with `403 plan_feature_unavailable`. Clearing it is allowed on every plan. - `store_transcripts: false` deletes every transcript already stored, at once and for good. A test-mode credential sending it is refused with `409 livemode_required`. - `transfer_numbers` replaces the whole list. An empty list needs nothing else. A non-empty one needs a `forwarding_number`, stored or sent in the same request, and none of its numbers may equal it. Each `label` is at most 80 characters and unique in the list, ignoring case. With several locations, the rules cover every location's line; see [Configure one location](/guides/locations/#configure-one-location). - `forwarding_number` is held to the same rule from the other side: it cannot equal one of the `transfer_numbers`, and cannot be cleared while the list has entries, unless the same request replaces the list. - `notify_on_every_call` and `notify_on_incoming_call` control push notifications to the owner's Talki mobile app only, and reach nobody until the owner has signed in there. To notify by email or SMS, send your own message on `call.ended` or `appointment.created`. - `call_auto_archive_days` is the account's own setting, the same at every location. Send it without `location_id`, or with the main location's: with another location's, the request is refused with `400 invalid_parameter`. The nightly archiving sends `call.updated` for each call, with `origin.source` `system`. - `languages` is read only: every call opens in `primary` (`it`), and the assistant can switch to any language in `supported`. `multilingual_enabled` is what changes it. ## Call forwarding A business's own line forwards its calls to the Talki number, `phone_number` on the account. Store that line as `forwarding_number`, then `POST /v1/forwarding_check`: Talki rings it from its own number, and with forwarding on the call comes straight back. Poll `GET /v1/forwarding_check` every few seconds; within about a minute `status` is `verified` or `not_detected`. A check places a real call, so it is refused with a test-mode credential (`409 livemode_required`). Each location runs one check at a time and at most 3 an hour, and an OAuth app at most 30 an hour across all the accounts it is installed on. Past either, the answer is `429 check_quota_exceeded` with a `Retry-After` header in seconds. `503 checks_busy` also carries `Retry-After`; `502 check_call_failed` means the call could not be placed, so retry later. Changing `forwarding_number` discards the last result: `last_verified_at` reads `null` until a check of the new number succeeds. `phone_number` is read-only; Talki assigns and releases it, and both send `account.updated`. `line` on the account sums this up for a status screen: `no_number` while the account has no Talki number, `unverified` until a check of the current `forwarding_number` succeeds, then `verified` with `verified_at`. A check that finds no forwarding leaves it unchanged; read that result from `GET /v1/forwarding_check`. When the business has finished setting up in your interface, call `POST /v1/account/complete_onboarding`, so the Talki app does not take the owner through its own setup again. ## Voice `GET /v1/voices` lists the voices the assistant can speak with, and the account's `voice` is one of their `id`s. Set it with `PATCH /v1/account` and `{"voice": "Sulafat"}`; an ID not in the list is refused with `400 invalid_parameter`, and `voice` cannot be cleared. Each voice has a `preview_url`: a path relative to `https://api.talki.it` that returns a short WAV sample. It needs the same credential as any other request, so a browser cannot load it directly: fetch it on your server and pass the audio on to your panel. ## Weekly hours `GET /v1/working_hours` returns `slots`. Each slot is a `start`–`end` range in the account's `timezone` on one or more `days`, numbered as ISO weekdays: `1` is Monday, `7` is Sunday. A day with a lunch break has two slots. `PUT /v1/working_hours` replaces the **whole week**, and a slot you leave out is an opening removed. To change one day, read the hours, edit the list, and send all of it back with `If-Match`: ```json { "slots": [ { "days": [2, 3, 4, 5], "start": "09:00", "end": "13:00" }, { "days": [2, 3, 4, 5], "start": "14:30", "end": "19:00" }, { "days": [6], "start": "09:00", "end": "17:00" } ] } ``` - `end` must be after `start`, so a range cannot cross midnight: send two slots, one ending at `23:59` and one starting at `00:00` on the next day. - At most 20 slots. - An empty list removes every regular opening: the assistant then offers callers no regular time to book. The new hours apply at once, both to `GET /v1/availability` and to the next caller. ## Closures A closure is a dated exception to the weekly hours, in one of two forms: - whole days: `all_day: true` with `start_date` and `end_date`, both included; - an exact range: `start_at` and `end_at`, RFC 3339 with an offset. Sending fields of both forms is refused. During a closure the assistant offers callers no time and `GET /v1/availability` reports nothing free. `reason` is optional, and the assistant can tell callers, so write it as something to say: "Christmas holidays". ```bash curl --fail-with-body -X POST https://api.talki.it/v1/closures \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Idempotency-Key: closure-2026-12-24" \ -H "Content-Type: application/json" \ --data '{"all_day":true,"start_date":"2026-12-24","end_date":"2026-12-26","reason":"Christmas holidays"}' ``` A closure without `location_id` applies at every location of the account. To close one location only, send its `location_id` in the body; it cannot be moved to another location later. **Appointments already inside a closure are not cancelled.** After creating one, list `GET /v1/appointments` with `starts_after` and `starts_before` over the closed range, and let the business decide what to move. `GET /v1/closures?from=2026-10-01&to=2026-12-31` lists the closures that overlap those local days, earliest first, in one page. Without parameters it covers a year from today; `to` can be at most 366 days after `from`. `PATCH /v1/closures/{closure_id}` changes one; to move it, send the whole new window in one of the two forms. `DELETE /v1/closures/{closure_id}` removes it, and the days it covered follow the weekly hours again. `POST` requires an `Idempotency-Key`. Send one on `DELETE` too: without it, a retried `DELETE` that had already succeeded answers `404`. A special opening adds bookable hours to one day. Create it with `kind: "open"` and an exact range that starts and ends on the same day in the account's `timezone`; ending at the next midnight is allowed, and a range that reaches into another day is refused with `400 invalid_parameter`. Whole days are for closures only. The opening's hours are added to that day's weekly hours: on a day the business is normally shut they are its only hours, and on a working day they extend it. A closure on the same day still wins over both. For several days, create one opening per day. `GET /v1/closures` lists closures unless you pass `kind=open` or `kind=all`. ## Concurrent edits: `ETag` and `If-Match` `GET` and `PATCH /v1/account`, and `GET` and `PUT /v1/working_hours`, return an `ETag` header. Send it back as `If-Match` on your next write. If the settings changed since you read them, the write is refused with `412 precondition_failed` and nothing is stored: read again, reapply your change to the fresh copy, and retry with the new tag. - The account's tag covers every field `PATCH /v1/account` can write. A change to `phone_number` or `onboarding_completed` does not move it. - A write that asks for exactly what is already stored succeeds whatever `If-Match` says. Retrying a write whose response was lost is therefore safe: the first attempt moved the tag, and the retry is not refused for having succeeded. - `*` always matches. A weak tag (`W/"…"`) never does. - Without `If-Match`, the write replaces whatever is stored. - A `412` is not stored against the request's `Idempotency-Key`, so the retry with the fresh tag can reuse the same key. - A response replayed from an `Idempotency-Key` carries no `ETag`. Read again for a current one. Tags are opaque: store them as strings and do not parse them. ## Create services and resources `POST /v1/services` creates a service with a fixed duration, active and bookable at once. Send at most one of: - `initial_capacity` — how many bookings of this service can overlap. The service gets a resource of its own, which the resource feed lists with its `owner_service_id`. - `resource_ids` — existing resources the service draws on, one unit of each per booking. With neither, the service draws on its location's default resource (`is_default`), the one the location's other services share. Sending both is refused with `400 invalid_parameter`, and so is sending neither while the location has no default resource yet. Both creates take `?location_id=`, the main location without it; see [Book and create at a location](/guides/locations/#book-and-create-at-a-location). A booking that would not fit a listed resource, or the default one, is `400 resource_too_small`, and an unknown resource `404 resource_not_found`. `POST /v1/resources` creates a `shared` resource: a `name`, a `capacity`, and optionally the `service_ids` that draw on it. A linked service that can ask for more units than `capacity` is `400 capacity_below_service_requirement`; a service booked by the day or night, or without a duration, is `400 invalid_parameter`. A resource with no linked service limits nothing until one is linked to it. For several services that compete for the same units — three wash basins shared by every treatment — create the resource first, then each service with `resource_ids`. **Both creates require `Idempotency-Key`**, as does `POST /v1/closures`. Without one, a create retried after a lost response makes a second service, and the assistant offers callers both. Retry with the same key and the same body. The two share a budget of 20 creations per minute with tag and caller-rule creates, on top of the write limit; pace a bulk set-up. See [Errors and rate limits](/errors/#rate-limits). Services booked by the day or night, resources made of sized units such as tables or rooms, party sizes, buffers and exclusions are set up in Talki. ## Delete services and resources To stop the assistant offering a service, send `PATCH /v1/services/{service_id}` with `is_active: false`. Its bookings are kept, and `is_active: true` brings it back. `DELETE /v1/services/{service_id}` deletes a service for good. Its own resource (`owner_service_id`) is deleted with it, and it stops drawing on every other resource. While any of its bookings is still to come or under way, the delete is refused with `409 service_has_upcoming_bookings`, even on an inactive service: switch it off instead, and delete it once its last booking has ended or been cancelled. Past and cancelled bookings stay, with `service.id` null and the service name they were booked under. `DELETE /v1/resources/{resource_id}` deletes a resource with its unit classes and service links. It is refused for the default resource (`409 resource_is_default`), and for the only resource of an active service, or of a service booked by the day or night even while inactive (`409 resource_required_by_service`). A booking that held one of its units keeps its time and loses the unit. Nothing is cancelled or unlinked to let a delete through; see [Delete refusals](/errors/#delete-refusals). Send an `Idempotency-Key` on both: without it, a retried `DELETE` that had already succeeded answers `404`. ## Teach the assistant an answer Once the business has dealt with an [unresolved request](/concepts/#unresolved-requests), the next caller is likely to ask the same question. `POST /v1/unresolved_requests/{request_id}/teach` adds a general answer to a FAQ note the assistant uses with every caller: ```bash curl --fail-with-body -X POST \ https://api.talki.it/v1/unresolved_requests/6c1f9a74-1d0b-4c8e-9a3d-2f5b7e0c4d13/teach \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Idempotency-Key: teach-bridal-sundays" \ -H "Content-Type: application/json" \ --data '{"question":"Do you do bridal hair on Sundays?","answer":"Yes, on the first Sunday of each month, by appointment only."}' ``` - It needs `knowledge:write`, an opt-in scope that `unresolved:write` does not include; see [Scopes](/authentication/#scopes). - Only a `resolved` request can be taught from. Resolve it first with `PATCH /v1/unresolved_requests/{request_id}` and `status: "resolved"`; otherwise the answer is `409 unresolved_request_not_resolved`. - Write `question` and `answer` for any caller, as the business would say them. They are not the request's `answer`, which records what one caller was told and is neither read nor changed here. - `location_id` says where the assistant uses the answer: omit it for the request's own location, send another location's id, or send `all` for every location. An account whose `multi_sede` is `false` has one FAQ, used at every location, whichever of its locations you name. An id that is not one of the account's locations is a 404, as is an unknown request. `question` takes up to 200 characters and `answer` up to 500, each counted after line breaks and repeated spaces become one space. - `Idempotency-Key` is required. Teaching the same request again adds another entry. - The `202` comes before the note is indexed: the assistant uses the answer shortly after, not necessarily on the very next call. The FAQ is a note the business sees in Talki, «Domande frequenti» under **Documenti**, one per location; an answer taught with `location_id` `all` goes into each location's note. The business can reword or delete what you taught, and nothing asks for its approval first: you are writing on the owner's behalf, so agree with the business what to teach. A note that has no room left for the entry is refused with `409 faq_full`, until the business tidies it in Talki; an account whose knowledge storage is full for its plan, with `413 payload_too_large`. The FAQ note is a document like any other: `GET /v1/documents?faq=true` lists it, and teaching sends `document.created` for a note it starts and `document.updated` for one it adds to, under `knowledge:read`. See [Manage the knowledge base](/guides/knowledge-base/#edit-the-faq-note). The request comes back through its change feed with no published field changed. ## Keep a copy in sync These events report a change, whoever made it — the owner in Talki, or a credential through the API: | Event | When | `data.object` | |---|---|---| | `account.updated` | A writable field of the account, `onboarding_completed` or `phone_number` changes | Account | | `working_hours.updated` | The weekly 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 | | `resource.created`, `resource.updated`, `resource.deleted` | A resource is added, changed, gains or loses a service, or is deleted | Resource | A location other than the main one reports its own settings and hours with `location.settings_updated` and `location.working_hours_updated`; see [Work with locations](/guides/locations/#keep-a-copy-in-sync). The account, hours and closure events need `account:read`, the service events `services:read` and the resource events `resources:read`. Receive them as a [webhook](/webhooks/) or as [app events](/app-events/). A save that changes none of the published fields of the account, the hours or a closure sends nothing. Your own writes come back as events too. Drop those whose `origin.source` is `api` and whose `origin.credential_id` is your credential: the API key's ID, or for an app the installation's ID. Replace your copy with `data.object`. Account and WorkingHours carry no timestamp, and a Closure only its `created_at`, which a change does not move. Events can arrive out of order, so keep the envelope `created_at` of the last event you applied to each object and skip an older one. A Service or Resource carries `updated_at`: order its events by that. `closure.deleted`, `service.deleted` and `resource.deleted` carry the object as it was, and an ID seen deleted does not come back. A write to the catalogue sends an event for each service or resource it changed in passing too; see the [event catalogue](/webhooks/#event-catalogue). --- # Work with locations Source: https://developers.talki.it/guides/locations/ Read an account's locations, filter by location, configure one location at a time, and book at the right one. A business can work from more than one place. Each location has its own Talki number, its own line that forwards to it, assistant settings, weekly hours, services, resources and bookings. Every account has exactly one **main location**, and every record the API publishes says which location it belongs to, so the same integration code serves an account with one location and an account with several. ## Show locations only when `multi_sede` is true `multi_sede` on `GET /v1/account` says whether the business works with locations. When it is `false`, the account has exactly one location, the main one: do not show a location picker or a location column to the owner. Every record still carries the main location's `location_id`, and nothing changes shape if the flag later turns `true`. ## List the locations `GET /v1/locations` returns every location in one page, active ones first and then in the owner's order. It needs `account:read`. ```bash curl --fail-with-body https://api.talki.it/v1/locations \ -H "Authorization: Bearer $TALKI_API_KEY" ``` - `is_default` marks the main location. It is never switched off, and its `phone_number` and `line` are the account's. - An `inactive` location was switched off in Talki, by the owner or because the plan has room for fewer locations. It stays in the list with its records, takes no calls or bookings, and has no Talki number. - `name` is unique among active locations only. Key on `id`. Locations are created, renamed, reordered and switched off in Talki. The API has no write for them. ## `location_id` on records | Object | `location_id` | |---|---| | Call, RefusedCall | The location whose number was dialled | | UnresolvedRequest | The location of the call it was raised on | | Appointment | The location of its service | | Service, Resource | The location that offers it | | Document | The location whose assistant uses it | | CallerRule, BlockedCallAttempt | The location whose calls the rule matches; a rule added without `location_id` is the main location's | | Closure | The one location it applies at, or `null` for every location | Except on a closure, it is never null, and it never changes: a record does not move between locations. Calls from before the account had several locations belong to the main one. Contacts and tags belong to the account and carry no `location_id`. A number blocked or marked VIP at one location is not at the others: add a rule at each location it should apply at. ## Filter by location These lists take `?location_id=`. Without it they return the records of every location; with it, those of that location only: - `GET /v1/calls`, `/v1/calls/count`, `/v1/calls/stats`, `/v1/calls/export` and `POST /v1/calls/mark_all_read` - `GET /v1/refused_calls`, `/v1/refused_calls/count`, `/v1/refused_calls/summary` - `GET /v1/appointments`, `/v1/services`, `/v1/resources`, `/v1/unresolved_requests`, `/v1/documents`, `/v1/caller_rules`, `/v1/caller_rules/blocked_attempts` - `GET /v1/closures`, which with a location returns that location's closures together with those of every location The id may be an inactive location's. An id that is not one of the account's locations answers `404 resource_not_found`. A change feed can be walked for one location, because a record never changes location. The cursor it returns is bound to that location: send it back with the same `location_id`. Under another `location_id`, or without one, it is refused with `400 invalid_cursor`. To mirror the whole account, walk without the filter. ## Configure one location The configuration a location holds takes `?location_id=` too. Without it, it is the main location's: - `GET` and `PATCH /v1/account` — the assistant settings, the switches (`unresolved_requests_enabled`, `remember_callers`, `store_transcripts`, `notify_on_every_call`, `notify_on_incoming_call`), `forwarding_number`, `phone_number` and `line` of that location. A `PATCH` naming a location changes them at that location only. `call_auto_archive_days` is the account's own setting: it reads the same at every location, and is written only without `location_id` or with the main location's; another location's is refused with `400 invalid_parameter`. - `GET` and `PUT /v1/working_hours` — that location's weekly hours. - `GET` and `POST /v1/forwarding_check` — rings that location's line from its own Talki number. Each location runs one check at a time and at most 3 an hour. An inactive location has no number, so its check is refused with `409 phone_number_missing`. An inactive location's settings and hours can still be read and changed; they apply once it is switched back on. The `ETag` of `/v1/account` and `/v1/working_hours` belongs to the location it was read at. Send `If-Match` with the same `location_id` you read with. Transfer targets are checked against every location's line: a location's `transfer_numbers` may not contain its own or the main location's `forwarding_number`, nor another location's, and a location's `forwarding_number` may not be another location's line, nor a transfer target of any location, unless the same request replaces this location's own `transfer_numbers`. A non-empty `transfer_numbers` at any location needs the main location's `forwarding_number`, already stored or sent in the same request to the main location, and the main location's `forwarding_number` cannot be cleared while any location has `transfer_numbers`. A closure takes `location_id` in the body of `POST /v1/closures`. Without it, the closure applies at every location. It cannot be moved to another location afterwards: a `PATCH` with a different `location_id` is refused with `400 invalid_parameter`, so delete it and create another. ## Book and create at a location - **Appointments.** A booking is made at its service's location, against that location's hours, closures and resources; a booking without a service is made at the main location. `POST /v1/appointments` takes no `location_id`: pick the service of the right location from `GET /v1/services?location_id=`. Each location has its own copy of a service, with its own `id`. - **Services and resources.** `POST /v1/services` and `POST /v1/resources` take `?location_id=`, the main location without it. `resource_ids` and `service_ids` must belong to that location, or the create is refused with `400 invalid_parameter`. A service sent with neither `initial_capacity` nor `resource_ids` draws on its location's default resource. - **Documents.** `POST /v1/documents` takes `?location_id=`, the main location without it, and `location_ids` for a copy at each other location named; see [Copies at other locations](/guides/knowledge-base/#copies-at-other-locations). - **Inactive locations.** A location that is switched off takes no bookings. Booking a service of an inactive location, moving one of its bookings to a new time and restoring one of its cancelled bookings are refused with `400 location_inactive`. Its bookings can still be renamed and cancelled. ## Keep a copy in sync | Event | When | `data.object` | |---|---|---| | `location.created` | A location is added | Location | | `location.updated` | A location's name, order, Talki number or line changes | Location | | `location.deactivated` | A location is switched off | Location | | `location.reactivated` | A location is switched back on | Location | | `location.settings_updated` | A location other than the main one changes the assistant settings it keeps for itself | Account, read at that location | | `location.working_hours_updated` | The weekly hours of a location other than the main one change | WorkingHours of that location | All of them need `account:read`. The main location's number and line, its settings and its hours stay on `account.updated` and `working_hours.updated`, and so does `call_auto_archive_days`: each change arrives under one type only. A location can be switched off or on by the plan as well as by the owner, so expect these events with `origin.source` `system`. Switching a location off also clears its `phone_number`; that arrives as the one `location.deactivated`. --- # Manage the knowledge base Source: https://developers.talki.it/guides/knowledge-base/ Add the text notes, files and websites the assistant answers from, wait for them to be indexed, replace, switch off and download them, and keep a copy in sync. The assistant answers callers from the business's knowledge base: text notes, uploaded files and imported websites, the same documents the owner sees under **Documenti** in Talki. `/v1/documents` reads them under `knowledge:read` and writes them under `knowledge:write`, an opt-in scope. Neither scope includes the other, so an integration that writes documents and then follows their `status` needs both; see [Scopes](/authentication/#scopes). Whatever a document says, the assistant tells every caller who asks. The owner can edit or delete what you write, and nothing asks for their approval first: agree with the business what you add. | Task | Request | |---|---| | List documents, of every location or one | `GET /v1/documents` | | Read one, with its `status` | `GET /v1/documents/{document_id}` | | Read the text the assistant searches | `GET /v1/documents/{document_id}/text` | | Download an uploaded file | `GET /v1/documents/{document_id}/file` | | List a website's pages | `GET /v1/documents/{document_id}/pages` | | Add a note, a file or a website | `POST /v1/documents` | | Find a site's pages before importing it | `POST /v1/documents/discover` | | Replace a note or a file, import a website again | `PUT /v1/documents/{document_id}` | | Rename a document, switch it off or on | `PATCH /v1/documents/{document_id}` | | Delete a document | `DELETE /v1/documents/{document_id}` | | Drop one page of a website | `DELETE /v1/documents/{document_id}/pages/{page_id}` | ## Choose a source - **Text note**: content you already hold as text, such as an answer, a price list or a policy. The assistant reads exactly what you send, title included, and you replace it in place. For a FAQ you manage, one short note per question lets you replace, switch off or delete each entry on its own. - **File**: a PDF, DOCX, XLSX, TXT or CSV the business already has. Its text is read after the upload, and the file is kept for download when `file_available` says so. - **Website**: pages of the business's site. Their text is read from the pages you choose; import the site again when it changes. ## Add a text note ```bash curl --fail-with-body -X POST https://api.talki.it/v1/documents \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Idempotency-Key: note-bridal-sundays" \ -H "Content-Type: application/json" \ --data '{"title":"Bridal hair","body":"Bridal hair is booked on the first Sunday of each month, by appointment only."}' ``` The answer is `202`, with the document still `processing`: ```json { "id": "3f6b1c9e-8d27-4a5f-b0e4-6c2d9a7f1e83", "location_id": "0b7d4e19-8a2c-4f61-9d3e-5c1a7b2f8e40", "title": "Bridal hair", "source": "text", "mime_type": "text/markdown", "size_bytes": 77, "chunk_count": 0, "file_available": false, "status": "processing", "status_error": null, "enabled": true, "faq": false, "copy_group_id": null, "created_at": "2026-11-02T09:30:12Z", "updated_at": "2026-11-02T09:30:12Z" } ``` - `Idempotency-Key` is required on `POST /v1/documents`. A create retried without it would add a second document, which the assistant reads beside the first. - `?location_id=` names the location whose assistant uses the document; without it, the main location's. `location_ids` in the body adds a copy at each other location named; see [Copies at other locations](#copies-at-other-locations). ## Upload a file ```bash curl --fail-with-body -X POST https://api.talki.it/v1/documents \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Idempotency-Key: upload-price-list-2026-11" \ -F "file=@price-list.pdf;type=application/pdf" ``` - Send the file as `multipart/form-data`, in a field named `file`. Its name becomes the document's `title`. `location_ids` goes as one form field per location. - A part sent with no type, or as `application/octet-stream`, is typed by its file extension. Any other type that is not one of the accepted ones, or content that does not match its type, is refused with `415 unsupported_file_type`. - The `202` carries the document, and its copies exist from then too, before the file is read. - Reading can still fail after the `202`: a file with no readable text, an encrypted DOCX or XLSX, or a file over one of the reading limits in [Limits](#limits). The document then turns `failed`, with `status_error`. ## Import a website 1. `POST /v1/documents/discover` looks for the pages of the site at `url`, from its sitemap or its links, and returns them with the plan's `max_pages`. It imports nothing. ```bash curl --fail-with-body -X POST https://api.talki.it/v1/documents/discover \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Content-Type: application/json" \ --data '{"url":"https://salone-esempio.it"}' ``` 2. `POST /v1/documents` with `source: "website"`, the `url` and the `pages` you chose. Without `pages`, only the page at `url` is read. ```bash curl --fail-with-body -X POST https://api.talki.it/v1/documents \ -H "Authorization: Bearer $TALKI_API_KEY" \ -H "Idempotency-Key: site-salone-esempio" \ -H "Content-Type: application/json" \ --data '{"source":"website","url":"https://salone-esempio.it","pages":["https://salone-esempio.it","https://salone-esempio.it/prezzi"]}' ``` 3. Once the document is `ready` or `failed`, `GET /v1/documents/{document_id}/pages` lists each page with its `status`, which says whether the page was read and is searched; see [List a website's pages](/reference/operations/listdocumentpages/). 4. `DELETE /v1/documents/{document_id}/pages/{page_id}` drops a page and what it said; the assistant stops using it once the document is `ready` again. Dropping the last page that was read deletes the document, with `document.deleted`. - A site already imported at a location is refused there with `409 conflict`, and the message names its document: import that one again with `PUT`. - An address that is not a public `http` or `https` address is refused with `400 invalid_parameter`, as is a `pages` list longer than the plan allows. - Discovery that cannot read the site answers `502 api_error`: check the address, or retry later. - While the site is being imported, `PUT` and page deletes are refused with `409 document_not_ready`: retry once the document is `ready` or `failed`. - A page delete that would leave the site with no text to search, while other pages are still listed as read, is refused with `409 conflict`. A retry gets the same answer: import the site again with the `pages` to keep, or delete the document. - Page requests on a document that is not a website, listing or deleting, are refused with `400 invalid_parameter`. - A website's `title` is its address, and cannot be renamed. ## Wait for `status` The assistant uses a document only once it is `ready`. Every write that changes what a document holds answers before indexing, with the document `processing`; that includes a note's new title. - Each change of `status` sends a `document.updated`. Without webhooks, poll `GET /v1/documents/{document_id}`. - `failed` comes with `status_error`, a sentence for people: show it, and do not branch on its text. Send the content again with `PUT`: a new `POST` adds a second document, or for a website answers `409 conflict`. - A document that already had content keeps answering with it while it is `processing` again, and after a `failed`. - A document still `processing` six hours after its last change turns `failed`. - A file upload or a website import that failed before holding anything is deleted seven days after it failed, with `document.deleted`. - `GET /v1/documents/{document_id}/text` returns a note's text as last written, before it is indexed. For a file or a website it is empty until the document is first `ready`. ## Replace a document `PUT /v1/documents/{document_id}` keeps the document's id: - A note takes JSON with its new `body`, and `title` to rename it in the same request. It can be replaced at any time. - A file takes `multipart/form-data` with the new `file`, under the same rules as an upload. The document's `title` and `size_bytes` become the new file's once it is `ready`. - A website takes JSON with the `pages` to read, or `{}` to read again the pages it holds. A site whose import failed before any page was read holds none, and `{}` reads only its address: send `pages` again. The document turns `processing`, and keeps answering with its previous content until the new one is `ready`. - A note is never refused for its `status`. - A file can be replaced again while its new version is still being read: the newer file takes its place. Until the last file sent is in place, a replacement is refused with `409 document_not_ready`: while the file's first upload is still `processing`, and for a moment while a new version that has been read replaces the previous one. Retry shortly. - A website is refused with `409 document_not_ready` while it is being imported: retry once it is `ready` or `failed`. For a moment while a new version switches in, a `ready` site is refused the same way: retry shortly. It is refused with `409 conflict` when an older document holds the same site at its location: import that one again, or delete one of the two. - With `?also=copies`, the `document_not_ready` refusals apply to each copy too, and the message names the copy that holds the request up: retry, or send it without `?also=copies`. - A body of the wrong kind for the document, or a JSON field the body does not take, is refused with `400 invalid_parameter`; for a field, the message names it. `PUT` takes no `source`, so a JSON create body sent again with `PUT` is refused. ## Rename and switch off `PATCH /v1/documents/{document_id}` takes `title`, `enabled` or both. An omitted field is left as it is, and `null` is refused. - `enabled: false` keeps the document, but the assistant stops using it until `enabled: true`. - A note's new title is indexed again, as its text is. - A file's title cannot change while its file is `processing`, nor, with `?also=copies`, while a copy's file is: the request is refused with `409 document_not_ready`. Rename it once the file is `ready` or `failed`, or without `?also=copies`. A note is never refused. - A `PATCH` that changes nothing sends no `document.updated`. ## Copies at other locations A document belongs to one location. On an account with several, `location_ids` on `POST /v1/documents` makes a copy at each location named: a document of its own, with its own id, sharing `copy_group_id`. `GET /v1/documents?copy_group_id=` lists a group. - `?also=copies` on `PUT`, `PATCH`, `DELETE` and a page delete applies the change to every copy too. Without it, only the document named changes, and its copies keep what they hold. - Each copy counts against the knowledge storage on its own. - `409 request_conflict` means another request changed the copies at the same moment: retry. ## Download the original file `GET /v1/documents/{document_id}/file` returns an uploaded file as it was received, with its type in `Content-Type` and its name in `Content-Disposition`, when the document's `file_available` is `true`. - `file_available` turns `true` shortly after a file is first `ready`, and later for a copy at another location. While it is `false` the download answers `404 resource_not_found`. - A note, a website, a file whose first upload failed and a file whose upload was not kept have none to return. For a note, a website or a file that was read, `GET /v1/documents/{document_id}/text` returns its text instead. - A file being replaced returns its previous version until the new one has been read. It then has none until shortly after the new version is `ready`, and returns the new version from then. - `503 api_error` means the file cannot be read right now: retry later. ## Edit the FAQ note The answers taught with [`POST /v1/unresolved_requests/{request_id}/teach`](/guides/configure-assistant/#teach-the-assistant-an-answer) go to the location's FAQ note, the document with `faq: true`; a location has one at most. It is a text note like any other: `PUT` replaces its text, `PATCH` switches it off, and after a `DELETE` the next answer taught there starts a new one. The business edits it in Talki too, so read its text before you replace it. ## Limits | What | Limit | |---|---| | Note | Text up to 100 KB in UTF-8; title up to 200 characters | | File | PDF, DOCX, XLSX, TXT or CSV, up to 10 MB or the plan's lower limit | | What is read from a file | Up to 4 MB of text, 150 PDF pages, and 50,000 rows and 100 columns per spreadsheet, or the plan's lower limits for pages and rows; a file over them, or an encrypted DOCX or XLSX, turns `failed` | | Website import | Up to the plan's pages per import, `max_pages` from discovery | | Addresses | Public `http` or `https`, up to 2,048 characters | | Knowledge storage | The plan's, across every document of the account, each copy counted on its own | | Sandbox and test accounts | 5 MB in all, 1 MB per file, 5 pages per import, 20 pages per PDF, or the plan's lower limits | | Rate | `POST`, `PUT`, `PATCH` and discovery share 60 document writes a minute, on top of the write limit; see [Rate limits](/errors/#rate-limits) | A write over the knowledge storage is refused with `413 payload_too_large`; a website import over it turns `failed` instead, with `status_error`. ## Errors | HTTP | Code | When | |---|---|---| | 400 | `idempotency_key_required` | `POST /v1/documents` without `Idempotency-Key` | | 400 | `invalid_parameter` | An empty title or body, a title over 200 characters, `pages` over the plan's limit, an address that is not public `http` or `https`, a body of the wrong kind for the document, a JSON field the body does not take (the message names it), a website's new title, listing or deleting pages of a document that is not a website, or `null` in a `PATCH` | | 404 | `resource_not_found` | An unknown document, page or location, or a document with no file to download | | 409 | `conflict` | The website is already imported at that location, or an older document holds it there (the message names that document); or a page delete would leave the site with no text to search. Not retryable as sent | | 409 | `document_not_ready` | A file replacement before the last file sent is in place (retry shortly), a file's rename while its file or a copy's file is `processing`, a website re-import or page delete during an import, or a website re-import while a new version switches in (retry shortly). With `also=copies`, the message names the copy. Never for a note | | 409 | `request_conflict` | Another request changed the document's copies at the same moment | | 413 | `payload_too_large` | A file over the upload size, a note over 100 KB, or the knowledge storage full for the plan | | 415 | `unsupported_file_type` | A file of a type not accepted or not matching its content, `source: "file"` in JSON, or a body neither `application/json` nor `multipart/form-data` | | 502 | `api_error` | Discovery could not read the site | | 503 | `api_error` | The file, or the text of a file or website, cannot be read right now, including to drop a page | ## Keep a copy in sync | Event | When | `data.object` | |---|---|---| | `document.created` | A document is added: a note, an upload or a website, by a person, an app or an answer taught; one event per document, each copy included | Document | | `document.updated` | Its title, content or `enabled` changes, it turns `processing` again, or its indexing ends `ready` or `failed` | Document | | `document.deleted` | It is deleted, by a person or an app, with its last page that was read, or as a failed upload or import that held nothing | Document, as it was | - The events need `knowledge:read`. Receive them as a [webhook](/webhooks/) or as [app events](/app-events/). - They report every change, whoever made it: the owner in Talki, an app, an answer taught, or the indexing, whose `ready` or `failed` has `origin.source` `system`. A location added in Talki can bring copies of another location's documents, each with its own `document.created`. - Your own writes come back too, with `origin.source` `api` and your credential's ID in `origin.credential_id`. A response carries only the document named: apply the events for the copies a write makes with `location_ids` or changes with `?also=copies`, or list them with `?copy_group_id=`, rather than dropping your own. - Switching a document on or off, and some `status` changes, leave its `updated_at` as it is (see [Retrieve a document](/reference/operations/getdocument/)), so order its events by the envelope's `created_at` and skip an older one, or read the document again on each event. - `GET /v1/documents` is a newest-first list, not a change feed. To rebuild a copy, or to find deletions whose event you missed, walk it in full and drop the ids it no longer returns. --- # Errors and rate limits Source: https://developers.talki.it/errors/ Stable error codes, booking conflicts, request IDs, retries, and limits. ## Shape Every public API error uses one JSON envelope: ```json { "error": { "type": "invalid_request", "code": "invalid_cursor", "message": "The cursor parameter is not a cursor this API issued.", "request_id": "req_7f24a2f8c6b64e699901563a458b5031" } } ``` The same ID comes back in `X-Request-Id`. Log it, and quote it in a support request. Branch on `error.code`; the message is for humans. ## Common codes | HTTP | Code | Meaning | Action | |---|---|---|---| | 400 | `invalid_parameter` | A path, query, header, or body value is invalid | Fix the request. | | 400 | `invalid_cursor` | The cursor was altered, belongs to the other mode (a list cursor on a feed, or the reverse), was issued by another change feed, or is a change-feed cursor issued under another `location_id` filter | Resume with the filter it came with, or restart that feed without a cursor. | | 400 | `idempotency_key_required` | An appointment write (restore included), a service, resource, closure, tag, caller-rule or document create, or teaching the assistant omitted `Idempotency-Key` | Add one unique to the logical write. | | 400 | `unsupported_version` | `Talki-Version`, or the credential's pin, names a version this API does not serve | Send one of the versions the message lists. | | 401 | `invalid_credentials` | The key is missing or unusable | Check secret injection or replace the key. | | 402 | `subscription_inactive` | The account has no active subscription | Ask the owner to resolve billing. | | 403 | `insufficient_scope` | The key lacks the endpoint's scope | Use an appropriately scoped key. | | 403 | `plan_upgrade_required` | The plan does not include the REST API | Ask the owner to upgrade to Business. | | 403 | `plan_feature_unavailable` | The plan includes the API but not this feature: a custom `call_opening`, or exporting calls over a date range | Leave the feature out, or ask the owner to upgrade. | | 403 | `account_closed` | The account is scheduled for deletion | Stop syncing and contact the owner. | | 403 | `forbidden` | The credential may not take this action on this account: deleting a call during a free trial, or resetting a partner's test account | Do not retry unchanged; read the message. A call can be deleted once the trial has ended. | | 403 | `sandbox_api_key_required` | An OAuth access token called a `/v1/sandbox/*` control, other than the token of the app that created the [test account](/partner-billing/#test-accounts) it is on | Use the sandbox API key from the console. | | 404 | `resource_not_found` | The resource, or the location `location_id` names, is absent from this account; or a document has no file to download | Remove or reconcile the stale reference. For a document's file, wait for `file_available`, or read its text. | | 404 | `transcript_not_available` | The call exists but has no transcript: the account does not keep transcripts, or nothing usable was said | Do not retry; show the call without a transcript. | | 409 | `idempotency_key_reuse` | The key was already used for a different request | Use a new key for a new write. | | 409 | `request_in_progress` | The first request with this key has not finished | Retry the same request shortly. | | 409 | `appointment_cancelled` | The appointment cannot be changed in its current state | Refresh it and create a new booking if needed. | | 409 | `appointment_not_cancelled` | Restoring an appointment that is not cancelled | Nothing to restore: read the appointment. | | 409 | `livemode_required` | A test-mode credential asked for a real call or a permanent deletion: a forwarding check, `store_transcripts: false`, or deleting a call | Run it with a live credential. | | 409 | `unresolved_request_not_resolved` | Teaching the assistant from a request that is not `resolved` | Resolve the request first. | | 409 | `faq_full` | The FAQ note the answer would go into has no room left | Ask the business to tidy the note in Talki. | | 409 | `request_conflict` | Another request added the same number to the caller rules at the same moment, or changed a document's copies at the same time | Retry; for a caller rule the retry answers `200` with the rule. | | 409 | `conflict` | The request conflicts with the current state: a website already imported at that location, or a page delete that would leave a website with no text to search | Do not retry unchanged; read the message. For a website already imported, it names the document holding the site: import that one again with `PUT`. For a page delete, import the site again with the `pages` to keep, or delete the document. | | 409 | `document_not_ready` | Replacing a file before the last file sent to it is in place; renaming a file while its file, or with `also=copies` a copy's file, is `processing`; importing a website again or deleting one of its pages during an import, or importing it again while a new version switches in. A note is never refused | For a file replacement, or a website that is already `ready`, retry shortly. Otherwise retry once the `status`, of the copy named if any, is `ready` or `failed`. When the message names a copy, you can also send the request without `also=copies`. | | 412 | `precondition_failed` | `If-Match` names a version other than the stored one | Read again, reapply the change, and retry with the new `ETag`. | | 413 | `payload_too_large` | A file over the plan's upload size, a note over 100 KB, or a document write or taught answer while the account's knowledge storage is full for its plan | Send less; for storage, ask the business to free space in Talki, or to upgrade. | | 415 | `unsupported_file_type` | A document file that is not PDF, DOCX, XLSX, TXT or CSV, or whose content does not match its type, or a body neither `application/json` nor `multipart/form-data` | Convert the file, or fix the `Content-Type`. | | 429 | `rate_limit_exceeded` | The account or installation exceeded its request rate | Honour `Retry-After` and back off. | | 429 | `ip_rate_limited` | Too many failed authentications, or requests, from your address | Fix the credential; wait for `Retry-After`. | | 500 | `internal_error` | Talki hit an unexpected failure | Retry with backoff; report the request ID if persistent. | | 502, 503 | `api_error` | A service the request depends on could not answer, such as a site that could not be read for discovery (`502`), or a knowledge-base file or index that is briefly unavailable (`503`) | Retry later; for a site, check the address. | A resource in another account returns the same 404 as one that does not exist. ## Booking refusals `POST` and `PATCH /v1/appointments` return stable machine codes: - `409` means another time may work: `slot_unavailable`, `capacity_exhausted`, `service_exclusion`, `closed_day`, `outside_hours`, or `in_the_past`. `outside_hours` is a time outside the business's opening hours for that day; `in_the_past` is a start that has already passed. Neither is ever offered by `GET /v1/availability`. - `400` means changing only the time cannot help: `service_inactive`, `location_inactive`, `party_too_large`, `stay_too_short`, `stay_too_long`, `stay_dates_invalid`, or `no_services_requested`; and `invalid_parameter` for a quantity outside the service's range. - `400 invalid_parameter` on `PATCH` is also a move that changes the length of a booking of one timed service: see [Write a booking](/guides/mirror-appointment/#write-a-booking). - `409 booking_refused` is a refusal with no code of its own. Do not retry it unchanged: try another time, or ask the business. `location_inactive` is a booking at a location that is switched off: booking one of its services, and moving or restoring one of its bookings, are refused. Such a booking can still be renamed or cancelled. `PATCH /v1/appointments/{id}` and `POST /v1/appointments/{id}/restore` also answer `400 service_deleted` when a service of the booking has been deleted and, after the move or restore, that service's part of the booking would end in the future. Book it again with a service that exists. A booking of a deleted service can still be renamed, or moved within the past. Availability is advisory, so a `409` after a clean availability lookup is normal. Refresh the windows and let the customer pick again. ## Capacity refusals `PATCH /v1/resources/{id}` refuses a capacity the business cannot honour: - `409 capacity_below_committed` — bookings already on the calendar use more of the resource at once than the capacity you asked for. The message carries both numbers and the moment of peak use. The write is refused: a configuration change never cancels existing bookings. - `400 capacity_below_service_requirement` — a service linked to the resource can request more units than the new capacity would ever allow. Lower that service's quantity range, or unlink it, in Talki first. - `400 invalid_parameter` — the resource is in `units` mode, where capacity is derived from unit classes that this API does not write. The two creates refuse a link that cannot hold a booking: - `POST /v1/services` — `400 resource_too_small` when a booking of the service would not fit a resource in `resource_ids`, or the location's default resource when neither is sent, and `400 invalid_parameter` when both `initial_capacity` and `resource_ids` are sent, neither while the location has no default resource yet, or a resource of another location. - `POST /v1/resources` — `400 capacity_below_service_requirement` when a service in `service_ids` can request more units than `capacity`, and `400 invalid_parameter` for a service booked by the day or night, without a duration, or of another location. Nothing is created when a create is refused. ## Delete refusals `DELETE /v1/services/{id}` is refused while the service still has bookings to honour. `DELETE /v1/resources/{id}` is refused for the default resource and for a resource a service cannot be booked without. Nothing is cancelled or unlinked to let a delete through: - `409 service_has_upcoming_bookings` — the service has bookings still to come or under way, even if it is already inactive. Set `is_active: false` with `PATCH /v1/services/{id}` instead: the assistant stops offering it and its bookings are kept. Delete it once its last booking has ended or been cancelled. - `409 resource_is_default` — the resource is its location's default resource (`is_default`), which new services draw on. Its capacity can be changed; it cannot be deleted. - `409 resource_required_by_service` — a service draws on no other resource: an active one, or one booked by the day or night even while inactive. The message lists their IDs. Link a service with a duration to another resource first, for example with `POST /v1/resources` and `service_ids`, or switch it off. A service booked by the day or night needs a `units` resource, which this API does not create (`POST /v1/resources` with its ID is `400 invalid_parameter`): relink it in Talki. ## Retry rules Retry `429` and transient `5xx` with exponential backoff and jitter, honouring `Retry-After` when it is present. An appointment retry must carry the original `Idempotency-Key` and the identical request; the replay returns the original status and body with `Idempotency-Replayed: true`. A key keeps only a final answer: - **Replayed for 24 hours:** a success, and a refusal that holds until someone changes the state. That is a booking `409` such as `slot_unavailable`, `appointment_cancelled`, `appointment_not_cancelled`, a full list (`tag_limit_reached`, `caller_rule_limit_reached`), `livemode_required`, `conflict`, `404 resource_not_found`, a `400` that depends on the business (`service_inactive`, `location_inactive`, `service_deleted`, `party_too_large`), `413 payload_too_large`, and any code not listed below. Once its cause is gone, send the request with a new key. - **Released, so the same key runs the request again:** a `400` or `415` that asks for a different request (`invalid_parameter`, `invalid_cursor`, `export_too_large`, `unknown_scenario`, `no_services_requested`, `stay_too_short`, `stay_too_long`, `stay_dates_invalid`, `unsupported_file_type`), a `409` whose cause clears on its own (`request_conflict`, `document_not_ready`, `check_in_progress`), and any `412`, `429` or `5xx`. The corrected request can reuse the key. A key is released only if the request saved nothing. If it had already saved its changes, a refusal is replayed as it was sent, and a `412`, `429` or `5xx` is replayed as `500 internal_error`, saying the changes may have been saved. Read the resource to see the result before writing again. `409 request_in_progress` is never stored: retry it with the same key and the same request, or a booking may be made twice. Keys are scoped to the credential: two keys, or two installations, on the same account never collide, and a rolled API key starts a fresh scope. `400`, `401`, `402`, `403` and `404` are not worth retrying unchanged. The named `409` cases are handled above. A `412 precondition_failed` needs a fresh read first; see [concurrent edits](/guides/configure-assistant/#concurrent-edits-etag-and-if-match). ## Rate limits Limits are per account for API keys, and per installation for OAuth apps: | Operation | Limit | |---|---| | `GET /v1/account` | 60 requests/minute | | Public read endpoints | 600 requests/minute | | Public write endpoints | 120 requests/minute | `GET /v1/sandbox/scenarios` counts as a read. `POST /v1/sandbox/calls` allows 30 requests/minute and `POST /v1/sandbox/reset` 6. `POST /v1/services`, `POST /v1/resources`, `POST /v1/tags` and `POST /v1/caller_rules` count as writes, and together are also limited to 20 creations/minute. `GET /v1/calls/export` and `GET /v1/calls/{call_id}/export` count as reads, and together are also limited to 10 exports/minute. `POST /v1/documents`, `PUT` and `PATCH /v1/documents/{document_id}` and `POST /v1/documents/discover` count as writes, and together are also limited to 60 document writes/minute. `POST /v1/forwarding_check` counts as a write and places a real call, so it has quotas of its own: 3 checks an hour per location, and 30 an hour per OAuth app across all the accounts it is installed on and all their locations. Past either, it answers `429 check_quota_exceeded` with `Retry-After` and none of the `X-RateLimit-*` headers. The per-minute windows are one minute long. Every API key on an account draws from **one** budget per limit; an OAuth app gets one per installation, so each customer account it serves has its own. A `429 rate_limit_exceeded` carries `Retry-After` (seconds) and `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds). Successful responses carry none of them, so pace yourself rather than reading headers. ### Per-address limits Two more limits apply to the address a request arrives from, whatever the credential: | Limit | Counts | Beyond it | |---|---|---| | 20/minute | Failed authentications: a `401 invalid_credentials`, or `invalid_client` on `/oauth/token` and `/oauth/revoke` | **Every** request from the address, valid credentials included, gets `429 ip_rate_limited` until the minute ends | | 20,000/minute | All requests | `429 ip_rate_limited` | They apply to `/v1`, `/oauth/token` and `/oauth/revoke`. `429 ip_rate_limited` carries `Retry-After` and none of the `X-RateLimit-*` headers; on `/oauth/token` and `/oauth/revoke` its body has the [OAuth shape](/oauth/#token-errors). The address is the one that connected to Talki; an `X-Forwarded-For` you send is ignored. --- # Security and data handling Source: https://developers.talki.it/security/ What you are handling, what is retained, and what is expected of you. These obligations apply wherever you store the data, alongside Talki's DPA. ## What flows through this API Personal data of your customer's callers: phone numbers, names, appointment details, a generated summary of each call, and the assistant's one-line record of a question it could not answer. Treat every response as personal data under the GDPR. ## Retention - Call records and related caller data — including unresolved requests — are purged on a rolling **365-day** retention window. - Webhook delivery logs, including payloads and receiver response excerpts, are retained for **30 days**. - An account scheduled for deletion loses API access immediately and is erased after a **30-day** grace period unless the owner cancels the request. - Transcripts are deleted with their call, and when the account stops keeping transcripts. Call audio is not exposed through this API. ## Your obligations - Store credentials server-side, encrypted at rest. - Verify webhook signatures against the raw body and reject stale timestamps. - Store only the fields you use. - Honour deletion. Act on `contact.deleted`, `call.deleted`, `service.deleted` and `resource.deleted`. Feeds carry no tombstones, so also walk contacts, services, resources and unresolved requests in full periodically: a record gone from Talki must also be gone downstream. - Never copy production payloads into documentation, tickets or test fixtures. ## Reporting a vulnerability Email [info@talki.it](mailto:info@talki.it) with the subject **Security report**. No API keys, webhook secrets or caller data in the message. Include the affected hostname, a minimal reproduction, the impact, and a safe way to reach you. You will receive an acknowledgment and coordinated disclosure; there is no fixed response time during the private beta. --- # Changelog and lifecycle Source: https://developers.talki.it/changelog/ What changed, what is deprecated, and which versions are served. ## Version lifecycle | Dated version | Status | Notes | |---|---|---| | `2026-10-01` | Draft | Private beta: read, sync, availability, and narrowly scoped writes. | The first beta version is not frozen. A support and deprecation window will be published before general availability; none is promised yet. ## Changes ### 2026-09-25 - Added [OAuth apps](/oauth/) and [app events](/app-events/). - Added key expiry, key rolling, and the 49-character key format with a checksum. - Webhook secret rotation can overlap; during the overlap the signature header carries one `v1` per secret. - Added delivery-log error classes, plan pauses (`plan_paused`), and per-address rate limits (`ip_rate_limited`). - Added the `Talki-Version` header to every operation, and `unsupported_version`. ### 2026-09-16 - Added appointment, contact, and service retrieve endpoints and commit-ordered change feeds. - Added resumable call recovery through `GET /v1/calls?sync=true`; timestamp filters remain available for reporting windows. - Added `GET /v1/availability` for timed and day-range services. - Added appointment create, reschedule, and cancel operations with required idempotency keys and stable booking-conflict codes. - Added contact upsert with trusted-name provenance and external references. - Added the narrow service update operation. - Added the resource change feed and retrieve endpoint, with `resources:read`, and a two-field resource update behind `resources:write`. - Added the unresolved-request change feed and retrieve endpoint, with `unresolved:read`. - Removed the unused `webhooks:manage` scope. - `origin` on webhook events now carries the API credential and idempotency key of a write, so a consumer can recognise its own changes. - Delivery headers are `X-Talki-*`, and the user agent is `Talki-Webhooks/1.0`. - Removed the legacy flat fields from `call.ended` and `appointment.created`; resource payloads now use `data.object` only. ### 2026-09-15 - Published `GET /v1/account`, `GET /v1/calls`, and `GET /v1/calls/{call_id}`. - Added scoped API-key authentication, public request IDs, stable error envelopes, and per-credential rate limits. - Added canonical objects to call, appointment, and contact webhook events. - Added `account_id` and `origin` to the webhook envelope. --- # Support Source: https://developers.talki.it/support/ What to include when you report a problem. Write to [info@talki.it](mailto:info@talki.it) and include: - The **request id** from the error response, or the **event id** of the webhook delivery. - The `Talki-Version` the response was served at, or the webhook envelope's `api_version`. - Timestamps with the timezone, and what you expected instead. Never send an API key, revoked ones included, and never paste a caller's personal data into a support thread. The ids above are enough to find the record. For beta access: the Talki account's business name, your CRM or product name, the scopes you need, and whether the integration serves one account or many. To become a partner, see [Become a partner](/partners/#apply). ## Using these docs with an AI agent Point a coding assistant at the machine-readable copies rather than at scraped HTML: - [`/llms.txt`](/llms.txt) — an index of every page, following the [llmstxt.org](https://llmstxt.org) convention. - [`/llms-full.txt`](/llms-full.txt) — the whole prose documentation in one file. - [`/openapi.yaml`](/openapi.yaml) — the pinned OpenAPI document. This is the contract: where an agent's summary disagrees with it, the document wins. Every page is also served as markdown at its own path — `/webhooks/` is also [`/webhooks.md`](/webhooks.md).