TalkTool API
Sync contacts from your CRM and pull call records, transcripts, AI summaries and recordings into your own systems. The API is JSON over HTTPS, scoped to your organization, and versioned with a response header.
| Base URL | https://www.usetalktool.com/api/v1 |
| OpenAPI 3.1 | /api/v1/openapi.json |
| Current version | 2026-10-15 |
curl -H "Authorization: Bearer tt_live_…" https://www.usetalktool.com/api/v1/contacts.Authenticate with a Bearer key
Create an API key in Dashboard → Integrations. Keys look like tt_live_… and are shown once, so copy it when it is created. Send it in the Authorization header on every request.
curl https://www.usetalktool.com/api/v1/contacts \
-H "Authorization: Bearer tt_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"Scopes
Each key carries scopes. A request to an endpoint whose scope the key lacks returns 403 forbidden. New keys get the first three by default.
| Scope | Allows |
|---|---|
contacts:read | List and retrieve contacts |
contacts:write | Create, update and delete contacts |
calls:read | List calls, retrieve a call, download recordings |
webhooks:manage | Reserved for managing webhook subscriptions through the API. Today you manage endpoints in Dashboard → Integrations. |
GET /languages works with any valid key, whatever its scopes.
Rate limits and error format
Each key can make 300 requests per minute. Every successful response includes X-RateLimit-Limit and X-RateLimit-Remaining. Past the limit you get 429 rate_limited with a Retry-After header in seconds. Request bodies are capped at 64 KB (413).
Every response includes X-Request-Id and TalkTool-Api-Version. Quote the request id when you contact support. Errors always use the same envelope:
{
"error": {
"code": "invalid_request",
"message": "phone is not a valid phone number. Use E.164, e.g. +13105551234",
"request_id": "6b0f1c6e-3a52-4a0e-9f0e-1d2c3b4a5f60",
"details": { "field": "phone" }
}
}| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON, a bad field or a bad query parameter. details.field names the field when there is one. |
| 401 | unauthorized | Missing, malformed or revoked API key. |
| 403 | forbidden | The key does not have the scope this endpoint needs. |
| 404 | not_found | No such resource in your organization, or no recording for the call. |
| 409 | conflict | Duplicate phone or external_id (details.existing_id). Also used by request_in_progress. |
| 409 | request_in_progress | A request with the same Idempotency-Key is still running. Retry shortly. |
| 413 | payload_too_large | Body exceeds 64 KB. |
| 422 | idempotency_mismatch | The Idempotency-Key was already used with a different request. |
| 429 | rate_limited | More than 300 requests in a minute. |
| 502 | recording_unavailable | Recording service temporarily unavailable. Retry. |
| 503 | temporarily_unavailable | Access logging is temporarily unavailable; retry with the same Idempotency-Key. |
| 500 | internal_error | Something went wrong on our side. Retry; for writes reuse the same Idempotency-Key. |
Cursor pagination
List endpoints return { data, next_cursor }, newest first. Use limit (1–100, default 50) to size the page. While next_cursor is not null, pass it back as cursor to fetch the next page. Treat cursors as opaque. A null next_cursor means you have reached the end.
curl "https://www.usetalktool.com/api/v1/calls?limit=50" \
-H "Authorization: Bearer $TALKTOOL_API_KEY"
# {"data":[ ... ],"next_cursor":"NjZmMWMyYTliN2Q0ZTVhMWM4ZjAzYTEy"}
curl "https://www.usetalktool.com/api/v1/calls?limit=50&cursor=NjZmMWMyYTliN2Q0ZTVhMWM4ZjAzYTEy" \
-H "Authorization: Bearer $TALKTOOL_API_KEY"Safe retries with Idempotency-Key
Send an Idempotency-Key header (1–200 characters: letters, digits, - _ : .) on POST and PATCH requests. Repeating a request with the same key and body returns the original response with an Idempotent-Replayed: true header instead of running it twice. Reusing a key with a different body returns 422 idempotency_mismatch. If the first attempt failed, retrying under the same key runs it again.
curl -X POST https://www.usetalktool.com/api/v1/contacts \
-H "Authorization: Bearer $TALKTOOL_API_KEY" \
-H "Idempotency-Key: crm-sync-8841-v1" \
-H "Content-Type: application/json" \
-d '{"first_name":"Maria","phone":"+13105551234"}'Contacts
Contacts are the people you call. Use external_id to store your CRM record id, then look contacts up with GET /contacts?external_id=… instead of keeping a mapping table.
| Field | Notes |
|---|---|
| first_name | Required on create. Up to 100 characters. |
| last_name | Optional. Up to 100 characters. |
| phone | Required on create. E.164 (+13105551234) or a US-style national number ((310) 555-1234). Stored and returned as E.164. |
| Optional. Must be a valid address. | |
| language | A code like ES or EN_US, an English name like Spanish, a locale like es-US, or an ISO code like es. Stored and returned as the code. Defaults to EN_US on create. See Languages below. |
| external_id | Your CRM record id. Up to 200 characters. Unique per organization. |
| notes | Optional. Up to 5,000 characters. |
| type | general (default), allowed or blocked. |
List contacts
GET /contacts · scope contacts:read. Query parameters: limit, cursor, phone, external_id, search (name, email or phone), updated_since (ISO 8601).
curl "https://www.usetalktool.com/api/v1/contacts?external_id=crm_8841" \
-H "Authorization: Bearer $TALKTOOL_API_KEY"{
"data": [
{
"id": "66f1c2a9b7d4e5a1c8f03a12",
"first_name": "Maria",
"last_name": "Gonzalez",
"phone": "+13105551234",
"email": "maria@example.com",
"language": "ES_US",
"external_id": "crm_8841",
"notes": "Prefers morning calls",
"type": "general",
"created_at": "2026-10-01T15:04:05.000Z",
"updated_at": "2026-10-12T09:30:00.000Z"
}
],
"next_cursor": null
}Create a contact
POST /contacts · scope contacts:write. Returns 201 with the contact.
curl -X POST https://www.usetalktool.com/api/v1/contacts \
-H "Authorization: Bearer $TALKTOOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-8841-create" \
-d '{
"first_name": "Maria",
"last_name": "Gonzalez",
"phone": "(310) 555-1234",
"language": "Spanish",
"external_id": "crm_8841"
}'{
"data": {
"id": "66f1c2a9b7d4e5a1c8f03a12",
"first_name": "Maria",
"last_name": "Gonzalez",
"phone": "+13105551234",
"email": "maria@example.com",
"language": "ES_US",
"external_id": "crm_8841",
"notes": "Prefers morning calls",
"type": "general",
"created_at": "2026-10-01T15:04:05.000Z",
"updated_at": "2026-10-12T09:30:00.000Z"
}
}If a contact with the same phone (or the same external_id) already exists you get 409 conflict and the id of the existing contact:
{
"error": {
"code": "conflict",
"message": "A contact with this phone already exists",
"request_id": "6b0f1c6e-3a52-4a0e-9f0e-1d2c3b4a5f60",
"details": { "existing_id": "66f1c2a9b7d4e5a1c8f03a12" }
}
}Upsert
Add ?upsert=true to update the existing contact instead of failing. It matches on phone first, then external_id, applies the fields you sent, and returns 200 with the updated contact. If there is no match it creates one and returns 201. This is the easiest way to keep a CRM in sync: run it on every record change.
curl -X POST "https://www.usetalktool.com/api/v1/contacts?upsert=true" \
-H "Authorization: Bearer $TALKTOOL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"first_name":"Maria","phone":"+13105551234","language":"es-US"}'Retrieve, update and delete
GET /contacts/{id} (contacts:read) returns one contact. PATCH /contacts/{id} (contacts:write) accepts any subset of the create fields; send null to clear last_name, email, notes, external_id or language. Changing to a phone or external_id another contact already uses returns 409.
curl -X PATCH https://www.usetalktool.com/api/v1/contacts/66f1c2a9b7d4e5a1c8f03a12 \
-H "Authorization: Bearer $TALKTOOL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"maria.g@example.com","notes":"Call after 2pm"}'DELETE /contacts/{id} (contacts:write) soft-deletes the contact.
curl -X DELETE https://www.usetalktool.com/api/v1/contacts/66f1c2a9b7d4e5a1c8f03a12 \
-H "Authorization: Bearer $TALKTOOL_API_KEY"{ "data": { "id": "66f1c2a9b7d4e5a1c8f03a12", "deleted": true } }Calls, transcripts and recordings
| Endpoint | Scope | Description |
|---|---|---|
GET /calls | calls:read | List calls. Filters: since, until (ISO 8601), direction (inbound, outbound, conference), status, contact_id, phone, plus limit and cursor. |
GET /calls/{id} | calls:read | Retrieve one call. The id is the call SID (CA…). |
GET /calls/{id}/recording | calls:read | Redirects to the recording audio. |
curl "https://www.usetalktool.com/api/v1/calls?direction=inbound&since=2026-10-01T00:00:00Z&limit=25" \
-H "Authorization: Bearer $TALKTOOL_API_KEY"curl https://www.usetalktool.com/api/v1/calls/CA1f2e3d4c5b6a79880011223344556677 \
-H "Authorization: Bearer $TALKTOOL_API_KEY"{
"data": {
"id": "CA1f2e3d4c5b6a79880011223344556677",
"direction": "outbound",
"status": "completed",
"type": "call",
"from": "+13105550100",
"to": "+13105551234",
"number": "+13105550100",
"contact": {
"id": "66f1c2a9b7d4e5a1c8f03a12",
"first_name": "Maria",
"last_name": "Gonzalez",
"external_id": "crm_8841"
},
"started_at": "2026-10-14T18:00:02.000Z",
"answered_at": "2026-10-14T18:00:09.000Z",
"ended_at": "2026-10-14T18:04:41.000Z",
"duration_seconds": 279,
"languages": { "agent": "EN_US", "customer": "ES_US" },
"transcript_text": "Agent: Hello, this is Sam from Acme...",
"summary": {
"text": "Sam confirmed Maria's appointment for Friday.",
"key_points": ["Appointment confirmed for Friday 10am"],
"action_items": ["Send reminder text on Thursday"],
"sentiment": "positive"
},
"recording": {
"available": true,
"url": "https://www.usetalktool.com/api/v1/calls/CA1f2e3d4c5b6a79880011223344556677/recording"
},
"created_at": "2026-10-14T18:00:02.000Z",
"updated_at": "2026-10-14T18:05:10.000Z"
}
}direction is one of inbound, outbound, conference or unknown. languages are language codes (the same codes as the Languages endpoint). contact is null when the call is not linked to a contact. transcript_text and summary are null when none exists or when your organization has turned transcripts or summaries off.
Download a recording
GET /calls/{id}/recording responds with a 302 redirect to a signed audio URL that expires after about five minutes; the JSON body also contains { data: { url, expires_in } }. Follow the redirect with curl -L and send your key. Most recordings are MP3; some older ones are WAV. You get 404 when the call has no recording or recording is disabled for your organization. Do not store the signed URL, request a fresh one each time.
curl -L https://www.usetalktool.com/api/v1/calls/CA1f2e3d4c5b6a79880011223344556677/recording \
-H "Authorization: Bearer $TALKTOOL_API_KEY" \
-o call-recording.mp3Languages
Every language in TalkTool has one code: uppercase letters, with an underscore before a regional variant, for example EN_US, ES_US, ES or PT_BR. The code is what the API stores and returns in language on contacts and in languages on calls. Send the code whenever you can. For convenience the API also accepts the English name, the locale (es-US) and the two-letter ISO code (es), case-insensitively, and converts them to the code. Anything else returns 400 invalid_request with details.field set to language.
ES_US is Spanish for the United States and Mexico, which is what most North American teams want. ES is Spanish for Spain. Plain "Spanish", es-US, es-MX and es-419 all resolve to ES_US. The bare code es, es-ES and "Spanish (Spain)" resolve to ES. If your CRM only stores "Spanish", send ES_US explicitly rather than relying on the alias. Any other Spanish region code (es-CO, es-AR, es-PR…) also resolves to ES_US.GET /languages returns the full list with every spelling each code accepts, so you can build a mapping from your CRM's language field once.
{
"data": [
{
"code": "ES_US",
"label": "Spanish (US & MX)",
"locale": "es-US",
"also_accepted": ["Spanish (US & MX)", "es-US", "spanish", "es-MX", "es-419", "mexican spanish"]
},
{
"code": "ES",
"label": "Spanish (Spain)",
"locale": "es-ES",
"also_accepted": ["Spanish (Spain)", "es-ES", "castilian", "european spanish", "es"]
}
]
}All language codes
| Code | Language | Locale | Also accepted |
|---|---|---|---|
| AF | Afrikaans | af-ZA | afrikaans, af |
| AR | Arabic | ar-SA | arabic, ar |
| AR_EG | Arabic (Egyptian) | ar-EG | egyptian-arabic, arabic-egypt, egyptian arabic |
| BE | Belarusian | be-BY | belarusian, be |
| BN | Bengali | bn-BD | bengali, bn |
| BG | Bulgarian | bg-BG | bulgarian, bg |
| CA | Catalan | ca-ES | catalan, ca |
| ZH_CN | Chinese (Simplified) | zh-CN | mandarin, chinese, mandarin-chinese, simplified-chinese, zh-hans, zh-cn |
| ZH_TW | Chinese (Traditional) | zh-TW | chinese-traditional, traditional-chinese, taiwanese, zh-hant, zh-tw |
| HR | Croatian | hr-HR | croatian, hr |
| CS | Czech | cs-CZ | czech, cs |
| DA | Danish | da-DK | danish, da |
| NL | Dutch | nl-NL | dutch, nl |
| EN_AU | English (Australia) | en-AU | australian-english, english-australia |
| EN_IN | English (India) | en-IN | indian-english, english-india |
| EN_IE | English (Ireland) | en-IE | irish-english, english-ireland |
| EN_NZ | English (New Zealand) | en-NZ | new-zealand-english, english-new-zealand, kiwi-english |
| EN_ZA | English (South Africa) | en-ZA | south-african-english, english-south-africa |
| EN_GB | English (UK) | en-GB | british-english, english-uk, uk-english |
| EN_US | English (US) | en-US | english, american-english, english-us, us-english, en-ca, american english, us english, en |
| ET | Estonian | et-EE | estonian, et |
| FI | Finnish | fi-FI | finnish, fi |
| FR | French | fr-FR | french, fr-fr, fr |
| FR_CA | French (Canada) | fr-CA | canadian-french, french-canada, quebec-french, canadian french |
| GL | Galician | gl-ES | galician, gl |
| DE | German | de-DE | german, de-de, de |
| DE_CH | German (Swiss) | de-CH | swiss-german, german-swiss, swiss german |
| EL | Greek | el-GR | greek, el |
| HE | Hebrew | he-IL | hebrew, he |
| HI | Hindi | hi-IN | hindi, hi |
| HU | Hungarian | hu-HU | hungarian, hu |
| ID | Indonesian | id-ID | indonesian, id |
| IT | Italian | it-IT | italian, it |
| JA | Japanese | ja-JP | japanese, ja |
| KN | Kannada | kn-IN | kannada, kn |
| KO | Korean | ko-KR | korean, ko |
| LV | Latvian | lv-LV | latvian, lv |
| LT | Lithuanian | lt-LT | lithuanian, lt |
| MK | Macedonian | mk-MK | macedonian, mk |
| MS | Malay | ms-MY | malay, ms |
| MR | Marathi | mr-IN | marathi, mr |
| NB | Norwegian Bokmal | no-NO | norwegian, norwegian-bokmal, no |
| FA | Persian (Farsi) | fa-IR | persian, farsi, fa |
| PL | Polish | pl-PL | polish, pl |
| PT_BR | Portuguese (Brazil) | pt-BR | brazilian-portuguese, portuguese-brazil, brazilian portuguese |
| PT | Portuguese (Portugal) | pt-PT | portuguese, european-portuguese, pt-pt, pt |
| RO | Romanian | ro-RO | romanian, ro |
| RU | Russian | ru-RU | russian, ru |
| SR | Serbian | sr-RS | serbian, sr |
| SK | Slovak | sk-SK | slovak, sk |
| SL | Slovenian | sl-SI | slovenian, sl |
| ES | Spanish (Spain) | es-ES | spanish-spain, european-spanish, castilian, es-es, spain spanish, castilian spanish, european spanish, es |
| ES_US | Spanish (US & MX) | es-US | spanish, spanish-us, american-spanish, es-us, es-mx, es-419, mexican spanish, latin american spanish, us spanish |
| SV | Swedish | sv-SE | swedish, sv |
| TL | Tagalog / Filipino | tl-PH | tagalog, filipino, fil, tl |
| TA | Tamil | ta-IN | tamil, ta |
| TE | Telugu | te-IN | telugu, te |
| TH | Thai | th-TH | thai, th |
| TR | Turkish | tr-TR | turkish, tr |
| UK | Ukrainian | uk-UA | ukrainian, uk |
| UR | Urdu | ur-PK | urdu, ur |
| VI | Vietnamese | vi-VN | vietnamese, vi |
Webhooks
Webhooks push call events to your server as they happen, so you do not have to poll GET /calls. TalkTool sends an HTTPS POST with a signed JSON body to every endpoint subscribed to the event.
Create an endpoint
Open Dashboard → Integrations → Webhooks and choose Add endpoint. Enter an https:// URL, pick the events you want and save. TalkTool shows the signing secret once: store it in a secrets manager. You can send a test event, rotate the secret, edit the subscription or review recent deliveries from the same page. Only organization admins can change endpoints; every member can view them. An organization can have up to 20 endpoints.
Events
| Event | Fires when | Notes |
|---|---|---|
call.ringing | A call starts ringing. | Inbound calls fire when the call reaches your number; outbound calls when the far end starts ringing. |
call.answered | Someone answers the call. | answered_at is set. |
call.completed | A call that was answered ends. | Includes duration_seconds. It can arrive before call.recording.ready, and the transcript and summary may still be null. |
call.missed | A call ends and nobody answered. | Sent instead of call.completed for unanswered calls. |
call.recording.ready | The recording has been processed. | recording.available is true and recording.url works with GET /calls/{id}/recording. Only sent when recording is enabled for the organization. |
Events for one call are not guaranteed to arrive in order, because each is delivered and retried on its own. Use the timestamps inside data.call if order matters.
Delivery format
Every delivery is a JSON object with the event id, type, api_version, created_at and a data.call object that is identical to what GET /calls/{id} returns at the moment of the event.
POST /talktool/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: TalkTool-Webhooks/1.0
TalkTool-Event-Id: evt_5b0f1c6e-3a52-4a0e-9f0e-1d2c3b4a5f60
TalkTool-Event-Type: call.completed
TalkTool-Delivery-Attempt: 1
TalkTool-Signature: t=1760465082,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Header | Value |
|---|---|
TalkTool-Event-Id | The event id, the same as id in the body. Stable across retries. |
TalkTool-Event-Type | The event type, e.g. call.completed. |
TalkTool-Delivery-Attempt | 1 for the first attempt, up to 7. |
TalkTool-Signature | t=<unix seconds>,v1=<hex HMAC>. See below. |
User-Agent | TalkTool-Webhooks/1.0 |
{
"id": "evt_5b0f1c6e-3a52-4a0e-9f0e-1d2c3b4a5f60",
"type": "call.completed",
"api_version": "2026-10-15",
"created_at": "2026-10-14T18:04:42.000Z",
"data": {
"call": {
"id": "CA1f2e3d4c5b6a79880011223344556677",
"direction": "outbound",
"status": "completed",
"type": "call",
"from": "+13105550100",
"to": "+13105551234",
"number": "+13105550100",
"contact": {
"id": "66f1c2a9b7d4e5a1c8f03a12",
"first_name": "Maria",
"last_name": "Gonzalez",
"external_id": "crm_8841"
},
"started_at": "2026-10-14T18:00:02.000Z",
"answered_at": "2026-10-14T18:00:09.000Z",
"ended_at": "2026-10-14T18:04:41.000Z",
"duration_seconds": 279,
"languages": { "agent": "EN_US", "customer": "ES_US" },
"transcript_text": null,
"summary": null,
"recording": { "available": false, "url": null },
"created_at": "2026-10-14T18:00:02.000Z",
"updated_at": "2026-10-14T18:04:41.000Z"
}
}
}Verify the signature
Each endpoint has its own signing secret. TalkTool computes an HMAC-SHA256 over the timestamp, a dot and the exact raw request body, and sends it in TalkTool-Signature as t=<unix seconds>,v1=<hex>. To verify: read the raw body, recompute the HMAC with your secret, compare in constant time, and reject timestamps more than five minutes old to stop replays.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.TALKTOOL_WEBHOOK_SECRET; // whsec_...
function verify(rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split(/=(.*)/s).slice(0, 2))
);
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(parts.t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the RAW body. Parsing and re-serializing JSON changes the bytes.
app.post("/talktool/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("TalkTool-Signature") || "")) {
return res.status(400).send("bad signature");
}
res.sendStatus(200); // acknowledge first, work later
const event = JSON.parse(raw);
queue.add(event); // dedupe on event.id inside your worker
});import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["TALKTOOL_WEBHOOK_SECRET"].encode() # whsec_...
def verify(raw_body: bytes, header: str, tolerance_sec: int = 300) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
if abs(time.time() - int(timestamp)) > tolerance_sec:
return False
except (KeyError, ValueError):
return False
expected = hmac.new(
SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/talktool/webhooks")
def talktool_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(raw, request.headers.get("TalkTool-Signature", "")):
return "bad signature", 400
enqueue(request.get_json()) # dedupe on event["id"] in your worker
return "", 200Retries and failures
Any 2xx response counts as delivered. Anything else, a timeout or a connection error is a failure and is retried with the same event id on this schedule:
| Attempt | Sent |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after the first failure |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later |
| 7 | 24 hours later, then we stop |
If an endpoint fails 20 deliveries in a row, TalkTool disables it and shows the reason in Dashboard → Integrations. Fix the problem and switch it back on under Edit. Delivery history is kept for 30 days.
Handling events well
- Respond 2xx within 5 seconds. Acknowledge first, then do the real work asynchronously in a queue. Slow responses are treated as failures and retried.
- Be idempotent. Retries, and rare duplicate sends, reuse the same event
id. Store the ids you have processed and skip repeats. - Do not rely on order.
call.recording.readycan land before or aftercall.completed. - Fetch when in doubt.
GET /calls/{id}always returns the current state of a call. - Rotate on suspicion. If a secret leaks, rotate it from Integrations. The old secret stops working immediately.
API questions
Can I use the API without an organization?
Is there a sandbox?
How do I rotate a key?
Are webhooks available?
Which API version am I on?
Keep exploring
Guides for the rest of TalkTool