# Actions
Source: https://docs.thinnest.ai/agent/actions
Letting the agent do things, not only say them — safely.
An agent that can only talk is a search box with manners. Actions let it look up
an order, check stock, or start something in your own systems.
**The tools we wrote arrive on; anything that reaches outside your business
arrives off.** Knowledge search, lead capture and escalation are what people set
an agent up for, so making you go and find three switches would be a checklist
before the thing works at all. Web search, your own API and every connected
service start off, because those are reachable from a public chat by a stranger
who may be trying to talk the model into using them — and that is a decision
worth making one at a time.
## How the Actions page is laid out
The page has three tabs. The one you have open is kept in the address, so a
reload, or a link you send a colleague, lands on the same tab.
* **Built in** — the tools we wrote, in three cards: **What Agent can do**
(knowledge search, lead capture, handing over to a person, booking a call back,
web search), **Reaching the customer elsewhere**, and **Remembering customers
across channels**.
* **On calls** — **Hand calls to another agent**, the **Who-is-calling lookup** and
**After the call**.
* **Connect a service** — webhooks, a helpdesk, your own API, then calendar,
spreadsheet, CRM and records, and the directory of other services.
Under the title, one line shows what is on: how many tools are switched on, how
many services are connected, and how many agents a call can be handed to. A tool
you have switched on that is missing something it needs shows an amber **Not in
use yet**, with what is missing, rather than quietly doing nothing.
## Built-in tools
Switchable per agent. Off means the tool is **removed**, not that the model is
asked not to use it.
* **Knowledge search** — your own material, always searched first. **Two
switches**: one for chat and WhatsApp, one for calls. Both on.
* **Web search** — runs on the model provider's side, so it is only available on
models that offer it.
* **Capture lead** — see [Leads](/agent/leads).
* **Escalate** — hand the conversation to a person. See [Escalation](/agent/escalation).
* **Hand calls to another agent** — on calls only. Under this heading the agent
can pass a live call to another of your agents, say sales to support, on the
same line and in the same conversation. Each agent on the list is handed over
**warm** (with a short summary and the conversation so far) or **cold** (it
starts fresh and greets the caller). See
[Voice](/channels/voice#handing-a-call-on) for the rules.
* **End the call** — on calls only. The agent hangs up once the conversation is
genuinely finished. See [Voice](/channels/voice#ending-the-call).
* **Book a call back** — somebody asks to be called later and the agent books
it; your number rings them at that time. See
[Voice](/channels/voice#booking-a-call-back).
## Booking into your own calendar
Different from **Book a call back** above, and worth keeping straight: that has
your number ring the customer, this puts a real meeting in your diary at a time
you were genuinely free.
Connect Cal.com and the agent can read your availability, offer real times, and
book — with Cal.com sending the confirmation. Twelve switches, all starting off.
See [Calendar](/agent/calendar).
## Filling in your own spreadsheet
The agent takes a caller's details and writes them into a row of your spreadsheet
as it goes, and can look somebody up in the same table to answer from what you
already hold. Connect your own Grist account; two switches, both starting off.
See [Spreadsheet](/agent/spreadsheet).
**Your calendar and spreadsheet switches share one budget: eight on at once.**
An agent given too many tools gets worse at choosing between them, so we stop
you at eight rather than let it quietly degrade.
## Reaching the customer on a different channel
A caller needs a link sent to them. A customer on WhatsApp would rather be
phoned. These are the tools for that, and they have their own block on the
Actions page because they share something the others do not: each one reaches
the customer **outside** the conversation the agent is in.
**All four start off**, and three of them cost money each time. Turning one on is
not quite enough on its own — each also needs something only you can give it, and
the row says so when it is missing.
| Tool | You turn it on, and it needs | What it does |
| - | - | - |
| **Ring the customer now** | a number that can dial out, and the customer inside your calling hours | Rings that customer straight away on a number we are allowed to use, and tells them it is coming |
| **Send the customer a WhatsApp** | an approved **utility** template | Sends that template to the customer, blanks filled — never free text, and never while the agent is already on WhatsApp with them |
| **Send the customer an SMS** | your own [SMS provider](/channels/sms) connected, and at least one DLT template | Sends one of your DLT templates by SMS, blanks filled — never free text. Indian mobile numbers only; your provider bills you |
| **Reply to the customer by email** | a connected helpdesk that can send — Zendesk today | Replies on that customer's existing email thread. See [Helpdesk](/workspace/helpdesk) |
**"Ring the customer now" is not the same switch as "Book a call back".** One
is a customer asking to be rung later; the other is the agent ringing them
while it is already talking to them somewhere else. You can have either, both,
or neither.
**The agent never chooses the destination.** It cannot be told "email this to
another address" or "call this number instead" — there is no such thing to
say. The number or address is resolved from your records at the moment of
sending, from the same evidence that
[links a person across channels](/concepts#how-two-channels-become-one-person).
So the worst a visitor can talk the agent into is contacting the right person
at the wrong moment.
A few consequences worth expecting:
* **A WhatsApp template costs money**, the same as any other template send. The
agent may send **two an hour in one conversation**, so a customer who keeps
asking — or tries to make it loop — cannot run up your bill.
* **SMS is capped the same way**, two an hour in one conversation, and is billed
by your own provider, not from your wallet.
* **Emails are capped the same way**, at three an hour in one conversation.
Enough for the invoice and the corrected invoice, not enough to flood an inbox.
* **The agent will refuse in words** when it has no usable number or address —
it says so and carries on helping, rather than pretending it sent something.
### Which templates the agent may send
Two separate decisions, both yours:
1. **Whether this agent may send at all** — the switches above, per agent.
2. **Which templates any agent may send** — a tick on each template, once for the
whole workspace.
Only templates you have ticked **Agents may send this during a call or chat** are
shown to the agent. It picks the one that fits what the customer asked for, and
fills the blanks. It can't send anything you didn't tick, and it can't write its
own words.
| Tool | Where you tick | What can be ticked |
| - | - | - |
| **Send the customer a WhatsApp** | The template's card on [Templates](/whatsapp/templates#letting-an-agent-send-a-template) | Approved **Utility** templates whose link, if any, is fixed |
| **Send the customer an SMS** | The template in [SMS settings](/channels/sms) | Any of your DLT templates |
New templates start **unticked**. A tool with nothing ticked says so on its row,
and isn't given to the agent at all.
The agent is shown at most **ten** ticked templates, the first ten alphabetically,
so every turn stays quick and the same ones appear each time. With more than ten
ticked, untick the ones you don't want it to reach for. The SMS page tells you
when you are over.
Want a message that is always the same one, sent at a known moment? Use
[After the call](/agent/after-the-call). There you choose the exact template and
what fills every blank, and no tick is needed.
## A message after the call
Separate from the tools above: the agent can also send the caller a WhatsApp or
an SMS when a call ends — a thank-you with your booking link, or a
sorry-we-missed-you. It's set in the **After the call** card on the same page.
See [After the call](/agent/after-the-call).
## Call your own API
Point the agent at an HTTPS endpoint of yours, describe what it does and what it
takes, and the agent can call it mid-conversation. **[Full guide, including how
to write the endpoint safely](/agent/custom-api).**
* Credentials are encrypted at rest and are not readable from the dashboard.
* The URL is validated **and DNS-resolved** before we fetch it, so an endpoint
cannot be used to reach inside our network.
* Your header secret is never shown again after you save it.
## Connect a service (MCP)
21 services with addresses read from the official registry and grouped by what
they do, plus any address you want to paste. A further 1,434 services are listed
in the directory.
Sign-in happens in a popup and tokens are sealed and renewed automatically, so
nobody signs in twice.
**Asana, Xero and Shopify need a manual step.** Those vendors do not support
dynamic client registration, so connecting them currently means registering an
app on their side yourself. It works; it is not one-click, and we would rather
say so here than have you discover it mid-setup.
## Send what happens somewhere else
Signed outbound webhooks to any HTTPS endpoint. This is the lane that reaches
Google Sheets, Zoho and effectively any CRM through Zapier or Make today. Paste
an address — or an email address, if your helpdesk opens tickets that way — and
pick what it should be told about.
| Event | Fires when |
| - | - |
| A lead was captured | The agent collected a name, email or phone from somebody interested |
| A conversation was escalated | It handed a conversation to a person |
| A conversation was resolved | A teammate marked one done |
| **A call finished** | Any call placed or answered — with the outcome, how long it lasted, and which campaign it belonged to |
| **A call's results are ready** | After a call ends and has been read — its summary, the details it collected, the transcript and a recording link ([shape](/api-reference/calls/get-call)) |
| **A campaign finished** | Everybody on a broadcast or [calling campaign](/channels/voice-campaigns) has been reached or given up on |
| **A caller asked not to be called again** | During a call, somebody asked not to be called again and their number went on your do-not-call list |
| **Something went wrong on a live call** | While a call is going: the agent could not answer, your own model key failed, a reply was very slow, or the voice connection failed. Sent once per kind of problem per call |
A finished call carries whether it was answered, missed or refused, so a missed
call is something your own system can act on rather than something you find out
by opening ours. Want what was *said* as well? Tick **A call's results are
ready** — it arrives a few seconds later, once the summary and details are
written. See [Call leads from your CRM](/guides/leads-from-your-crm).
Every delivery is signed with a secret shown once when you add the endpoint, so
your receiver can prove the request came from us. A delivery that fails is
tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (six
attempts over about 8½ hours). An endpoint is switched off, and says so, only
after five events in a row that each failed every attempt; a failure that a
retry then delivers does not count. What failed can be sent again for 7 days. See
[Webhooks](/api-reference/webhooks) for the headers, the retries and how to
dedupe.
**Choose the events you want.** An endpoint with no events chosen receives
every event — the ones that exist today and any we add later. A receiver built
for leads should not be woken by a phone call it has never seen the shape of,
so tick only the events it is built for.
## Two limits worth knowing
**Eight tools per agent.** Every tool's schema is re-sent on every turn, and
models get measurably worse at choosing as the list grows. The cap is a quality
decision, not a licensing one.
**Every discovered tool arrives off.** Enabling is one deliberate act per tool.
A newly connected service does not silently gain the agent thirty new
capabilities.
## Name protection
Third-party tools are namespaced, so nothing you connect can register itself as your own knowledge search and quietly take over your own material.
# After the call
Source: https://docs.thinnest.ai/agent/after-the-call
Send the caller a WhatsApp or an SMS when a call ends — a thank-you, a booking link, a sorry-we-missed-you.
When a call ends, the agent can send the caller a message: a WhatsApp, an SMS, or
both. It's the same message every time, from a template you chose, with blanks
filled from the call. Set it in the **After the call** card on the agent's
**Actions** page.
Things it's good for:
* **Answered calls** — "Thanks for calling, . Book your visit here: …"
* **Missed calls** — "Sorry we missed you, . Reply here or call us back on …"
* **Every call** — your address and opening hours, after anyone rings.
## Set it up
* **WhatsApp:** an approved **Utility** template. See [Templates](/channels/whatsapp).
* **SMS:** a DLT template, with your SMS provider connected. See [SMS](/channels/sms).
**Send after**: *Calls somebody answered*, *Missed calls*, or *Every call*.
A call that failed before it rang never gets one.
For each blank, choose what goes in it:
| Choice | Fills with |
| - | - |
| Customer's first name | The caller's first name, or "there" if we don't know it |
| Business name | Your workspace's name |
| Agent's name | This agent's name |
| Date of the call | Like "7 Oct", in your workspace's timezone |
| Fixed text… | Whatever you type, like your booking link |
An SMS blank holds at most 30 characters, so fixed text there is kept short.
Choose **Don't send** for a channel to turn it off.
**A WhatsApp template with a blank in its link button can't be used here.** A
button like `yoursite.com/{{1}}` needs a value for the blank, and nothing can
fill it after a call. Those templates don't appear in the list, and saving one
through the API is refused. Use a template whose link is fixed, or put the
changing part in the message text.
## What keeps it polite
* **Once per call.** A call that ends twice in our records still sends once.
* **Never to anyone on your do-not-contact list**, including someone who asked
the agent on the call not to be contacted again.
* **At most once a day to the same number**, per channel. A caller who rings
three times in a morning gets one message, not three.
* **Only to a number we know is theirs**, the one they called from or that we
called. Never to a number someone said out loud on the call.
* **WhatsApp must be a Utility template.** Marketing templates are refused here.
They need marketing consent, and Meta pauses them quickly when people don't
want them.
## What it costs
* **WhatsApp** is charged like any other template you send. See
[WhatsApp](/channels/whatsapp).
* **SMS** goes through your own provider, which bills you directly. Nothing is
taken from your wallet here.
## Seeing what went out
Each WhatsApp appears in the customer's WhatsApp conversation, marked as sent by
the agent after the call. Each SMS appears as a note on the call's conversation.
A message that couldn't be sent (no WhatsApp on that number, a template no longer
approved, your SMS provider refused it) is simply not sent. The call itself is
unaffected.
## From the API
The same setting is `followUp` on [Update Post-call
Settings](/api-reference/post-call/update-post-call-settings). It needs a **full**
key, because it makes the agent message customers.
```bash theme={null}
curl -X PATCH https://app.thinnest.ai/api/v1/agents/ag_5cbb2cb9-41aa-4181-9e37-78914bd7b4f9/post-call \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"followUp": {
"when": "missed",
"whatsapp": {
"templateId": "3f1c2b7e-9a41-4f0b-8e2d-5c6a7b8d9e01",
"values": ["customer_name", "business_name"]
},
"sms": {
"templateId": "8d2e4f6a-1b3c-4d5e-9f70-a1b2c3d4e5f6",
"values": ["customer_name", "fixed:acme.example/book"]
}
}
}'
```
`values` fill the template's blanks in order, one each. Anything you leave out of
`followUp` keeps what is saved, and a channel set to `null` stops it:
```json theme={null}
{ "followUp": { "sms": null } }
```
# Behaviour
Source: https://docs.thinnest.ai/agent/behaviour
Tone, language, opening line, model and when to fetch a human.
## Where to set it
Most of this is on the agent's **Playground**, in four cards: **Language and
model**, **Voice**, **What Agent says** (the opening line and the instructions)
and **Reply length**. Nothing changes until you press **Save changes** at the top
right; after that it applies to the next message, including for real customers,
so the Playground is not a sandbox. **Test a call** and a chat panel sit beside
the cards (above them on a narrower screen), so you can try a change straight away.
## Name
Rename an agent from the pencil beside its name at the top of its sidebar, or
under **Settings → Identity**. Customers see the name in the chat.
## Business description
The facts the agent falls back on when a knowledge search finds nothing — what
you sell, to whom, and anything true of the business as a whole.
Keep it factual. This is grounding, not instructions: it is treated as
information to work from, never as commands.
## Instructions
Tone and subject matter. "Be warm but brief." "Never discuss competitors."
"Always mention that delivery is free over ₹2,000."
Tune these in the **Playground**, against a live reply. Guessing at wording and
shipping it is how you find out a week later that the agent has been ending
every message with an emoji.
Instructions can be up to **20,000 characters** — the counter under the box
shows how many you have used, and the same limit applies through the API.
Conversation steps are limited separately (up to 12 steps, each with an
80-character title and a 600-character detail) and don't count toward the
20,000.
Your instructions are treated as guidance on tone and subject matter, not as a
command channel. The platform's own safety rules always win — which is what
stops a customer talking the agent out of them too.
**Your agent never pretends to be a person.** Give it any name and persona you
like, but if someone asks whether they are talking to an AI, a bot or a real
person, it says it is an AI assistant — on every channel, in every language,
whatever your instructions say. On a call, it also answers truthfully whether
the call is being recorded.
## Opening line
The first thing a visitor reads in the widget. Write it in the business's own
voice; "How can I help you today?" is a wasted sentence.
## Reply language
Choose it when you create the agent, or any time on the **Playground** or
**Settings**. Either **\[Auto] Match the customer** — the agent replies in
whatever language the customer speaks in — or tick the languages it speaks: one,
or up to four.
Through the API, send `languages` on Create Agent or Update Agent: a list of
language names. `[]` is auto, one name is that language alone, and two to four are
the languages the agent expects, the first being the one it falls back to, for
example `["Telugu", "English"]`. More than four, a repeat, a name the console does
not offer, or `languages` together with `language` is refused with a `400`. Get
Agent returns `languages` too. Sending `language` (one language) still works and
replaces any list.
In chat (website chat, WhatsApp, Telegram, the Playground and test chat), **Match the
customer** reads the script of the customer's latest message. Write in Telugu, Hindi,
Tamil, Kannada, Malayalam, Bengali, Gujarati, Punjabi or Odia script and the agent
replies in that language, even if its opening line was in another. Once a conversation
is under way, a few words in another script do not move it: the agent switches when the
customer's first message is in that script, or when a later message is a real sentence
in it. Some cases are left to the agent's own judgement: a message written in English
letters (including Telugu or Hindi typed in English letters), a message too short to
tell, and one that is mostly English. A customer can always ask for a different
language and the agent follows.
Pick one language and the agent answers **only** in it. On a phone call that
also tells the speech recogniser which language to listen for. **Match the
customer** leaves the recogniser to work it out afresh for every sentence, and a
short or mixed reply can be heard as the wrong language — a name from another
language is enough. If your callers use one language, choose it.
Pick **several** — Hindi and English, say, or Tamil and English — and the agent
answers in whichever of them the customer is speaking, and in the **first** one
you ticked when they use any other. On a phone call the recogniser still works
out the language for itself, but only among yours: a reply it hears as some
other language is listened to again in the language the call is in. That
second listen adds about half a second, and only to those replies. Up to four
languages; the first one you tick is the one the agent falls back to.
37 languages, including all 22 of the Eighth Schedule, searchable, each in its
own script, so there is no "other languages" section to scroll past.
## Speaking speed
In the **Voice** card, under the voice itself: how fast the agent talks on
calls, on a slider from slower to faster. See
[Speaking speed](/channels/voice#speaking-speed).
## Answer style
Presets rather than a raw temperature number. Precise for policy and pricing
questions; warmer where the conversation is a sales one.
## Model
Every new agent starts on **Prana \[Voice]** — from scratch, from a pre-built agent, or
through the API (`prana-voice`) — and answers chat and calls with it until you choose
another. The choice is checked against your plan at request time, so downgrading a plan
cannot leave a more expensive model quietly running.
If your workspace brings only its own voice key, calls run on Prana, Prana \[Voice],
GPT-OSS 120B or another low-cost model; an agent set to anything else answers its calls
on GPT-OSS 120B. If it brings only its own language model key, your key answers on any model it
offers, and the call is billed like any other call, by the voice. See
[Bring your own keys](/api-reference/bring-your-own-keys).
A model marked **premium** in the picker puts every call on the premium per-minute rate,
even with a standard voice. `GET /api/v1/models` gives the same thing for each model:
`callTier` (`standard` or `premium`) and `callPricePerMinuteMicro`, the price of one call
minute in millionths of `currency` (the currency of the wallet your calls are charged
to). Both cover the model only, with a standard voice: a premium or studio voice can
raise a call's price, and one call is billed at one rate, never a stacked surcharge. If
your workspace brings its own keys, calls bill at the
[BYOK per-minute rate](/api-reference/bring-your-own-keys) whatever the model, so these
prices apply only to an agent you've set to run on our stack. The exception is a workspace
that brings only its own language model: there the voice plan still applies, set by the voice
alone, and the model you run never raises it. Typical response latency
per model isn't published.
**The agent does not go dark.** If a model provider fails mid-conversation, the
reply falls through to another model rather than to silence. A customer looking
at a dead chat window is a worse outcome than a slightly slower answer.
## Handing over to a person
Two triggers, each switchable — and [Escalation](/agent/escalation) covers what
happens when one fires, including when a custom action fails:
* **When the material does not cover the question.** The honest escalation.
* **When the customer asks for a person**, or is clearly upset.
Turning one off removes the capability entirely rather than asking the model to
refrain — the difference matters, because a model that has a tool will
eventually use it.
A business with nobody watching the inbox should turn escalation off. An
escalation nobody answers is worse than a straight "I don't know", because the
customer waits.
# Calendar
Source: https://docs.thinnest.ai/agent/calendar
Your agent reads your real availability and books meetings into your Cal.com.
Someone asks when you are free. The agent reads your actual calendar, offers two
or three real times, takes their name and email, and books it. Cal.com sends the
confirmation itself.
It works on every channel — website chat, WhatsApp, Telegram and phone calls.
This is not the same as **Book a call back**. That has your number ring the
customer at a time they chose. This puts a meeting in your diary at a time you
were actually free. You can have both on; they do not know about each other.
## Connecting your calendar
You need a Cal.com account. Go to **Actions**, find **Calendar**, and click
**Cal.com**.
In Cal.com, open **Settings → Developer → API keys** and create one.
It must be a **live** key — one beginning `cal_live_`. Cal.com also issues
test keys, which begin `cal_` and work perfectly against a separate test
world, so a booking made with one never reaches the calendar you are looking
at. We refuse those rather than let you find out from an empty diary.
Leave the expiry off unless you have a reason to set one — a key that
expires will stop your agent booking, and the first sign is usually a
customer who could not get a slot.
We call Cal.com once to check the key works and to read back which account
it belongs to. If it does not work you are told immediately, rather than
finding out from a missed meeting.
Everything starts **off**. Connecting your calendar on its own gives the
agent nothing.
## Choosing what it can do
Twelve things, each its own switch. **An agent can have eight on at once** —
past that it gets worse at picking the right one, so we stop you rather than let
it quietly degrade.
That eight is shared with anything else the agent connects, so a
[spreadsheet](/agent/spreadsheet) switch and a calendar switch come out of the
same budget.
### What a customer can ask for
| Switch | What happens |
| - | - |
| **See what can be booked** | Reads your meeting types — a 15-minute intro, a demo — so the agent offers real ones instead of inventing them |
| **Check when you are free** | Reads your genuinely open slots and offers a couple of them |
| **Hold a slot while you talk** | Reserves the time for a few minutes so nobody else takes it while the customer is still spelling their email |
| **Book the meeting** | Puts a real booking in your Cal.com. Cal.com emails the confirmation |
| **Find their existing booking** | Looks up what this customer already has, by their email |
| **Move a booking** | Reschedules to a time they picked |
| **Cancel a booking** | Cancels it, with the reason they gave |
Most people want the first four. Add **Find** before **Move** or **Cancel** —
those two need it, because it is where the agent gets the booking to act on.
### Acting as you
Five more decide something on your behalf: accepting or declining a booking that
is waiting on you, marking a no-show, and reading your working hours or time off.
**Three of these need Find their existing booking turned on as well** —
accepting, declining and marking a no-show all act on a specific booking, and
Find is the only thing that can identify one.
Marking a no-show needs one more thing: the person's email address. Cal.com
identifies an attendee by email and by nothing else, so without one the agent
cannot say who failed to turn up.
**Leave these off unless the agent only ever talks to your own staff.** A phone
call has no login, so the agent cannot tell you from a caller who has worked out
what to say. It is asked to refuse strangers, but the reliable protection is the
switch being off.
## What your customer experiences
> **Them:** Can I get a demo this week?
> **Agent:** Let me check the diary. I have Tuesday at 11, or Thursday at 3.
> **Them:** Thursday.
> **Agent:** Can I take your name and email?
Then a Cal.com confirmation lands in their inbox, exactly as if they had used
your booking page.
The agent will **not** invent a time. If nothing is free it says so and offers to
look at another week, rather than promising a slot you do not have.
## Things worth knowing
**It always asks for an email.** Cal.com will accept a booking without one; we
will not. A booking with no email gets no confirmation and cannot be found again
if the customer rings back to change it.
**Testing in the Playground books for real.** The Playground runs your actual
agent, not a simulation — so if you ask it to book you a demo, a real meeting
appears in your Cal.com and a real confirmation email goes out. Cancel it the
same way, or leave these switches off until you are ready.
**Times are read in your calendar's timezone**, unless the customer says where
they are — then it converts.
**Hidden meeting types are never offered.** If you took something off your public
booking page, the agent will not offer it either.
**Disconnecting leaves your bookings alone.** They are your customers'
appointments, not ours to cancel.
## If it stops working
The card says **Needs attention** and shows what Cal.com said.
Almost always the key expired or was revoked. Create a new one in Cal.com and use
**Replace key** — your switches are remembered, so you do not have to set them up
again.
## Deliberately not included
The agent cannot **create, change or delete your meeting types**. It can book
into them, and that is all. Nobody phoning your business has a good reason to
reshape what your business offers, and the safest way to guarantee that is not to
give the agent the ability at all.
# Call your own API
Source: https://docs.thinnest.ai/agent/custom-api
Give the agent one narrow endpoint of yours, and every rule that keeps it safe.
A custom action is an HTTPS endpoint of yours that the agent can call
mid-conversation — an order lookup, a stock check, a booking.
It is the most powerful thing you can give an agent and the one with the
sharpest edges, because it is reachable from a public chat by a stranger who may
be trying to talk the model into using it.
## Defining one
| Field | What it is |
| - | - |
| **Name** | 3–40 characters: lowercase letters, numbers and underscores, starting with a letter |
| **Description** | What the model reads to decide whether to call this at all |
| **Method** | `GET` or `POST` |
| **URL** | Must be `https://`. May contain `{{placeholders}}` |
| **Parameters** | Each with a name and a description |
| **Body template** | For `POST`. JSON, with `{{placeholders}}` |
| **Header secret** | Sent on every call so your endpoint can verify it is us |
### The description is the whole thing
The model has nothing else to go on. It decides whether to call your action, and
with what, entirely from what you write here.
**Weak:**
```
Gets order info
```
**Strong:**
```
Look up a customer's order. Requires BOTH the order number and the email
address on the order — if the customer has given only one, ask for the other
before calling this. Returns found: false when the pair does not match: say you
could not find it and ask them to check both, never say the order does not
exist.
```
The second one tells the model when to call it, when *not* to, what it needs
first, and how to phrase the failure. Every sentence prevents a specific bad
answer.
Write the description, then read it back as though you knew nothing about your
own systems. Anything you had to already know is a sentence that belongs in it.
### Parameters need descriptions too
Every parameter must say what it is, or the model will invent a value. `order_id`
with no description gets filled with something plausible-looking and wrong.
## Placeholders
Use `{{parameter_name}}` in the URL or the body:
```
https://shop.example.com/api/orders/{{order_number}}
```
```json theme={null}
{ "orderNumber": "{{order_number}}", "email": "{{email}}" }
```
Two rules the editor enforces before you can save:
* **Every placeholder must have a parameter behind it.** A `{{order_id}}` with
no matching parameter would reach your API as the literal text `{{order_id}}`
— failing in a way that looks like your bug rather than ours.
* **The body must still be valid JSON once the blanks are filled.**
Values are escaped when substituted, so a customer cannot break out of a string
and reshape your request.
## Values the platform fills
Some placeholders are filled by us from the conversation, not by the model, so
a caller cannot talk the agent into sending a different one. They have a dot in
the name, and you do not declare them as parameters.
| Placeholder | Value |
| - | - |
| `{{call.id}}` | The call's id — the same id `GET /api/v1/calls` lists. Empty outside a call |
| `{{call.from}}` | The number the call came from, E.164. Empty outside a call |
| `{{call.to}}` | The number that was dialled, E.164. Empty outside a call |
| `{{call.direction}}` | `inbound` or `outbound`. Empty outside a call |
| `{{conversation.id}}` | The conversation (`conv_…`) |
| `{{contact.id}}` | The contact (`cust_…`) |
| `{{contact.phone}}` | The contact's phone number, E.164, when known |
| `{{agent.id}}` | The agent (`ag_…`) |
| `{{workspace.id}}` | The workspace (`org_…`) — useful when you run customer workspaces |
| `{{test.chat}}` | `true` during a test chat, empty otherwise |
```
https://crm.example.com/calls/{{call.id}}/notes?caller={{call.from}}
```
* They work in the path, the query, the body and in header values. The host
must be written out in full — a placeholder before the path is refused.
* Header values take only these, never a model parameter.
* An unknown one, such as `{{call.nope}}`, is refused when you save.
* On text chat (WhatsApp chat and web chat) the `call.*` values are empty; the rest still fill.
Voice calls on every surface, WhatsApp calls and web voice included, fill them.
A test from the editor or `POST …/actions/{id}/test` has no conversation, so
only `{{agent.id}}` and `{{workspace.id}}` fill.
Every call also carries `X-Call-Id`, `X-Conversation-Id`, `X-Agent-Id` and
`X-Workspace-Id` headers (each only when known), so you get them without
editing a template. A header you set yourself with the same name wins, whatever its capitalisation
(`x-call-id` replaces `X-Call-Id`).
### During a test chat
A test chat (`POST /api/v1/agents/{id}/test-chat`, or `test_chat` over MCP) runs
your actions for real. So your endpoint can tell, every action request in a test
chat carries an `X-Test-Chat: true` header and `{{test.chat}}` fills as `true`.
There is no call, so `{{call.id}}` and `{{call.from}}` are empty and `X-Call-Id`
is not sent. Check for the header and answer as normal without writing anything
— no order placed, no record created — so testing never changes your real data.
Outside a test chat the header is never sent.
Only the API's test chat is marked. A chat in the console's **Playground**, a
live conversation and a test of a single action from the editor are not, and the
tools of an MCP server you have connected to the agent are not marked either.
## Reserved names
These clash with the agent's built-in tools and are refused: `search_knowledge`,
`capture_lead`, `escalate_to_human`, `send_media`, `send_link`.
Anything else you connect is namespaced, so no third-party tool can impersonate
one of the agent's own.
## Security
Once when you save it — so you hear about an `http://` address while looking
at the form — and again at call time.
Validated **and DNS-resolved**, so an address cannot be used to reach a
private network from inside ours.
Stored encrypted, not readable from the dashboard, and never displayed again
after you save it.
Custom actions are the only tool that spends *your* money, so the cap is a
per-conversation one. A conversation is the unit a visitor controls, so it is
the unit that gets bounded — a workspace-wide cap would be one a single
visitor could exhaust for everybody.
Thirty is far above any honest conversation. Checking an order, a booking and
a balance twice over is under ten.
## Writing the endpoint
Four rules, in order of how much trouble they save.
Before parsing anything.
```js theme={null}
if (req.get("X-Agent-Secret") !== process.env.AGENT_SECRET) {
return res.status(401).json({ error: "unauthorised" });
}
```
An order number alone is guessable — they are usually sequential. Require the
number **and** something only the real customer knows, like the email on the
order.
```js theme={null}
// Same response either way. A different one for each is an oracle telling a
// stranger which order numbers are real.
if (!order) return res.json({ found: false });
```
Everything you return may end up spoken to the customer. Return the status
and the tracking link, not the whole order record with the customer's address
and payment details in it.
```js theme={null}
return res.json({
found: true,
status: order.status,
carrier: order.carrier ?? null,
trackingUrl: order.trackingUrl ?? null,
});
```
**Never write an action that changes something irreversible.** Cancelling an
order, issuing a refund, deleting a booking — these should not be one
conversation away from a stranger who is good at persuasion. Read actions
first; for writes, [escalate to a person](/agent/escalation) instead.
## When it fails
A timeout, a 500, or an unreachable host does not crash the conversation. The
agent is told the lookup failed and answers accordingly — normally by
[handing over to a person](/agent/escalation) rather than guessing.
Which is the argument for having escalation switched on wherever you have
actions: the failure mode of "I could not check that" is much better when there
is somebody to pass it to.
## Sending leads somewhere
The other direction: signed outbound webhooks to any HTTPS endpoint, fired when
something happens rather than when the model asks. That is the lane that reaches
Google Sheets, Zoho and effectively any CRM through Zapier or Make.
## A full worked example
[Answer "where is my order?"](/guides/order-status) builds one end to end — the
endpoint, the action config, the description text, and the four cases to test.
# Escalation
Source: https://docs.thinnest.ai/agent/escalation
How the agent hands a conversation to a person — including when an action fails.
The counterpart to knowledge search: one finds an answer, this one admits there
isn't one.
Without it the agent's only options are to guess or to apologise into the void,
and you never learn it happened.
## The two triggers
Each switchable, per agent.
| Trigger | Fires when |
| - | - |
| **When the material does not cover it** | A knowledge search found nothing and guessing would be worse than waiting |
| **When the customer asks** | They ask for a person, or are clearly upset |
Turning one off **removes the capability**, rather than asking the model to
refrain. A model that has a tool will eventually use it; a model without one
cannot.
**A business with nobody watching the inbox should turn escalation off
entirely.** An escalation nobody answers is worse than a straight "I don't
know", because the customer waits for something that is not coming.
## What happens when it fires
Status changes to **escalated** and it surfaces in the inbox.
One sentence on what the customer wants and what the agent could not find —
written for a colleague, not for a log.
Outbound webhooks are delivered, and if you have connected one, a
[helpdesk ticket](/workspace/helpdesk) opens in Zendesk or Salesforce with
the transcript.
The agent says a person will follow up. It does not go quiet.
## On a phone call: putting the caller through
With **Call transfer** chosen in **Hand over to a person**, a caller on the phone
is also put through to the number you give. Choose how:
* **Cold** (the default) — the phone rings and the caller is connected as soon as
someone answers.
* **Warm** — whoever answers first hears a short spoken summary of who is calling
and what they need, then the caller is connected. The caller hears the phone
ringing meanwhile. Add **Press 1 to accept** and the caller is connected only
once that person presses 1, so a voicemail never takes the call; choose
**Connect after the summary** to connect as soon as it has played.
Press 1 to accept is not available on Telnyx numbers yet. The details are on
[Voice](/channels/voice#handing-a-call-on).
## Escalation from a failed action
The case worth planning for.
When a [custom action](/agent/custom-api) times out, returns a 500, or cannot be
reached, the agent is told the lookup failed. It does **not** invent an answer
and it does not pretend the tool succeeded.
What it does next depends on whether escalation is available:
| Escalation | What the customer gets |
| - | - |
| **On** | "I can't reach our order system right now — I've passed this to the team and someone will come back to you." |
| **Off** | "I can't check that at the moment. Please try again shortly." |
**Switch escalation on wherever you have custom actions.** The failure mode of
"I could not check that" is far better when there is somebody to pass it to —
and your API will be down at some point, whatever your uptime target says.
The same applies when a customer hits the [per-conversation action
limit](/agent/custom-api#security): the agent stops calling and hands over
rather than continuing without the information.
## Once a person takes over
The agent **stops replying to that thread entirely**. Two voices answering the
same customer, contradicting each other, is worse than a slower answer.
When a teammate replies, the agent later reads that reply as its own prior turn
— so if the conversation returns to it, it does not re-answer something a person
just handled.
## It escalates once
The agent is told to call it once per conversation. Calling it again does not
make anybody arrive sooner, and a thread that escalates four times is four
notifications about one problem.
## If flagging fails
If the escalation cannot be recorded for some reason, the agent still tells the
customer a person will follow up — because the conversation is in the inbox
either way, and promising nothing is worse than promising something you will
honour by reading it.
## Related
Where escalations land, and how taking over works.
Escalations as Zendesk or Salesforce tickets, with replies routed back.
The actions whose failures escalation catches.
"Needed a person" over time — not a failure metric, a working one.
# Find leads
Source: https://docs.thinnest.ai/agent/find-leads
Describe the businesses you want. Your agent finds them and brings back their phone numbers and emails.
Find leads is the first tab on **Leads**. You describe the kind of business you
want to reach — "real-estate developers in Pune", "dental clinics in Bengaluru" —
and an agent finds them and brings back their phone numbers and email addresses. The people who came to *your*
agents are on the second tab, [Inbound leads](/agent/leads).
## Find your first leads
Open **Leads**. Type who you want to reach, or start from one of the
examples. Add a place: "in Pune" finds far better than "developers".
A broad request ("builders") gets one question with a few answers to tick —
which city, what kind. Tick, add your own, or skip it.
The agent shows what it will look for. Pick 25, 50, 100, 250 or 500 leads and
**Start search**. Before it starts you see the most the search can cost.
The chat on the left shows each search the agent runs; the table on the right
fills in as leads are found. You can leave the page — the search carries on —
and **Stop** it whenever you like.
## Ask for more, or something different
After a search, just talk to the agent in the box under the chat.
* **"2 more"**, **"20 more please"**, **"keep going"** — it searches again for
different businesses and adds them to the same table, up to 500 leads in a
search. You only pay for the new leads.
* **"Only the ones with an email"**, **"now in Mumbai"**, **"also include
orthodontists"** — it changes the search and starts the next one in the same chat.
* **"Interior designers in Hyderabad"** — a different search, in the same chat.
* Say how many in the same message — **"10 dentists in Pune"** — and it starts
straight away.
Your earlier searches stay in the chat, each with a **View leads** button that
brings its table back. **Back to new search** (the arrow beside **Leads**) starts a
fresh chat.
## What you get for each lead
A row in the table: the business, its website, phone, email, location and what
kind of business it is.
* **Only what the website itself prints.** A number or email is included only if
it is on the business's own page. The link beside the website opens that exact
page, so you can check any of them.
* **The business's public details, not a person's.** Expect a main line, a sales
number, a toll-free number or a general address such as `info@` — not the direct
number of a named person.
* **More than one number or email?** The first is shown, with **+2** or similar;
hover it to see the rest.
## What it costs
* **₹1 for each lead** (one cent if your workspace is billed in dollars), taken
from your balance. No GST is added.
* **The first 50 leads are free** — in your workspace's first month of Find leads,
counted from your first search. They are counted per workspace and per person.
* **You only pay for a lead:** a business with a phone number or email on its own
website that you don't already have. Sites that were not a match, sites with no
contact details, businesses already in your contacts and businesses an earlier
search found are never charged.
* **When the balance runs out,** the search pauses. Add money and press
**Continue**. A workspace with no balance — the free trial — is asked to choose a
plan once its free leads are used.
The price is also on each plan card in **Settings → Billing**.
## Do something with them
Tick the leads you want, then:
* **Save to Contacts** — they become contacts in your workspace, and go into a
[group](/agent/leads#groups) you choose or create.
* **Send WhatsApp** — saves them to a group and opens a
[broadcast](/whatsapp/broadcasts) already aimed at it.
* **Call with an Agent** — saves them to a group and opens a
[calling campaign](/channels/voice-campaigns) already aimed at it.
Saving asks you to confirm that **contacting these businesses is your
responsibility**. These businesses have not asked to hear from you, so read the
next section before you send anything.
## Before you contact them
* **Calls.** Calling businesses that have not asked to be called is covered by
India's telemarketing rules, including Do Not Disturb. Your calling campaign
still respects calling hours and your do-not-contact list; the rules themselves
are yours to follow.
* **WhatsApp.** WhatsApp expects people to have opted in. A broadcast to found
businesses is allowed, but complaints and blocks lower your WhatsApp account's
quality rating and can get it limited. Send only what they would expect from a
business like yours.
* **Anyone who says stop is never contacted again,** on any campaign, whichever
way they were found.
## Save a search, run it again
On a finished search, **Save search** gives it a name and keeps it at the top of
the start screen under **Saved searches**. **History** lists your recent
searches. **Run again** repeats a search: businesses you already have come back as
duplicates and are not charged, so a rerun pays only for new ones.
## Limits
* One search at a time in a workspace, and 30 a day.
* Up to 500 leads a search. A search ends early if it is finding nothing, and says
so.
* Businesses in India.
* Leads stay in your workspace. They are not in the Contacts download or in the
contacts API.
## Not available yet
* Workspaces of a white-label client, and customers of a developer: their usage is
billed through somebody else, and this is not part of that yet.
* Finding leads from the API or the MCP server.
* Repeating a saved search automatically every week.
# Knowledge
Source: https://docs.thinnest.ai/agent/knowledge
What the agent answers from, and what it will refuse to guess at.
The agent answers from material you give it. When a search finds nothing, it
says it does not know rather than inventing an answer — that behaviour is the
point, and it is what makes the answers worth trusting.
## Ways to add material
Paste a URL. Good for a returns policy or a pricing page you want indexed on
its own.
Paste your homepage and choose a depth of 1 to 3 levels. You see the list of
pages found and **pick which ones to index before anything is ingested** —
a crawl is a proposal, not a commitment.
Reaches pages a crawl cannot: unlinked pages, pages only reachable from
search, anything not in your navigation. If a crawl missed something
important, this is usually why.
For the policies that live in someone's head rather than on a page. Often
the highest-value thing you will add.
`.txt`, `.md`, `.csv` and PDFs with a text layer.
CSVs are chunked **row by row**, so a price never gets separated from its
product.
## What is not supported
**Scanned documents do not work.** There is no OCR. A PDF that is really a
photograph of a page will ingest and contribute nothing, which is worse than
failing loudly — so check that you can select the text in your PDF before
uploading it.
Also unsupported: images, video, and Word or Excel files. Export to PDF, CSV
or text first.
## Getting good answers
**Fewer, better pages.** Four hundred pages of navigation chrome make retrieval
worse, not better. Index the pages that answer questions.
**Write the answer down somewhere.** If three customers a week ask something
your site never states, no amount of crawling will find it. Paste it in as text.
**Check the gaps report.** [Analytics](/workspace/analytics) lists the questions
the agent searched for and found nothing on. Each row is something worth adding
— it is the most directly useful page in the product.
## Knowledge and language
You do not need a knowledge base per language. Keep your material in one
language; the agent translates as it answers, in whichever of the 37 supported
languages the customer is using.
# Leads and contacts
Source: https://docs.thinnest.ai/agent/leads
Ending the conversation with somebody to call, not just a closed ticket.
A support bot answers a question and the visitor leaves. This one answers the
question **and** leaves you a name and a reason to call.
**Leads** has two tabs. **Inbound leads** is this page: people who came to your
agents. **Find leads** goes the other way — your agent finds
businesses you want to reach. See [Find leads](/agent/find-leads).
## How a lead gets captured
When a visitor shows real interest, the agent asks for their details in the flow
of the conversation and records:
* name
* email
* phone
* **a one-line note on what they actually wanted**
That last field is the one that matters. "Priya, kitchen quote, 12ft run,
budget around ₹4L" is a callback worth making. A bare phone number is not.
## Partial details are still a lead
A phone number on its own is recorded. Later turns add to the same record rather
than starting a new one, so a customer who gives their name first and their
number three messages later ends up as one contact.
## Switching it off
Per agent. Off removes the capability entirely — the agent has no way to ask
rather than being asked not to.
Worth turning off for a pure support agent, where asking for contact details
reads as a sales tactic and annoys people.
## Contacts
Everyone who has spoken to an agent, **leads first**, with what they asked for.
One click goes from a contact to the conversation that produced them, so
whoever calls back can read what was actually said.
## Groups
A group is a named list of contacts you put together yourself — "Wholesale
buyers", "Pune walk-ins" — so you can send a broadcast or run a voice campaign to
exactly those people.
On **Contacts**, choose **New group** and name it. Names are unique in your
workspace, whatever the capitals.
Tick contacts in the list (the box at the top ticks everyone on the page),
then **Add to group** and pick the group. Somebody already in it is left as
they are. A contact can be in any number of groups.
Each group is a chip above the list with how many people are in it. Open one
to see only its members; tick people there and **Remove from** the group to
take them out.
The **⋯** beside an open group renames or deletes it. Deleting a group never
deletes its contacts.
A group decides *who you are aiming at*, never *who agreed*. Consent still
applies to everybody in it — see
[narrowing by group](/whatsapp/broadcasts#narrowing-by-tag-or-group).
## Contact language
Each contact can carry a language. Where a WhatsApp template exists in several
languages, the contact's language decides which one they receive.
A contact with no language set gets exactly what they would have got before —
your default. Setting the field is an improvement, never a prerequisite.
## Consent
Contacts carry a marketing consent state, and it is enforced in the database
rather than in the interface. A campaign audience is computed at send time from
consent — never from a list somebody exported last week.
Businesses you found with [Find leads](/agent/find-leads) have not opted in, and
saving them asks you to confirm that contacting them is your responsibility. They
are the one exception to the opt-in rule for WhatsApp broadcasts; calls and
everything else here still apply, and anyone who says stop is never contacted again.
Somebody who opts out stops receiving **marketing**. Utility and authentication
messages still reach them, and must: an order confirmation is not something
anybody opted out of.
# Spreadsheet
Source: https://docs.thinnest.ai/agent/spreadsheet
Your agent writes what it learns into your spreadsheet while it is still talking, and can look things up in it.
Someone rings and gives their name, their number and what they want. The agent
writes each answer into a row of your spreadsheet as it arrives, so a call that
ends after two questions still leaves those two answers behind. It can also look
somebody up — an order, a booking, a customer record — and answer from what you
already hold.
It works on every channel: website chat, WhatsApp, Telegram and phone calls.
We use **Grist**, a spreadsheet you can sign up for free at
[getgrist.com](https://www.getgrist.com). You connect your own account; we never
put your data in ours.
This is not the same as **Lead capture**, which stores names and numbers in your
inbox here. This puts them in **your** spreadsheet, in columns you chose, where
your team already works. You can have both on, and when you do the agent records
the details in both places rather than choosing between them.
## Connecting your spreadsheet
Go to **Actions**, find **Spreadsheet**, and click **Grist**.
In Grist, click your profile picture → **Account settings** → **Developer**,
and create a key in the **API keys** section.
It does not expire until you remove it, so unlike a calendar key there is
nothing here that quietly stops working next year.
We call Grist once to check the key really belongs to someone. If it does
not work you are told immediately.
Open the document in Grist, go to **Settings** and find the **API** section.
Copy the **Document ID** from there.
The id in your browser's address bar is a **shorter** one, and the API
cannot use it. If you paste that, we tell you so rather than letting you
discover it as "document not found".
We show you every table in the document with how many fields each has. Pick
one. We then read that table's real column names and remember them.
That is what the agent fills in. It cannot invent a column, and it cannot
reach any other table or any other document.
## What the agent may do
Two switches, both **off** until you turn them on. Connecting your spreadsheet on
its own gives the agent nothing.
| Switch | What happens |
| - | - |
| **Fill in a row** | Writes what it has learned into your table — one row per conversation, filled in as the answers arrive |
| **Look something up** | Finds rows by matching one column exactly, so the agent can answer from what you already hold |
Every switch you turn on — here, on your calendar, anywhere — is described to
the agent on **every turn of every call**. A handful is sharp and quick. A long
list is a slower reply from an agent less sure which one you meant. Nothing
stops you, so the restraint is yours.
## Filling the lead's own row, not a new one
A lead list usually arrives with a row per person already in it — imported from
an ad, a form or a CSV — and the job of the call is to fill in the rest. Left
alone, every call adds a fresh line underneath and the row you already had stays
half empty.
On the spreadsheet card, pick the column that identifies each person under
**Update an existing row**, and how to compare it:
| Comparing as | Use it for | What matches |
| - | - | - |
| **Phone number** | Mobile numbers | The last ten digits, so `+91 98018 05137`, `919801805137` and `98018-05137` are one person |
| **Exact value** | An application id, an email, a name | The same text, ignoring case and spacing |
When a caller matches, their answers go into **that** row. When nobody matches,
a new row is added as before.
We never guess the column. A column called *Contact* might hold an email or a
phone number, and a wrong guess writes one customer's answers over another's.
### Filling in what the call already knew
On a campaign you already know things the customer is never asked — their name
and number came from your own list. A model told not to repeat those back never
volunteers them either, so they would stay blank.
**Fill from campaign variables** on the same card maps a column to a variable
your campaign supplies, one per line:
```
MOBILE_NO=phone
CANDIDATE_NAME=name
```
Anything the agent did learn wins; this only fills what was left blank.
## When to save: during the call, or after it
A switch on the spreadsheet card, and it only changes **phone calls**.
| | |
| - | - |
| **During the call** | Rows fill in as the customer answers, so the sheet is current while the call is still going. Each save costs the caller a short pause. |
| **After the call** | The agent just asks its questions. The whole conversation is read once it ends and the row is written in one go. |
Reading afterwards is usually the better call, and not only for the pause:
* An answer given in the first minute lands in the right column even if the
question came in the fourth.
* Someone who corrects themselves — *"sorry, it's 42, not 41"* — is recorded
once, correctly, instead of twice.
* A call that ends abruptly still leaves everything that was said.
What you give up is liveness: nothing appears until the call is over.
**Chat and WhatsApp always save as they go.** Nothing there ends the way a
call does, so there is no "after" to wait for. The switch is about calls.
## Save in the background
A switch on the same card. With it on, the agent **stops waiting for Grist**.
The answer is kept here the moment the customer gives it, and reaches your
spreadsheet within a minute. Nothing is lost if Grist is slow, and nothing is
lost if you have used up a free plan's calls for the month — the work waits and
goes through when it can.
**Turn it on if your agent answers the phone.** A caller hears every second the
agent spends waiting, and this removes all of them.
**Leave it off if you watch the sheet live** — during a campaign, say — and want
the row to appear as the words are spoken.
Either way the agent never claims something is saved when it is not.
## What your customer experiences
> **Them:** Hi, I'd like a quote for a kitchen.
> **Agent:** Of course. Can I take your name?
> **Them:** Priya Nair.
> **Agent:** Thanks Priya. And the best number to reach you on?
By the end of that call there is one row in your spreadsheet with both answers in
it, plus anything else you have columns for.
## A column for what happened
Make a column called **Summary** — or Notes, Remarks, Comments — and turn on
**Summarise each call** on the Voice page, and it fills with two or three
sentences about what the customer said and how the call ended.
It is the one column that is filled even when nothing else could be: somebody
who says only *"not interested, I'm busy"* answers no question on your list, and
that sentence is the whole useful record of the call.
**Both halves are needed.** The switch is what starts it; making the column
does not. And after adding any column in Grist, go back to the spreadsheet
card and pick your table again — we cache the column list when you pin it, so
a column added since is one the agent has never been told about.
## Things worth knowing
**One row per conversation.** Every answer goes into the same row, so you get one
line per customer rather than one line per thing they said.
**The agent fills in only what it was told.** A field it never heard is left
empty. It will not guess.
**Formula columns are read, not written.** Grist computes those itself. If
*every* column in the table is a formula we say so when you pick it — the agent
can read that table but cannot save anything into it.
**Looking something up is exact and case-sensitive.** `PRIYA` does not match
`Priya`. Use it for order numbers, phone numbers and reference codes, which is
what customers give verbatim anyway.
**It will not search for nothing.** If the agent has no value to match on it asks
the customer for one, rather than returning whatever happens to be at the top of
your table.
**At most five rows come back from a lookup.** More than that read aloud is a
paragraph nobody can follow on a phone.
**If you rename or delete a column in Grist, tell us.** We remember your columns
from when you picked the table. Rename one and saves start failing. Fixing it
takes ten seconds: open the card and choose the table again.
**Free Grist has limits, and they are Grist's rather than ours.** A free site
allows **3,000 API calls a month** and **5,000 rows per document**. A busy phone
line can use those, and when they are gone Grist refuses — your card says so.
*Save in the background* handles the smaller bumps for you.
**Disconnecting keeps your data.** Rows already written stay in your spreadsheet;
they are your records, not ours to remove. Anything still queued and not yet
written is discarded, and we tell you how many that is before you are left
wondering.
## If it stops working
The card says **Needs attention** and shows what Grist said.
Usually it is one of three things: the key was removed in Grist, the document is
full or out of monthly calls, or a column was renamed. Create a new key and use
**Replace key** if it is the first — your switches and your chosen table are
remembered.
## Deliberately not included
The agent cannot reach **any other document or table**, even though your API key
could. It cannot **create or delete columns, tables or documents**, and it cannot
**delete rows**. It fills the one table you pointed it at, and reads from it.
Nobody phoning your business has a good reason to reshape your spreadsheet, and
the safest way to guarantee that is not to give the agent the ability at all.
# Website & app agent
Source: https://docs.thinnest.ai/agent/website-and-app-agent
Let your agent see the screen your customer is on, fill it in, point at the right button and step in when they get stuck.
Switch this on and your agent stops being a chat in the corner. On your website
— and, with the mobile SDKs, inside your app — it can see the screen your
customer is on: the form, which fields are empty, the error under a field, the
buttons. It answers about what is in front of them, fills fields in, points at
the next step, and offers help on its own when someone gets stuck. By chat or by
voice.
## Switch it on
Go to **Agents**, open the agent on your website, and choose **Website & app**
under **Deploy**.
Switch on **Let the agent see and use the screen** and **Save**. If your chat
widget is already on your site, there is nothing else to add: it works on the
next page load.
Every action is on until you switch it off: point at a field or button, scroll
to it, put the cursor in a field, type into fields, choose from drop-downs,
tick boxes, press buttons and links, open another page of your site, and run
your own actions.
Under **When it steps in**, add a moment: what happens, on which pages, after
how long, and what the agent says. Set **At most … per visit**.
## What it always does — whatever you switch on
* It **never reads or fills** passwords, card numbers, CVV, OTP, Aadhaar or PAN
fields.
* It sees **whether** a field is filled, not **what** is in it — unless you mark
the field readable (below).
* PAN, Aadhaar and card numbers shown on the page are masked before anything
leaves it, and page addresses leave without their query string.
* A button that **pays, submits or confirms** is pressed only after your customer
taps **Allow** in the chat. If they tap **Not now**, the agent tells them so and
leaves it to them.
* It never touches anything you mark off-limits.
## The moments it steps in
| Moment | What it means |
| - | - |
| The person has not done anything for a while | No typing, tapping or scrolling for the time you set |
| A field shows an error | An error stays on screen for the time you set |
| The same field fails twice | They fixed it, and it failed again |
| Clicks the same thing again and again | Three or more clicks on one spot within a second |
| Clicks something that does nothing | Nothing on the page changed after the click |
| Moves to leave the page | Desktop only: the pointer leaves the top of the window |
| Has been on the page for a while | Time on the page passes what you set |
**On pages containing** takes part of the page address (`checkout`, `/kyc`) or
the screen name your site sets (below). Leave it empty for every page. Each
moment shows at most once per page, never while the chat is open, and never more
than your per-visit limit. The message appears beside the chat button; a tap
opens the chat with the agent already talking.
## Tell it more (optional)
Add these after the widget tag:
```html theme={null}
```
Action names are lowercase with underscores. Whatever your function returns is
what the agent is told. Nothing your function does passes through ThinnestAI.
## Control element by element (optional)
```html theme={null}
…
```
## Voice
If the agent takes calls on your website (**Voice → where it answers → Website**),
it can act on the screen during a call too. The call shrinks to a small bar with
the agent's name, a live caption, mute and hang-up, so your customer sees their
details go in while they talk. Anything that pays or submits still waits for
**Allow** in the chat.
## Mobile apps
SDKs for **Android, iOS, React Native and Flutter** are in early access. They show
the same agent — chat, voice, cards and forms — in a sheet over your app. Your app
describes each screen to the agent (its fields, their state and errors) and
carries out the actions in its own code. [Ask us for access](mailto:support@thinnest.ai).
## FAQ
The extra script loads only when this is switched on for the agent, and it
reads the page only while the chat is open or a call is on.
It can fill the form. Pressing a button that submits, pays or confirms waits
for your customer to tap Allow. To never let it press a particular button,
mark it `data-ta-no-touch`.
By design: values stay on your customer's device. Add `data-ta-allow` to a
field the agent may read, such as a city or plan name — never to anything
personal.
# Create Action
Source: https://docs.thinnest.ai/api-reference/actions/create-action
/api-reference/openapi.json post /agents/{id}/actions
Give the agent a call to your own API, with placeholders it fills from what the customer said. Dotted placeholders are filled by the platform, never the model: `{{call.id}}` (the id `GET /calls` lists), `{{call.from}}`, `{{call.to}}`, `{{call.direction}}` (empty outside a call), `{{conversation.id}}`, `{{contact.id}}`, `{{contact.phone}}`, `{{agent.id}}`, `{{workspace.id}}` and `{{test.chat}}` (`true` during a test chat, empty otherwise) — in the path, query, body or a header value, never the host; an unknown one is refused. Every call also sends `X-Call-Id`, `X-Conversation-Id`, `X-Agent-Id` and `X-Workspace-Id` when known, and `X-Test-Chat: true` during a test chat (`POST /agents/{id}/test-chat`), where the call values are empty and `X-Call-Id` is absent — so your endpoint can answer without writing anything. A new action is always **off**: test it with `POST …/test`, then `PATCH` `enabled: true` — sending `enabled` here is refused. Header values are sealed on arrival and never returned. A **build** key may do this.
# Delete Action
Source: https://docs.thinnest.ai/api-reference/actions/delete-action
/api-reference/openapi.json delete /agents/{id}/actions/{actionId}
Remove the action and its sealed headers. The agent stops being able to call it at once. A **build** key may do this.
# Get Action
Source: https://docs.thinnest.ai/api-reference/actions/get-action
/api-reference/openapi.json get /agents/{id}/actions/{actionId}
One action. Header values never appear; only `headerNames`.
# List Actions
Source: https://docs.thinnest.ai/api-reference/actions/list-actions
/api-reference/openapi.json get /agents/{id}/actions
The agent's actions — calls to your own HTTPS API that it may make mid-conversation — newest first. Header values never appear; only `headerNames`.
# Test Action
Source: https://docs.thinnest.ai/api-reference/actions/test-action
/api-reference/openapi.json post /agents/{id}/actions/{actionId}/test
Make the call once, for real, through the same address checks and credentials the agent uses — and it works while the action is still off, which is the point. A test has no conversation, so only `{{agent.id}}` and `{{workspace.id}}` fill; the rest are empty. A failed call is still `200`, with `ok: false`. On a `POST`, `PUT`, `PATCH` or `DELETE` action the test **does** the write. A **build** key may do this.
# Update Action
Source: https://docs.thinnest.ai/api-reference/actions/update-action
/api-reference/openapi.json patch /agents/{id}/actions/{actionId}
Change any field, or switch it on or off with `enabled`. The action is validated as it will be saved, so a new `url` is checked against the parameters already there. Leaving `headers` out keeps the saved credential; sending any replaces the whole set. `enabled: true` makes it callable from every conversation at once. A **build** key may do this.
# Create Agent
Source: https://docs.thinnest.ai/api-reference/agents/create-agent
/api-reference/openapi.json post /agents
Makes an agent exactly as the console does: a starter persona when you send no `instructions`, a website widget key, and (where your deployment has voice) a voice channel that answers on the web and WhatsApp. Only `name` is required. The plan's agent allowance, its model list and its voice tiers apply; a refused voice undoes the whole create. A **build** key may do this.
# Delete Agent
Source: https://docs.thinnest.ai/api-reference/agents/delete-agent
/api-reference/openapi.json delete /agents/{id}
Deletes the agent and everything under it: its conversations, knowledge, actions and channels. It cannot be undone. A **build** key may do this.
Refused with `409` `agent_on_live_call` while the agent is on a call (ringing or connected): deleting it then would lose that call's recording, transcript, analysis and usage. Delete it after the call ends.
# Get Agent
Source: https://docs.thinnest.ai/api-reference/agents/get-agent
/api-reference/openapi.json get /agents/{id}
One agent: its instructions, model, languages, script, widget, call settings and the details it collects from calls. Which engine speaks its voice or runs its model is never part of the answer.
# Get Agent BYOK Model
Source: https://docs.thinnest.ai/api-reference/agents/get-agent-byok-model
/api-reference/openapi.json get /agents/{id}/byok-model
On a workspace that brings its own keys: the model this agent runs on your own LLM key. `model` is the agent's own choice (null when it runs the key's model), and `stale` names an earlier choice your key no longer reaches, which the key's model answers in place of. Refused with `409` when the workspace is not using its own keys, or when this agent is set to `byok: "off"` (it then runs on our models, and a choice you set is not available to read until you put it back on the workspace's keys).
# Get Agent BYOK Voice
Source: https://docs.thinnest.ai/api-reference/agents/get-agent-byok-voice
/api-reference/openapi.json get /agents/{id}/byok-voice
On a workspace that brings its own keys: the voice this agent speaks with on your own voice provider. `voice` is null for the provider's default voice. A choice belongs to the provider it was made on; after you switch your voice key to another provider, `stale` holds the old choice and calls speak the new provider's default until you choose again. On a Cartesia key the response also holds how the voice sounds: `speed`, `volume`, `emotion`, `agentModel` (this agent's own Sonic model; `model` is always your key's) and `pronunciationDictId`, each null while unset. Refused with `409` when the workspace is not using its own voice key (it brings no keys, or only its language model), or when this agent is set to `byok: "off"` (it then speaks in our voices).
# List Agents
Source: https://docs.thinnest.ai/api-reference/agents/list-agents
/api-reference/openapi.json get /agents
Every agent in your workspace, newest first, in the same shape Get Agent returns. Page with `limit` and `cursor`.
# Set Agent BYOK Model
Source: https://docs.thinnest.ai/api-reference/agents/set-agent-byok-model
/api-reference/openapi.json put /agents/{id}/byok-model
Chooses which model of your own LLM key this agent runs. It must be one the key reaches — listed under `byok.models` by List Models — or `null` to run the key's own model (the one set under Settings → Your keys). The next reply uses it. A **build** key may do this.
# Set Agent BYOK Voice
Source: https://docs.thinnest.ai/api-reference/agents/set-agent-byok-voice
/api-reference/openapi.json put /agents/{id}/byok-voice
Chooses the voice this agent speaks with on your own voice provider: an id from List BYOK Voices, checked against the provider's list for your key, or `null` for the provider's default. On Cartesia an id that is not on the list yet, such as a voice cloned a moment ago, is checked with Cartesia directly and accepted if your key owns it. The agent needs a voice channel first. A **build** key may do this.
On a Cartesia key you can also set how this agent's voice sounds: `speed` (0.6 to 1.5), `volume` (0.5 to 2.0), `emotion` (one of Cartesia's values; English only), `model` (`sonic-3.6`, `sonic-3.6-2026-08-27`, `sonic-3.5`, `sonic-3` or `sonic-2` for this agent alone; `sonic-3.6` follows Cartesia's latest stable snapshot, the dated one never changes) and `pronunciationDictId` (a dictionary id from your Cartesia account). Every field is optional and the request only changes what it names: a key left out stays as it is, `null` goes back to the default, a value sets it. So `{ "voice": "…" }` alone changes the voice and keeps the settings; `{ "voice": null }` goes back to the provider's default voice and clears them. The settings belong to a voice of the agent's own: sending one before a voice is chosen, or with `voice: null`, is refused. `speed`, `volume` and `emotion` need a Sonic 3 model, and `speed` does not apply to a Cartesia Professional Voice Clone. A refused field answers `400` with `code: validation_failed` and `field` naming it.
# Test Chat
Source: https://docs.thinnest.ai/api-reference/agents/test-chat
/api-reference/openapi.json post /agents/{id}/test-chat
Talks to your real agent through its website chat — the same prompt, model, knowledge, actions, limits and billing as the console's Playground — and answers with its reply and every tool it used. It runs as a sandbox: anything that would phone, message, email or send a code to a person, or change a calendar booking, is switched off and the agent says what it would have done instead; your own actions still run for real, and every request they make carries an `X-Test-Chat: true` header (and `{{test.chat}}` fills as `true`) so your endpoint can answer without writing anything. The conversation appears in your inbox; send `session` back to continue it. A **build** key may do this.
# Update Agent
Source: https://docs.thinnest.ai/api-reference/agents/update-agent
/api-reference/openapi.json patch /agents/{id}
Changes any subset of the fields Create Agent takes and answers with the whole agent. Fields you leave out are untouched, and unknown fields are ignored — but a body that names no field this API knows is refused. A model or voice is checked against your plan only when it changes, and a refused voice refuses the whole patch before anything is written. A **build** key may do this.
# Get Agent Analytics
Source: https://docs.thinnest.ai/api-reference/analytics/get-agent-analytics
/api-reference/openapi.json get /agents/{id}/analytics
What the agent's Analytics page shows: totals, conversations/replies/leads per day, conversations by hour and by channel, the questions it could not answer and the questions asked most. Windows are rolling `days`; hours and days are UTC. `resolved` and `escalated` count conversations by their current status. Any key may call this, a **read-only** key included.
# Get Dashboard
Source: https://docs.thinnest.ai/api-reference/analytics/get-dashboard
/api-reference/openapi.json get /dashboard
The console Dashboard's figures for the whole workspace: how many agents, numbers, knowledge documents, campaigns, keys, members, contacts and leads; conversations unread and waiting on a person; calls in the last 30 days; and the balance as the account card shows it. The period charts are `GET /calls/summary` and `GET /whatsapp/summary`. Any key may call this, a **read-only** key included.
# Authentication
Source: https://docs.thinnest.ai/api-reference/authentication
Bearer keys, and the one place they must never go.
```
Authorization: Bearer ta_live_…
```
Create a key in **Settings → API keys**. It is shown **once**, at creation, and
stored only as a hash — nothing can retrieve it afterwards, including us.
## This is not your widget key
Your **public key** (`pk_…`) identifies which agent answers and is meant to be
visible in your page source.
Your **API key** (`ta_live_…`) can message any of your customers. It belongs in
a server's environment. Putting it in your HTML hands anybody the ability to
message your entire contact list.
## Choose what each key can do
When you create a key, you pick one of three access levels. The level applies
everywhere the key is used — your own code calling the API, and the MCP server.
A key's access cannot be changed later — create a new key and revoke the old one.
| Access level | What the key can do |
| - | - |
| **Full access** | Everything, including messaging your customers and placing calls. Keys created before access levels existed have full access. |
| **Build** | See everything, and set up agents, knowledge, actions, contacts, webhooks, sequences and their steps, WhatsApp forms, template and one-time-code drafts, projects, teams, saved replies and notification settings, and connect a Telegram bot. It can also test-chat an agent, resolve, take over and assign conversations, and pause a sequence or pause or cancel a campaign. It **cannot** send messages or codes, place or end calls, reply to a customer, report events, add anyone to a sequence, start a campaign or sequence, publish a form, rent or release a number, clone a voice, or change the workspace, its members or invitations. Test chats and adding knowledge still count towards your usage. |
| **Read-only** | See everything. Change nothing. |
If a key tries something its access level does not allow, the request is
refused with `403` and a message saying what kind of key it is:
```json theme={null}
{ "error": "This API key is a build key: it can set up agents, actions, contacts and webhooks, but not message customers, send codes or place calls. Use a full key for that." }
```
Connecting an AI assistant such as Claude Code or Cursor to the
[ThinnestAI MCP server](/mcp/overview)? The MCP server can do exactly what its
key can do, so choose **Build** to let it set things up without being able to
contact a customer, or **Read-only** if it only needs to report. See
[Connect your assistant to the MCP server](/mcp/connect).
## Acting for one of your customers
If you build your own product on the API, your key reaches each of your
[customers](/api-reference/customers): add their id in a header.
```
Thinnest-Workspace: org_1f2e…
```
The request runs inside that customer, with your key's access level. An id that
is not one of your customers is `404`, whether or not it exists.
## Revoking
Immediate. Revoked keys are kept along with their last-used time, because that
is the first thing anybody wants to know after a leak.
## Rate limits
Per organization, per minute.
```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 60
```
Honour `Retry-After`. The limit exists because a loop in an integration can
spend a month of WhatsApp budget before anybody looks at a dashboard — and
because Meta rate-limits the number afterwards. The damage is money first and
reputation second.
Idempotency protects you from repeating **one** request. It does nothing about
a thousand different ones. These are separate problems and both are guarded.
## Storing the key
```bash Environment theme={null}
THINNEST_API_KEY=ta_live_xxxxxxxxxxxx
```
```js Node theme={null}
const res = await fetch("https://app.thinnest.ai/api/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.THINNEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
```
```python Python theme={null}
import os, requests
res = requests.post(
"https://app.thinnest.ai/api/v1/messages",
headers={"Authorization": f"Bearer {os.environ['THINNEST_API_KEY']}"},
json=payload,
)
```
# Bring your own keys
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys
Run your agents on your own speech-to-text, LLM and voice accounts, on your own voice and language model, on your own voice alone, or on your own language model alone. Each agent can follow the workspace or choose its own.
With **bring your own keys** (BYOK), your workspace uses your own accounts with the providers. The
three jobs are speech-to-text, the LLM and the voice. You pay those providers directly. Depending
on what you bring, we charge a flat platform fee or our usual per-minute voice plan.
You choose what you bring:
* **All three of my keys** (scope `all`). Speech-to-text, the LLM and the voice are all yours.
* **Only my voice** (scope `voice`). Your voice account speaks. Our speech-to-text hears the
caller, our model answers, and the phone line is ours.
* **My voice and my language model** (scope `llm_voice`). Your voice account speaks and your LLM
account answers, on any model your key offers. We keep the speech recogniser (our speech-to-text
hears the caller), and the phone line is ours.
* **Only my language model** (scope `llm`). Your LLM account answers, on any model your key
offers. We keep the speech recogniser and the voice, and the phone line is ours. You pay the same
per-minute voice plan as any other call: Standard, Premium or Studio, by the voice the agent
speaks with.
| | All three of my keys | My voice and my language model | Only my voice | Only my language model |
| - | - | - | - | - |
| Call minute | **₹1.00** (\$0.012) a minute, all-in | **₹1.00** (\$0.012) a minute, our speech-to-text and phone line included | **₹1.50** (\$0.02) a minute, our phone line included | Our usual voice plan: Standard, Premium or Studio, with your plan's discount |
| Chat reply | **₹0.02** per reply | **₹0.02** per reply | Our usual price, because the model is ours | **₹0.02** per reply, as with all three |
Calls are billed in 30-second pulses, at the rate set when the call starts. With only your
language model, the model you run never changes the price of the minute: the price follows the
voice alone, however costly your model is on your own account.
With all three, BYOK turns on only when a speech-to-text key, an LLM key and a voice key are all
added and checked. With your voice and your language model, it needs a checked LLM key and a
checked voice key. With only your voice, it needs only a checked voice key. With only your
language model, it needs only a checked LLM key, with a model chosen. Any plan can turn it on.
With all three keys, your voice and language model, or only your language model, each agent can also run its own **model** from your LLM key. On the agent's **Playground**, the
Model list shows every model your key reaches. Leave it on your key's model, which is the one set
in **Settings → Your keys**, or pick another for this agent alone.
Each agent speaks with a voice you choose from your own voice provider. Open the agent's
**Voice** page: it lists every voice your key can use, and **Play** speaks a short line in the
agent's language. The preview runs on your account, so the provider charges you for those few
characters. If a provider hosts its own sample of a voice, that sample plays instead, at no
cost. An agent without a chosen voice speaks with a standard voice from your provider.
On a call with all three, your speech-to-text hears the caller, your LLM answers, and your voice
speaks. With your voice and your language model, our speech-to-text hears the caller, and your LLM
answers and your voice speaks. With only your voice, only the speaking is yours. With only your language model, only the
answering is yours: our speech-to-text hears the caller and our voice speaks. If one of your keys cannot be used when a call starts, the call is not
answered on our providers instead.
### Which models answer calls with only your voice
With only your voice, the call minute includes our model, so a call runs only on a low-cost
model. Today those are:
* Prana
* Prana \[Voice]
* GPT-OSS 120B
* GPT-5 Nano
* GPT-4.1 Nano
The rule is Prana, Prana \[Voice] and GPT-OSS 120B by name, plus any voice model (other than our call-only
Composer, which runs a second model each turn) whose price per token is at or below our reference low-cost voice model on both input and output, so a new
model that is cheap enough joins the list on its own. `GET /api/v1/models` marks each model with
`voiceOnlyByok`.
An agent already set to another model keeps it for chat, which is billed as usual, and runs on
GPT-OSS 120B on calls. The agent's model picker lists only the allowed models while you use only your
voice. Setting another model through `POST` or `PATCH /api/v1/agents` returns a 400 that says
why. With all three of your keys, with your voice and language model, with only your language model, or without BYOK, nothing changes:
with only your language model the model is your key's, so any model your key offers can answer calls. The limit applies only to agents
that use your keys: an agent you have switched off your keys (below) answers on our stack, so it may
use any model.
### Each agent chooses how it uses your keys
BYOK is switched on for the workspace, and the scope you choose in **Settings → Your keys** is the
**default**: every agent follows it until it is told otherwise. A workspace holds its keys once, and
each agent can choose for itself. An agent's `byok` is one of:
* `null` (or `"workspace"`): follow the workspace's default scope. This is how every agent starts.
* `"off"`: never use your keys (below).
* `"all"`, `"voice"`, `"llm"` or `"llm_voice"`: run this agent on that scope's keys, whatever the
workspace default is. For example, with an LLM key and a voice key saved, one agent can run on
`"llm"` (your model, our voice) while another runs on `"llm_voice"` (your model and your voice).
Choosing a scope is refused with `409` unless the workspace holds checked keys, each with a model, for
everything the scope needs. The message names the missing key, for example "no verified voice key
(tts)". Switching BYOK off in the workspace is the master switch: with it off, no agent uses a key,
whatever it chose. The workspace default decides which keys turning BYOK on needs; an agent's own
scope needs its keys too, so a key an agent's scope needs cannot be removed while BYOK is on (set the
agent to follow the workspace first, or replace the key).
A call or reply is priced and run under the scope that applies to the agent when it starts, so two
agents in one workspace can bill differently: the price follows the agent's scope. If an agent's
scope can no longer be served when a call starts (a key was removed while BYOK was off), the agent
falls back to the workspace's scope and the call goes ahead; it is never refused for that, and never
billed at a rate that does not match how it ran.
A call can be handed to another agent only when that agent would run on the same stack the call
started on. A hand-off to an agent on a different scope, or switched off your keys, is refused.
#### An agent on ours
Give an agent `byok: "off"` and it never uses your keys, whatever the workspace does:
* calls run on our voices, our speech-to-text and our model, and the minute is billed at our normal
rate for that voice, not the BYOK rate;
* chat replies are billed as normal replies, not the flat BYOK reply charge. With only your language
model, the minute is already our normal rate, so what changes for an agent set to `"off"` is the
model (ours, not yours) and the chat reply price;
* the voice-only model limit does not apply to it;
* the model and voice choices you saved for your own keys are kept and ignored until you switch the
agent back to `"workspace"`.
The other agents in the workspace keep their own choice and price. A customer of yours
follows the same rule on its own agents. The setting is read once when a call or a reply starts, so a
call is never priced at one rate and run on another stack. A call already ringing keeps the stack it
was started on: if you switch its agent off your keys in those few seconds, the call is refused
rather than moved onto ours.
An agent set to `"off"` is billed at our rate for the voice it uses. A new agent starts on a Premium
voice, which bills at the Premium rate, so choose a standard voice for an agent whose minutes should
be cheaper.
## In the console
Open **Settings → Your keys** and choose the default: **All three of my keys**, **My voice and my
language model** (we keep the speech recogniser, ₹1/min), **Only my voice** or **Only my language
model**. All three key cards are always shown, so you can save a key for an agent that chooses a
different scope. Keys for jobs your default does not cover are not used by agents that follow it. On
an agent's **Playground** or **Voice** page, **How Agent uses your keys** chooses between following
the workspace, **Off**, and each set your saved keys allow. Each card has:
* **Provider.** Choose who does that job.
* **Model.** Every model the provider offers. Before you add a key, the list shows the models the
provider offers. Once your key is checked, it shows the models **your** key reaches. Long lists
are grouped by family, such as Flux, Aura-2 and Aura for Deepgram's voices.
* **API key**, plus any other field the provider needs. Azure, for example, needs a region.
**Verify and save** checks the key with the provider before storing it. A key the provider rejects
is not saved. Keys are stored encrypted, and are only ever shown by their last four characters.
Once the cards you need are filled, switch on **Use my own keys**.
## Providers
| Job | Providers |
| - | - |
| Speech-to-text | Deepgram, AssemblyAI, Soniox, Speechmatics, Gladia, Sarvam, ElevenLabs, Cartesia, OpenAI, Groq, Mistral, Azure Speech |
| LLM | OpenAI, Anthropic Claude, Google Gemini, Groq, Mistral, Sarvam, DeepSeek, xAI Grok, Together AI, Fireworks AI, Cerebras, OpenRouter, or any OpenAI-compatible URL |
| Voice | Deepgram, ElevenLabs, Cartesia, OpenAI, Sarvam, Soniox, Mistral, Azure Speech, Rime, Inworld, Hume, Murf, Speechify |
**Azure Speech** takes your key and region. If your Azure resource accepts its key only at its own
address, as Azure AI Foundry resources do, also give its **resource endpoint**, such as
`https://my-resource.cognitiveservices.azure.com`.
**Any OpenAI-compatible URL** takes a base URL that starts with `https://`. Azure OpenAI's
`https://.openai.azure.com/openai/v1` is one. If the server does not list its models, send
the model id with the key, and we check the two together with a one-token request.
On calls, a Google Gemini 2.5 or 3 model is asked to think less before it speaks, so the
caller is not left waiting (a Pro model cannot switch thinking off, so it is asked for the least
it allows). Chat replies are not changed.
## When a key fails
There is no fallback to our providers for a job that is yours. If your provider refuses a request
(a revoked key, no credit, rate limits), the reply fails, or the voice does not speak. We do not answer on our own models and bill you for it. You get a notification naming
the provider and the reason, at most once a day per provider. With only your voice, speech-to-text
and the LLM are ours, so our usual backups for them stay on. With only your language model, the
speech-to-text and the voice are ours and keep our usual backups, but the model never has one: if your
key fails, the caller hears our apology and the call is flagged, and we do not answer on our model.
To hear about changes to your keys in your own systems, subscribe a webhook to
[`byok.changed`](/api-reference/webhooks#workspace-events): it is sent when a key is added, replaced,
removed or refused by its provider, and when your own keys are switched on or off or set to cover
different jobs. It names the job and the provider, never any part of the key. Each call's
[Get Call](/api-reference/calls/get-call) also says which keys it ran on, in `billing.keys`.
## Customers
If you build on our API with [customers](/api-reference/customers), your customers use **your** keys
while BYOK is on, with your choice of all three, your voice and language model, only the voice or only the language model. A customer can bring a complete
set of its own for that choice (three keys, an LLM and voice key, a voice key or an LLM key), which then overrides yours. Turning BYOK off in your workspace turns it off for every customer that inherits your keys. A customer with its own complete set and its own switch on keeps using its own.
## API
Every request below also works for a customer, with the `Thinnest-Workspace` header.
### Status
```http theme={null}
GET /api/v1/byok
```
```json theme={null}
{
"enabled": true,
"scope": "all",
"usingScope": "all",
"using": "own",
"complete": true,
"credentials": [
{
"kind": "stt",
"provider": "deepgram",
"label": "Deepgram",
"key": "…k9z2",
"fields": { "model": "nova-3-general" },
"model": "nova-3-general",
"models": ["flux-general-en", "nova-3-general", "…"],
"verifiedAt": "2026-10-06T10:00:00Z",
"updatedAt": "2026-10-06T10:00:00Z"
}
]
}
```
`scope` is what this workspace brings by default: `all`, `voice`, `llm` or `llm_voice`; each agent may choose its own (below). `usingScope` is the scope that applies
right now (a customer runs on its developer's), or `null`. `complete` says whether the keys for
`scope` are ready. `using` is `own`, `developer` (a customer using its developer's keys) or `none`. `models` is what the
provider offered this key, and `model` is the one it runs.
### Add or replace a key
```http theme={null}
PUT /api/v1/byok/credentials
```
```json theme={null}
{ "kind": "llm", "provider": "openai", "credentials": { "apiKey": "sk-…", "model": "gpt-5.4-mini" } }
```
* **`kind`.** One of `stt`, `llm` or `tts`.
* **`provider`.** One of the provider ids in the table above, in lower case. An error lists the ones
that do that job.
* **`credentials.model`.** Optional. Without it, a speech key starts on the provider's usual model.
An LLM key starts on the provider's usual model when it has one; otherwise choose one.
* **Replacing a key.** If you replace a key with one for the same provider, its model is kept.
| Status | Why |
| - | - |
| `400` | The provider rejected the key, or does not offer that model on it. |
| `409` | BYOK is on, and this key would leave a job without a model. |
| `502` | The provider could not be reached to check the key. Try again. |
### Change the model, or refresh the list
```http theme={null}
PATCH /api/v1/byok/credentials/llm
```
```json theme={null}
{ "model": "gpt-5.4-nano" }
```
The key is not sent again. The model must be on the key's `models` list. For a model the provider
launched after you added the key, send `"refreshModels": true`, alone or together with the new
`model`.
### Turn BYOK on or off
```http theme={null}
PATCH /api/v1/byok
```
```json theme={null}
{ "enabled": true, "scope": "voice" }
```
Send `enabled`, `scope`, or both. `scope` is `all`, `voice`, `llm` or `llm_voice`. The request is refused with
`409` until the keys for the scope are added, checked, and each has a model: all three for `all`,
the voice key for `voice`, the LLM key for `llm`, the LLM key and the voice key for `llm_voice`. While BYOK is on, moving to a scope needs that
scope's keys first, for example the other two keys to move from `voice` to `all`.
### Voices
```http theme={null}
GET /api/v1/byok/voices
```
```json theme={null}
{
"provider": { "id": "elevenlabs", "label": "ElevenLabs" },
"model": "eleven_flash_v2_5",
"items": [
{ "id": "EXAVITQu4vr4xnSDxMaL", "name": "Sarah", "language": "en", "gender": "female", "sample": "https://…", "own": false }
]
}
```
These are the voices your voice provider offers your key. `sample` is a clip the provider hosts,
when it has one. On Deepgram, a voice is a model, such as `aura-2-helena-en` or `flux-alexis-en`.
On Cartesia the list starts with your own cloned voices.
`own` is `true` for a voice that belongs to your own account with the provider — one you cloned or
designed there — and `false` for the provider's library:
| Provider | What `own` means |
| - | - |
| Cartesia | The voices your Cartesia account owns (listed first) |
| ElevenLabs | The voices your account owns: your cloned and designed voices |
| Soniox | Always `true`: Soniox lists only your account's own voices |
| Every other provider | Always `false`: the provider does not say which voices are yours |
### Preview a voice
```http theme={null}
POST /api/v1/byok/voices/preview
```
```json theme={null}
{ "voice": "EXAVITQu4vr4xnSDxMaL", "agentId": "agt_…" }
```
The response is the audio itself: `audio/mpeg`, or `audio/wav` for Sarvam. Without `text`, the
voice speaks a short line of ours in the agent's language, or in `language` if you send it. Any
`text` you send is capped at 200 characters, because your provider charges you for every
character. Soniox has no preview here: it speaks only on a live call.
### Choose an agent's model
`GET /api/v1/models` lists our models in `items`, as before. On a workspace using its own keys
it also returns `byok`:
```json theme={null}
{ "byok": { "provider": { "id": "openai", "label": "OpenAI" }, "workspaceModel": "gpt-5.4-nano", "models": ["gpt-5.4-mini", "gpt-5.4-nano", "…"] } }
```
```http theme={null}
PUT /api/v1/agents/{id}/byok-model
```
```json theme={null}
{ "model": "gpt-5.4-mini" }
```
The model must be one your key reaches. Send `null` to go back to your key's model. `GET` on the
same path returns the agent's choice. It also returns `stale`, the agent's earlier choice, if your
key no longer reaches it; until you choose again, the agent runs your key's model.
### Choose an agent's voice
```http theme={null}
PUT /api/v1/agents/{id}/byok-voice
```
```json theme={null}
{ "voice": "EXAVITQu4vr4xnSDxMaL" }
```
The voice must be one that `GET /byok/voices` lists. Send `null` to go back to the provider's
standard voice. `GET` on the same path returns the agent's current choice.
Your own cloned voices are listed and can be used. On Cartesia, `GET /byok/voices` lists your
account's own voices first, then the whole library (up to 2,000 voices), so a clone is always on the
list however large the library is. If Cartesia is slow to answer, the list can come back short:
the response then says `"complete": false`, is not cached, and you should ask again. A voice you cloned a moment ago is accepted here by its id even before it shows up in the
list.
To use a different voice for a single call, put it in that call's `overrides`, as
`"overrides": { "voice": "EXAVITQu4vr4xnSDxMaL" }` on `POST /api/v1/calls` or in a batch.
On a workspace using its own keys, the voice must be one of yours from `GET /byok/voices`. A per-call voice is
checked like an agent's voice: if it is not on the list we ask the provider about that one voice
directly, so a clone made a moment ago is accepted. If the provider cannot be reached to check, the
request fails with `502` and can be retried; only a provider that says it has no such voice gives a refusal.
A choice belongs to the provider it was made on. If you switch your voice key to another provider,
the agent speaks with the new provider's standard voice until you choose again. `GET` shows the old
choice as `stale` until then.
### Tune a Cartesia voice
If your voice key is Cartesia, each agent can also set how its voice sounds. A client who finds
calls quiet or a little fast can fix it for that agent alone, without touching the others.
In the console, open the agent's **Voice** page and choose a voice. A **How it sounds** panel
appears under the list, with a **Hear it** button that speaks a short line with the settings as
they are now (on your account, so Cartesia charges you for those few characters) and a **Save
settings** button. Through the API, send the same fields to the voice call above:
```http theme={null}
PUT /api/v1/agents/{id}/byok-voice
```
```json theme={null}
{ "speed": 0.95, "volume": 1.4, "emotion": "calm", "model": "sonic-3.5", "pronunciationDictId": "pdict_123" }
```
| Field | Values | Default |
| - | - | - |
| `speed` | 0.6 to 1.5 | 1, Cartesia's own |
| `volume` | 0.5 to 2.0. Raise it if calls sound quiet. | 1 |
| `emotion` | One of Cartesia's emotions, such as `neutral`, `calm`, `content`, `sad`, `angry`, `scared`, `happy`, `excited`. | the words decide |
| `model` | `sonic-3.6`, `sonic-3.6-2026-08-27`, `sonic-3.5`, `sonic-3` or `sonic-2`, for this agent alone. `sonic-3.6` is Cartesia's own name for its newest 3.6 version; the dated one is a fixed release. | your key's model |
| `pronunciationDictId` | The id Cartesia gave a pronunciation dictionary in your account | none |
Every field is optional, and a request changes only the fields it names. A field you leave out stays
as it is, `null` puts one back to its default, and a value sets it. So `{ "voice": "…" }` alone
changes the voice and keeps the settings. `{ "voice": null }` goes back to the standard voice and
clears them, because the settings belong to a voice of your own: sending one before a voice is
chosen, or together with `"voice": null`, is refused. A setting that is not allowed answers `400`
with `code: validation_failed` and `field` naming it. `GET` on the same path returns the settings,
each `null` while unset. It returns the agent's own model as `agentModel`, because `model` there is
always your key's.
A few things to know:
* `emotion` works in English only. In other languages Cartesia ignores it.
* `speed`, `volume` and `emotion` need a Sonic 3 model. With `sonic-2` they are refused, and if your
key's model is later changed to `sonic-2` they are simply not sent.
* `speed` does not apply to a Professional Voice Clone, which keeps the pace of its recording. Use
`volume`, or an Instant Voice Clone, to adjust it.
* Cartesia treats these as guidance, not exact changes. Listen with **Hear it** or the preview below
before you rely on a value.
* A call that names its own voice in `overrides` keeps the agent's settings.
* Only an agent speaking with your Cartesia voice has them. Other providers are not affected.
To hear a setting without saving it, add the same fields to the preview:
```http theme={null}
POST /api/v1/byok/voices/preview
```
```json theme={null}
{ "voice": "a0e99841-438c-4a64-b679-ae501e7d6091", "speed": 0.95, "volume": 1.4 }
```
Only the fields you send are spoken; the agent's saved settings are not read.
### Choose how one agent uses your keys
`byok` is a field of the agent, on `POST /api/v1/agents` and `PATCH /api/v1/agents/{id}`, and comes
back on every agent:
```http theme={null}
PATCH /api/v1/agents/{id}
```
```json theme={null}
{ "byok": "llm_voice" }
```
* Leave `byok` out and the agent's choice is unchanged.
* `null` (or `"workspace"`) follows the workspace's default scope. An agent that follows the workspace
comes back as `"workspace"`.
* `"off"` keeps this agent on our voices, models and pricing.
* `"all"`, `"voice"`, `"llm"` or `"llm_voice"` runs this agent on that scope's keys. It is refused with
`409` if the workspace has no checked key for something the scope needs, and the error names it.
* Anything else is a 400.
In the console, the same choice is **How Agent uses your keys** on the agent's Playground and Voice
pages, shown only while your workspace uses its own keys.
Changing it needs an API key that can edit agents, as for the agent's model and voice. While an agent
is `"off"`, `GET /api/v1/agents/{id}/byok-model` and `byok-voice` answer `409`, because it uses
neither of your providers. `PUT` still saves a choice for when you switch it back on.
### Remove a key
```http theme={null}
DELETE /api/v1/byok/credentials/tts
```
The request is refused with `409` while BYOK is on and the key is one the scope needs. Under scope
`all` that is every key; under scope `voice` only the voice key is required, under scope `llm`
only the LLM key, and under scope `llm_voice` the LLM key and the voice key, so a leftover key for a
job that is ours can be deleted while BYOK is on. It is also refused while an agent is set to a scope that needs the
key. Replace a needed key instead, set that agent to follow the workspace, or turn BYOK off first.
Changing keys or switching BYOK needs an API key with **full** access. A read-only key can see the
status.
# Add BYOK Key
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/add-byok-key
/api-reference/openapi.json put /byok/credentials
Adds or replaces your key for one job. The key is checked with the provider before anything is stored — a key the provider rejects is not saved — then stored encrypted and only ever shown by its last four characters. The provider's model list is pulled at the same time. Without `credentials.model`, a speech key starts on the provider's usual model and an LLM key on its usual one where it has one; replacing a key with one for the same provider keeps the model already chosen. Needs a **full** key.
# Delete BYOK Key
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/delete-byok-key
/api-reference/openapi.json delete /byok/credentials/{kind}
Removes your key for one job. Refused while BYOK is on, since a workspace on its own keys always has all three — turn BYOK off first, or replace the key with Add BYOK Key. Needs a **full** key.
# Get BYOK Status
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/get-byok-status
/api-reference/openapi.json get /byok
Whether your workspace runs on its own keys, whose keys are in use (`own`, or `developer` for a customer using its developer's), which keys you bring (`scope`: all three, only your voice, only your language model, or your language model and your voice), whether your keys for that scope are complete enough to switch on, and each key, masked to its last four characters.
# List BYOK Keys
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/list-byok-keys
/api-reference/openapi.json get /byok/credentials
Your workspace's own provider keys, one per job, masked to their last four characters, with the model each runs and every model its provider offered it.
# List BYOK Voices
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/list-byok-voices
/api-reference/openapi.json get /byok/voices
The voices your own voice provider offers your key — what Set Agent BYOK Voice accepts. `sample` is a clip the provider hosts, free to play, when it has one. On Cartesia the list starts with your own cloned voices, then the whole library (up to 2,000 voices); if Cartesia is slow to answer the list can come back short: `complete` is then `false`, it is not cached, and you should ask again. A per-call `overrides.voice` is checked the same way as an agent's voice: if it is not on the list we ask the provider about that one voice directly, so a voice missing from a short list is still accepted. Refused with `409` when the workspace is not using its own keys; its voices are then List Voices. These are the voices of the agents that use your keys: an agent set to `byok: "off"` (Update Agent) speaks in our voices from List Voices, at our normal pricing, while your other agents stay on your keys.
# Preview BYOK Voice
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/preview-byok-voice
/api-reference/openapi.json post /byok/voices/preview
Speaks one short line in a voice of your own voice provider, on your key, and answers with the audio itself. Your provider charges you for the characters, so `text` is capped at 200; without it the voice says a short greeting of ours in `language`, or the agent's language when you name an agent. Soniox has no preview: it speaks only on a live call. On a Cartesia key, `speed`, `volume`, `emotion`, `model` and `pronunciationDictId` are spoken too, so a setting is heard before it is saved (they work as in Set Agent BYOK Voice; the agent's saved settings are not read). Needs a **full** key.
# Turn BYOK On or Off
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/turn-byok-on-or-off
/api-reference/openapi.json patch /byok
Switches your workspace onto your own keys, or back onto ours, and/or chooses what you bring (`scope`). Scope `all` needs all three keys — speech-to-text, LLM and voice — added, verified and each with a model chosen; scope `voice` needs only the voice key, and speech-to-text, the LLM and telephony stay ours; scope `llm` needs only the LLM key, and speech-to-text and the voice stay ours (calls are billed on the normal voice plan, whatever your model); scope `llm_voice` needs the LLM key and the voice key, and our speech recogniser stays (a call is a flat ₹1/min like `all`). `scope` is the default for your agents: any agent may follow it, be switched off, or choose another scope your keys allow (Update Agent `byok`). Any plan may turn it on. On a developer's workspace, turning it off turns it off for every customer using your keys, and a customer inherits your scope. Needs a **full** key.
# Update BYOK Key Model
Source: https://docs.thinnest.ai/api-reference/bring-your-own-keys/update-byok-key-model
/api-reference/openapi.json patch /byok/credentials/{kind}
Changes the model a stored key runs without sending the key again. `model` must be on the key's `models`; for a model the provider launched after you added the key, send `refreshModels: true` — alone, or with the new `model`, which is then checked against the fresh list. While BYOK is on, a refreshed list that drops the chosen model is refused. Needs a **full** key.
# Cancel Call
Source: https://docs.thinnest.ai/api-reference/calls/cancel-call
/api-reference/openapi.json delete /calls/{id}
Calls off a call you placed through the API (`out_…` or `sch_…`). A call still waiting to ring — booked, queued for its hours, or a retry — is cancelled and answers `200` with its report, `status: "cancelled"`. A call ringing or connected is ended on the line and answers `202` with the report as it stands; its ending arrives a moment later with `call.analysed`, and no retry follows. A call that has already ended answers `409`. Needs a **full** key.
# Get Call
Source: https://docs.thinnest.ai/api-reference/calls/get-call
/api-reference/openapi.json get /calls/{id}
One call and everything it learned: status, timings, how it ended, the summary, the collected `fields`, the transcript, a signed `recording` link and the call's `quality` figures (reply times, silences, interruptions, model errors). It is exactly the object `call.analysed` delivers, so a missed delivery can be read back here; `analysedAt` null means the summary and fields are not final yet. Any id List Calls hands out works — `out_…` and `sch_…` for calls you placed, the call reference for any other.
# Get Call Transcript
Source: https://docs.thinnest.ai/api-reference/calls/get-call-transcript
/api-reference/openapi.json get /calls/{id}/transcript
What was said on one call, oldest first: this call's spoken turns only, internal notes left out — the same words `call.analysed` delivers. `?format=txt` returns a plain-text file to save (`call-{id}.txt`, one `[mm:ss] Who: what` line per turn). A turn's `offset` is when it was written down, not where it falls in the audio, so it suits ordering and rough position but not subtitles.
# List Calls
Source: https://docs.thinnest.ai/api-reference/calls/list-calls
/api-reference/openapi.json get /calls
Every call your workspace made or took — inbound, website, WhatsApp, a campaign's or one you placed — newest first, without transcripts or recordings. Each item's `id` opens the call with Get Call. A call booked for later appears here only once it has rung; read a booked call by the `sch_…` id Place Call gave you.
# Place Batch Calls
Source: https://docs.thinnest.ai/api-reference/calls/place-batch-calls
/api-reference/openapi.json post /calls/batch
Rings up to 200 people with one request. Every option Place Call takes may be set once for the batch and again on an entry, the entry winning (an entry's `variables` sit on top of the batch's); `purpose` and `agent` are the batch's. Every entry is queued and paced inside your plan's lines and balance — none rings inline — and each gets its own `sch_…` id that reports through `call.analysed` and Get Call. A bad entry is listed under `refused` and does not stop the rest. Needs a **full** key.
# Place Call
Source: https://docs.thinnest.ai/api-reference/calls/place-call
/api-reference/openapi.json post /calls
Your agent rings a customer, opens with your `purpose` (spoken aloud, so 300 characters or fewer), then holds a normal conversation. A number that is not a contact yet becomes one with the `name` and `source` you send; someone who opted out or is on your do-not-call list is refused. Outside the calling hours — never wider than 9am–9pm in the customer's own time — or with `scheduledAt`, the call is queued and answers `status: "scheduled"` with a `sch_…` id; otherwise it rings now with an `out_…` id. Each call is billed from your balance, refused with `402` when it cannot pay, held to your plan's concurrent lines and 60 requests a minute; what it learned arrives as `call.analysed` or from Get Call. Needs a **full** key.
# Cancel Campaign
Source: https://docs.thinnest.ai/api-reference/campaigns/cancel-campaign
/api-reference/openapi.json post /campaigns/{id}/cancel
Stops a draft, sending or paused campaign for good and sets `finishedAt`. Messages already handed to WhatsApp, and calls already placed, still complete. This cannot be undone, and it is also how you get rid of a draft you do not want: campaigns cannot be edited or deleted, so cancel it and create a new one. A **build** key may do this, since it only ever stops sends.
# Create Campaign
Source: https://docs.thinnest.ai/api-reference/campaigns/create-campaign
/api-reference/openapi.json post /campaigns
Creates a WhatsApp broadcast (`kind: "whatsapp"`) or a calling campaign (`kind: "voice"`) as a draft, with the same checks as the console's builder: WhatsApp connected and the template approved with every blank filled, or for calls an agent with a number that answers phone calls and is licensed for this kind of call. The audience is built from your contacts right away, by consent; people who asked not to be contacted are always left out. Send `launch: true` to start it at once, or `scheduledAt` to start it later; a launch that is refused (no paid plan, not enough balance) still leaves the draft and says why in `message`, and an audience of nobody is never launched. Needs a **full** key.
# Get Campaign
Source: https://docs.thinnest.ai/api-reference/campaigns/get-campaign
/api-reference/openapi.json get /campaigns/{id}
Returns one broadcast or calling campaign with its current state and counts. Poll it while a campaign is sending to follow its progress; `finishedAt` is set when it ends or is cancelled, and `lastError` says why a campaign stopped, when it did.
# Launch Campaign
Source: https://docs.thinnest.ai/api-reference/campaigns/launch-campaign
/api-reference/openapi.json post /campaigns/{id}/launch
Starts a draft, as the console's Send or Start button does, re-running its checks at the moment they matter: your plan must include campaigns, a calling campaign's agent must still have a number that answers phone calls (and, for promotional calls, is licensed for them), and your balance must cover the send. If the balance covers only part of it, the campaign starts and `message` says how far it goes; the rest stays queued until you top up. Messages go out in the background; calls are paced by your free call lines. A campaign already sending answers `200` and is left alone, and a scheduled draft launched here starts now instead of waiting. Needs a **full** key.
# List Campaigns
Source: https://docs.thinnest.ai/api-reference/campaigns/list-campaigns
/api-reference/openapi.json get /campaigns
Lists your workspace's broadcasts and calling campaigns, newest first, each with its live counts: recipients, sent, delivered, read, replied, failed and skipped. Filter by `status` to watch only the ones that are sending.
# Pause Campaign
Source: https://docs.thinnest.ai/api-reference/campaigns/pause-campaign
/api-reference/openapi.json post /campaigns/{id}/pause
Stops a sending campaign from sending any more. Messages already handed to WhatsApp, and calls already placed, still complete. Resume Campaign picks up where it stopped, without messaging anyone twice. Only a `sending` campaign can be paused. A **build** key may do this, since it only ever stops sends.
# Resume Campaign
Source: https://docs.thinnest.ai/api-reference/campaigns/resume-campaign
/api-reference/openapi.json post /campaigns/{id}/resume
Sends a paused campaign again, to everyone it has not reached yet; nobody is messaged or called twice. It clears `lastError`, and what it sends is charged as usual. Only a `paused` campaign can be resumed; a draft is started with Launch Campaign. Needs a **full** key.
# Connect Telegram Bot
Source: https://docs.thinnest.ai/api-reference/channels/connect-telegram-bot
/api-reference/openapi.json post /agents/{id}/channels/telegram
Connects a Telegram bot to the agent with the token @BotFather gave you: the token is checked with Telegram, stored encrypted and never shown again, and Telegram is told where to deliver messages. Telegram needs a paid plan, and a bot can belong to one agent only. Telegram calls back on the address this request arrived on, so call the public API address. A **build** key may do this.
# Disconnect Telegram Bot
Source: https://docs.thinnest.ai/api-reference/channels/disconnect-telegram-bot
/api-reference/openapi.json delete /agents/{id}/channels/telegram
Disconnects the agent's Telegram bot (a 404 when none is connected) and tells Telegram to stop delivering to us. Its conversations stay in your inbox, and the bot itself still exists in Telegram — delete it in @BotFather if you want it gone. A **build** key may do this.
# Get Agent Channels
Source: https://docs.thinnest.ai/api-reference/channels/get-agent-channels
/api-reference/openapi.json get /agents/{id}/channels
Where the agent answers, as the Channels page shows it: the widget key, the WhatsApp number and its health, the Telegram bot and the voice line. No token or secret is ever part of the answer. The widget's look and the call settings are changed with Update Agent (`widget`, `voice`).
# Add Contacts To Group
Source: https://docs.thinnest.ai/api-reference/contact-groups/add-contacts-to-group
/api-reference/openapi.json post /contact-groups/{id}/members
Adds up to 1,000 contacts to the group. Contacts already in it are left as they are (`added` counts only the new ones). Every id must be a contact in this workspace, or nothing is added and the answer is `404` naming them. A **build** key may do this.
# Create Contact Group
Source: https://docs.thinnest.ai/api-reference/contact-groups/create-contact-group
/api-reference/openapi.json post /contact-groups
Creates an empty group. Names are unique in the workspace whatever the capitals; a second one answers `409`. A **build** key may do this.
# Delete Contact Group
Source: https://docs.thinnest.ai/api-reference/contact-groups/delete-contact-group
/api-reference/openapi.json delete /contact-groups/{id}
Deletes the group and its memberships. Its contacts stay. A campaign not yet sent that was aimed at it no longer reaches anybody through it. A **build** key may do this.
# Get Contact Group
Source: https://docs.thinnest.ai/api-reference/contact-groups/get-contact-group
/api-reference/openapi.json get /contact-groups/{id}
One group and how many contacts are in it.
# List Contact Groups
Source: https://docs.thinnest.ai/api-reference/contact-groups/list-contact-groups
/api-reference/openapi.json get /contact-groups
Every group in the workspace, by name, each with how many contacts are in it. Returned whole: `nextCursor` is always null.
# Remove Contacts From Group
Source: https://docs.thinnest.ai/api-reference/contact-groups/remove-contacts-from-group
/api-reference/openapi.json delete /contact-groups/{id}/members
Takes up to 1,000 contacts out of the group. The contacts stay; ids not in the group are ignored (`removed` counts the ones taken out). A **build** key may do this.
# Rename Contact Group
Source: https://docs.thinnest.ai/api-reference/contact-groups/rename-contact-group
/api-reference/openapi.json patch /contact-groups/{id}
Renames a group. Campaigns aimed at it keep reaching it. A **build** key may do this.
# Create Contact
Source: https://docs.thinnest.ai/api-reference/contacts/create-contact
/api-reference/openapi.json post /contacts
Creates a contact — or, when someone is already on that number, **updates that person** instead, so a CRM that syncs the same lead twice never doubles your list. Answers `201` for a new contact and `200` for one it updated. Creating a contact is not consent: only `consent.status: "granted"` with a `source` is. A **build** key may do this.
# Delete Contact
Source: https://docs.thinnest.ai/api-reference/contacts/delete-contact
/api-reference/openapi.json delete /contacts/{id}
**Erases** the contact: their conversations and transcripts, their calls, any queued call and their campaign rows go with them, and so does any duplicate that was merged into them. This cannot be undone. Use it for a person's request to be forgotten; to stop messaging someone, set their consent to `withdrawn` instead. A **build** key may do this.
**Recordings are erased too.** The audio of their calls is deleted before the call rows go: a recording we store is deleted right away, the phone provider's copy of a call on a number we rent is deleted through your workspace's own account with them, and any copy made for model training is deleted. A file that cannot be deleted at that moment is queued and retried every night until it is gone; the contact is erased regardless, and the response says how many are still queued in `recordingsPending`.
**Knowing when it is finished.** Subscribe a webhook to `contact.erased`: it is sent once the erasure is complete — straight away when this answers 204, or from the nightly retry once the last queued recording is gone — with only the contact's id and when it was erased.
**What is not erased.** Do-not-call entries are kept deliberately, so the person is not called again. Webhook payloads we already sent are purged on their own schedule after 7 days. Copies in your own systems, and in your vendors', are yours to delete.
# Get Contact
Source: https://docs.thinnest.ai/api-reference/contacts/get-contact
/api-reference/openapi.json get /contacts/{id}
One contact, by id.
# List Contacts
Source: https://docs.thinnest.ai/api-reference/contacts/list-contacts
/api-reference/openapi.json get /contacts
Contacts with a phone number, newest first. Filter by tag, by your CRM's id, or by phone to look one person up. Contacts folded into another by a merge are left out.
# Update Contact
Source: https://docs.thinnest.ai/api-reference/contacts/update-contact
/api-reference/openapi.json patch /contacts/{id}
Changes any of the fields Create Contact takes, consent included; fields you leave out keep their values, and `null` clears a field. `tags` replaces the whole list. A contact whose consent is `withdrawn` is never sent marketing, enrolled in a sequence or put in a campaign. A **build** key may do this.
# Assign Conversation
Source: https://docs.thinnest.ai/api-reference/conversations/assign-conversation
/api-reference/openapi.json post /conversations/{id}/assign
Make a conversation a teammate's job, by their email — or `null` to put it back in the pool. The teammate is notified when it really moves to them. Assigning does **not** silence the agent; taking it over with Update Conversation Status does. List your teammates with `GET /members`. The shared inbox needs a paid plan. A **build** key may do this.
# List Conversation Messages
Source: https://docs.thinnest.ai/api-reference/conversations/list-conversation-messages
/api-reference/openapi.json get /conversations/{id}/messages
One conversation's messages, newest first: the customer's, your agent's, and replies a teammate sent from the inbox (`author: "teammate"`). Your team's internal notes are left out. Photos, voice notes, videos and documents a customer sent come back as links valid for **eight hours** — download what you need to keep.
# List Conversations
Source: https://docs.thinnest.ai/api-reference/conversations/list-conversations
/api-reference/openapi.json get /conversations
Your workspace's conversations, newest first. `since` keeps only those a customer has written in at or after that moment — the way to find what a missed `message.received` webhook would have told you, including a conversation whose very first message you never heard about. Go back a few minutes from the last event you handled, since messages that arrive together can be delivered out of order.
# Reply to Conversation
Source: https://docs.thinnest.ai/api-reference/conversations/reply-to-conversation
/api-reference/openapi.json post /conversations/{id}/reply
Your own agent platform answers a WhatsApp customer — the other half of the `message.received` webhook. Send exactly one of `text`, `media`, `template` or `form`; text, media and forms need the customer's 24-hour window open, a template does not. Only works on a number with **Connect External Agent/CRM** turned on, and not while a teammate has taken the conversation over. Each message is charged like any WhatsApp message from that number. Needs a **full** key.
# Update Conversation Status
Source: https://docs.thinnest.ai/api-reference/conversations/update-conversation-status
/api-reference/openapi.json post /conversations/{id}/status
The inbox's three moves: `resolved` marks it done and fires the `conversation.resolved` webhook, `active` hands it back to the agent, and `human_handling` takes it over so the agent stays quiet until it is handed back. An internal note records which API key made the change; the customer never sees it. `changed: false` means it was already in that state, and nothing was written or announced. The shared inbox needs a paid plan. A **build** key may do this.
# Customers
Source: https://docs.thinnest.ai/api-reference/customers
Build your own product on the API: a workspace for each of your customers, reached with one key.
A **customer** is a workspace you create for one of your own customers — with its own agents,
contacts, knowledge, phone numbers and WhatsApp. Your one API key reaches any of them by adding a
header. Everything they use is billed to you.
Your first customer makes your workspace a **developer workspace**. A workspace that resells our
console under its own brand (white label) cannot also have API customers.
## Create a customer
```http theme={null}
POST /api/v1/customers
Authorization: Bearer ta_live_…
Idempotency-Key: 6f1d2c9a-…
Content-Type: application/json
```
```json theme={null}
{
"name": "Acme Dental",
"externalId": "acme-42",
"metadata": { "tier": "gold", "seats": 3 },
"timezone": "Asia/Kolkata"
}
```
`201 Created`
```json theme={null}
{
"id": "org_1f2e…",
"name": "Acme Dental",
"externalId": "acme-42",
"metadata": { "tier": "gold", "seats": 3 },
"timezone": "Asia/Kolkata",
"allottedCallLines": null,
"archivedAt": null,
"eraseAfter": null,
"createdAt": "2026-10-06T10:00:00Z"
}
```
| Field | |
| - | - |
| `name` | Required. 1–120 characters. |
| `externalId` | Your own id for this customer: 1–128 letters, digits, `.` `_` `:` or `-`. Unique among your customers. |
| `metadata` | Up to 50 keys (1–40 characters each). Values are strings (up to 500 characters), numbers or booleans — no nested objects or lists. 8 KB in all. |
| `timezone` | An IANA time zone such as `Asia/Kolkata`. |
Send an `Idempotency-Key` so a retried request returns the same customer instead of making a
second one. Creating a customer needs a key with **full** access.
| Status | Why |
| - | - |
| `400` | A field is wrong — the message names it. |
| `402` | Your plan's customer limit is reached. |
| `409` | Another customer already has that `externalId`, or your workspace uses white label. |
## Work inside a customer
Add `Thinnest-Workspace` with the customer's `id` to **any** request. It runs inside that customer
as if the customer had sent it.
```http theme={null}
POST /api/v1/agents
Authorization: Bearer ta_live_…
Thinnest-Workspace: org_1f2e…
```
```json theme={null}
{ "name": "Front desk" }
```
| You send | You get |
| - | - |
| No header, or your own workspace | Your own workspace |
| One of your customers | That customer |
| Someone else's customer, or an id that does not exist | `404 Workspace not found.` |
| A customer you deleted | `410` — with when it was deleted and when it will be erased |
| The header from a workspace with no customers | `400` |
Your key's access level applies inside the customer too: a read-only key can only read there.
## List customers
```http theme={null}
GET /api/v1/customers?limit=50
```
Newest first. Pass `nextCursor` back as `cursor` for the next page. Filters: `externalId`,
`archived=true` (deleted, waiting to be erased), `createdAfter`, `createdBefore` (ISO 8601 times).
Manage customers from your own workspace — **without** `Thinnest-Workspace`.
## Get one customer
```http theme={null}
GET /api/v1/customers/org_1f2e…
```
The customer, plus:
```json theme={null}
{
"counts": { "agents": 2, "phoneNumbers": 1, "contacts": 418 },
"spendThisMonthMicro": 1840000
}
```
`spendThisMonthMicro` is what this customer's usage has cost you since the 1st of the month (UTC),
in millionths of your currency — `1840000` is ₹1.84.
## Update a customer
```http theme={null}
PATCH /api/v1/customers/org_1f2e…
```
```json theme={null}
{ "name": "Acme Dental Pvt", "metadata": { "tier": null, "region": "south" } }
```
You can change `name`, `externalId` (`null` clears it), `timezone`, `allottedCallLines` (how many of
your call lines this customer may use at once; `null` for no cap) and `metadata`. Metadata is
**merged**: keys you leave out are kept, and a key set to `null` is removed.
## Delete a customer
```http theme={null}
DELETE /api/v1/customers/org_1f2e…
```
Release the customer's phone numbers and disconnect its WhatsApp number first. Deleting is
refused (`409`, naming them) while it still holds either, because releasing a number cannot be
undone.
When you delete a customer:
* requests into it answer `410`, and keys made for it stop working;
* its webhooks are switched off, its campaigns cancelled, its sequences and scheduled callbacks stopped;
* a call already in progress finishes;
* **30 days later it is erased**: every conversation, contact, recording and file.
Until then you can bring it back:
```http theme={null}
POST /api/v1/customers/org_1f2e…/restore
```
Restoring does not restart campaigns or bring back old keys — start those again yourself.
## Keys for one customer
To give a customer its own API access, or to run one customer's integration on a key that cannot
reach the others:
```http theme={null}
POST /api/v1/customers/org_1f2e…/keys
```
```json theme={null}
{ "name": "Acme's dashboard", "scope": "read" }
```
The key (`ta_live_…`) is in the response **once**. `scope` is `full`, `build` or `read`.
| | |
| - | - |
| `GET /api/v1/customers/{id}/keys` | The customer's keys — name, prefix, access level, last used. Never the key itself. |
| `DELETE /api/v1/customers/{id}/keys/{keyId}` | Revoke it, immediately. |
A key made for one customer works only in that customer.
## Move a phone number between customers
A number one customer rents can be handed to another customer, or to your own workspace, with its
digits kept — for a customer that is splitting in two, or a number you rented for yourself while
setting a customer up.
In the console it is on the **Phone Numbers** page: open the number's menu and choose **Move to
another workspace…**. Through the API:
```bash theme={null}
curl -X POST https://app.thinnest.ai/api/v1/phone-numbers/918041234567/move \
-H "Authorization: Bearer $KEY" \
-H "Thinnest-Workspace: org_…from…" \
-H "Content-Type: application/json" \
-d '{ "toWorkspace": "org_…to…" }'
```
Send it as the workspace that holds the number (no header when that is your own). It answers with
the number, `from`, `to` and `paidThrough` — the last month already paid, after which the new
workspace's rent begins. The number arrives unassigned; point it with
`PATCH /api/v1/phone-numbers/{number}` as the new workspace. An Indian number needs the new
workspace's own business details approved first (`number_move_needs_business_details`), and nothing
moves during a live call (`number_on_live_call`). See
[Move Phone Number](/api-reference/phone-numbers/move-phone-number) for every refusal.
## Billing
Everything your customers use — calls, messages, replies, phone number rental — is charged to
**your** balance at your plan's prices. Your customers have no balance of their own. When your
balance runs out, your customers stop with you until you top up.
A customer is always on your plan. Your plan decides how many customers you can have:
| Plan | Customers |
| - | - |
| Free and Pay as you go | 3 |
| Pro | 100 |
| Scale | 1,000 |
| Enterprise | 10,000 |
Need more? Write to us. A deleted customer counts until it is erased.
On the Free plan, the included replies, voice minutes and WhatsApp messages are **shared** by you and
all your customers — one trial, not one each.
### What a customer can see
Your balance stays yours. Inside a customer, `GET /api/v1/usage` returns `"billedTo": "developer"` and
no `wallet`, and its console shows no balance and nothing to pay — so a key you give a customer, or a
person you invite into it, never sees what you hold.
### Your cloned voices
Every voice you clone can be used by all your customers' agents, automatically: inside a customer,
[`GET /voices`](/api-reference/voices/list-voices) lists it with `"sharedFromDeveloper": true`. Only you
can rename or delete it, and it counts against your clone limit. A customer's own clones are its own;
you do not see them. More on the [Voice Clone](/channels/voice-clone#your-customers-can-use-your-voices) page.
### How long a customer's calls are kept
Set `callRetentionDays` on a customer with
[`PATCH /workspace`](/api-reference/workspace/update-workspace) and the `Thinnest-Workspace` header,
the same as any other setting of theirs. See [How long calls are kept](/workspace/data-and-erasure#how-long-calls-are-kept).
### Notifications
Your customers have no members of their own, so anything we would tell them — a webhook switched off,
a template rejected, a campaign in trouble — comes to **you**, with the customer's name in the title,
and is emailed according to your own notification settings. Its **Open** link takes you into that
customer's workspace, to the template or campaign it is about. So does a low-balance warning caused by
your customers' usage.
### Currency
You and your customers use one currency, fixed once you have a customer. If you have not chosen one
when you create your first customer, it is set to rupees — pick dollars before then if you bill in
dollars.
# Create Customer
Source: https://docs.thinnest.ai/api-reference/customers/create-customer
/api-reference/openapi.json post /customers
Creates a workspace for one of your customers and answers with its `id` — send that id as `Thinnest-Workspace` to act inside it. Your first customer makes your workspace a developer workspace; everything a customer uses (calls, messages, replies, number rental) is charged to your balance at your plan's prices, and your plan caps how many customers you may have (3 on Free and pay-as-you-go, 100 on Pro, 1,000 on Scale, 10,000 on Enterprise; a deleted customer counts until it is erased). Send an `Idempotency-Key` so a retry never makes a second workspace. Needs a **full** key.
# Create Customer Key
Source: https://docs.thinnest.ai/api-reference/customers/create-customer-key
/api-reference/openapi.json post /customers/{id}/keys
Mints an API key that works in this one customer only — to give your customer its own API access, or to run one customer's integration on a key that cannot reach the others. The full key (`ta_live_…`) is in this response **once**; store it, because it is never shown again. Its usage is still charged to you. Needs a **full** key.
# Delete Customer
Source: https://docs.thinnest.ai/api-reference/customers/delete-customer
/api-reference/openapi.json delete /customers/{id}
Deletes a customer: requests into it answer `410` from now on, every key made for it is revoked, its webhooks are switched off, its campaigns cancelled and its sequences and scheduled callbacks stopped (a call already in progress finishes). **30 days later it is erased** — every conversation, contact, recording and file — unless you restore it first. Refused while the customer still holds a rented phone number or a connected WhatsApp number: release those yourself first, because that cannot be undone. Deleting an already-deleted customer answers it as it is. Needs a **full** key.
# Get Customer
Source: https://docs.thinnest.ai/api-reference/customers/get-customer
/api-reference/openapi.json get /customers/{id}
One customer, with how many agents, active phone numbers and contacts it has and what its usage has cost you since the 1st of this month (UTC). Deleted customers waiting to be erased are found too.
# List Customer Keys
Source: https://docs.thinnest.ai/api-reference/customers/list-customer-keys
/api-reference/openapi.json get /customers/{id}/keys
Every API key made for this customer, newest first, revoked ones included: name, prefix, access level and when it was last used. Never the key itself. Not paged. A deleted customer answers `404`.
# List Customers
Source: https://docs.thinnest.ai/api-reference/customers/list-customers
/api-reference/openapi.json get /customers
Your customer workspaces, newest first. Deleted customers waiting to be erased are left out unless you ask for them with `archived=true`. Call it from your own workspace: with the `Thinnest-Workspace` header it is refused, because a customer has no customers of its own.
# Restore Customer
Source: https://docs.thinnest.ai/api-reference/customers/restore-customer
/api-reference/openapi.json post /customers/{id}/restore
Brings back a deleted customer before its erase date, with everything it held. It does not restart campaigns or revive the keys that were revoked when it was deleted: start those again and mint new keys. Restoring a customer that was never deleted answers it unchanged. Needs a **full** key.
# Revoke Customer Key
Source: https://docs.thinnest.ai/api-reference/customers/revoke-customer-key
/api-reference/openapi.json delete /customers/{id}/keys/{keyId}
Revokes one of a customer's keys, immediately: its next request answers `401`. Answers with the key as it now is. A key already revoked is `404`. Needs a **full** key.
# Update Customer
Source: https://docs.thinnest.ai/api-reference/customers/update-customer
/api-reference/openapi.json patch /customers/{id}
Changes a customer's `name`, `externalId`, `timezone`, `allottedCallLines` or `metadata`; fields you leave out are kept. `metadata` is merged, not replaced: keys you do not mention stay, and a key set to `null` is removed. A deleted customer must be restored before it can be changed. Needs a **full** key.
# Add Do-Not-Call Number
Source: https://docs.thinnest.ai/api-reference/do-not-call/add-do-not-call-number
/api-reference/openapi.json post /do-not-call
Puts a number on the list; from then on no outbound call rings it. Adding a number already there changes nothing and answers `200` with the entry as it was. A **build** key may do this — it only ever stops calls.
# Get Do-Not-Call Number
Source: https://docs.thinnest.ai/api-reference/do-not-call/get-do-not-call-number
/api-reference/openapi.json get /do-not-call/{phone}
Whether a number is on the list, and how it got there.
# List Do-Not-Call Numbers
Source: https://docs.thinnest.ai/api-reference/do-not-call/list-do-not-call-numbers
/api-reference/openapi.json get /do-not-call
The workspace's do-not-call list, newest first.
# Remove Do-Not-Call Number
Source: https://docs.thinnest.ai/api-reference/do-not-call/remove-do-not-call-number
/api-reference/openapi.json delete /do-not-call/{phone}
Takes a number off the list, so it can be called again. Needs a **full** key. If the person asked on a call not to be called (`source: call`), be sure they have since agreed before removing it.
# Errors
Source: https://docs.thinnest.ai/api-reference/errors
The status, the machine-readable code that tells refusals apart, and whether retrying will help.
Every error answers a JSON body with a sentence for a person and a code for a program:
```json theme={null}
{
"error": "That customer has asked not to be contacted.",
"code": "opted_out"
}
```
## Branch on `code`, never on the sentence
The status says what kind of problem it is. The **`code`** says exactly which. A `403` on
`POST /calls` can mean the customer opted out, the number is on your do-not-call list, or the key
is not a full key; those are three different codes, and what you do about each is different (an
opted-out customer is never rung again, a do-not-call number can be taken off the list, a key can
be swapped for a full one).
The sentence in `error` is for showing to a person and **may be reworded in any release**. A
`code` does not change; new ones are only ever added. So match on `code`, and treat a code you do
not recognise as the generic one for its status.
```js theme={null}
const res = await fetch("https://app.thinnest.ai/api/v1/calls", { method: "POST", headers, body });
if (res.status === 403) {
const { code } = await res.json();
if (code === "opted_out" || code === "do_not_call") markUncallable(lead);
else if (code === "key_scope_build" || code === "key_scope_read") alertOps("use a full key for calls");
}
```
### Which limit a `429` hit
A `429` names the limit in `code` and sizes it in a `limit` object, next to the `Retry-After`
header (seconds to wait):
```json theme={null}
{
"error": "Over 60 calls a minute. Slow down and retry.",
"code": "rate_limit_calls",
"limit": { "name": "calls", "perMinute": 60 }
}
```
`limit.perMinute` is set for the limits counted per minute; `limit.max` (and `limit.windowSeconds`
when it has a window) for the others, such as `concurrent_calls` and the codes one number may be
sent.
### A `402` on `POST /calls`
`code` is `insufficient_balance`. The existing `reason` field says which limit stopped the call:
`no balance`, `trial spent`, `overdrawn`, `too many calls`, `balance unknown` or `retry buffer`.
## Statuses
| Status | Means | What to do |
| - | - | - |
| `400` | Malformed request, or a template blank is missing | Fix and resend. Retrying unchanged fails identically |
| `401` | Key missing, wrong or revoked | Check the header. **Do not retry**. The answer is the same whichever it is, so a probe learns nothing about your keys |
| `402` | Money or the plan: the balance cannot pay, or the plan does not include it | Top up or upgrade. `code` says which |
| `403` | The key's level, or a refusal about the person or number | `code` says which. Retrying unchanged fails the same way |
| `404` | No template of that name on your account | Check the name. Names are per account, so another business's template is invisible to you |
| `409` | Template not approved, WhatsApp not connected, contact opted out, or Meta has restricted the account | Look at **Templates** or **Channels**. Retrying makes a restriction worse |
| `409` | The same idempotency key is still in flight | Retry in a moment; the first request will have answered by then |
| `409` | Deleting an agent that is on a live call (`agent_on_live_call`: "The agent is on a live call. Delete it after the call ends.") | Nothing was deleted. Delete it after the call ends. In the console the same refusal reads "This agent is on a call right now. Delete it once the call ends." |
| `429` | Rate limited | Wait for `Retry-After` |
| `503` | WhatsApp is temporarily unavailable | Ours to fix, not yours. Safe to retry with the same idempotency key |
## Every code
Generated from the same list the API uses, so it cannot drift from it. A status with no more
specific code answers its generic one: `validation_failed` (400), `unauthorized` (401),
`payment_required` (402), `forbidden` (403), `not_found` (404), `conflict` (409), `gone` (410),
`unprocessable` (422), `rate_limited` (429), `internal_error` (500) and `service_unavailable`
(502, 503, 504).
**The key and the workspace it acts in**
| `code` | Means |
| - | - |
| `unauthorized` | The key is missing, wrong or revoked. The answer is the same whichever it is. |
| `key_scope_read` | The key is read-only and the request changes something. Use a build or full key. |
| `key_scope_build` | The key is a build key and the request needs a full key: messaging customers, sending codes, placing calls, spending money or changing who has access. |
| `workspace_not_found` | The `Thinnest-Workspace` header names a workspace that is unknown, is not one of your customers, or is not an `org_` id. |
| `workspace_deleted` | The customer named in `Thinnest-Workspace` was deleted. Restore it before its erase date. |
| `workspace_not_developer` | `Thinnest-Workspace` was sent from a workspace that is not a developer workspace. |
| `workspace_header_forbidden` | A key that belongs to one customer workspace cannot act as another. |
**Limits: which one was hit (a `limit` object names it)**
| `code` | Means |
| - | - |
| `rate_limit_resources` | Over the per-minute limit on ordinary API requests for this workspace. Wait for `Retry-After`. |
| `rate_limit_family` | Over the per-minute limit across all of a developer's customers. Wait for `Retry-After`. |
| `rate_limit_calls` | Over the per-minute limit on `POST /calls`. Wait for `Retry-After`. |
| `rate_limit_messages` | Over the per-minute limit on `POST /messages`. Wait for `Retry-After`. |
| `rate_limit_codes` | Over the per-minute limit on `POST /codes`. Wait for `Retry-After`. |
| `rate_limit_code_recipient` | This number has already been sent the most codes allowed in the window. Wait for `Retry-After`. |
| `concurrent_calls` | Every line the plan allows is already on a call. Retry when one finishes. |
| `number_daily_limit` | The `from` number you chose has placed its calls for today. Use another number. |
| `rate_limited` | Too many requests of another kind. Wait for `Retry-After` when it is given. |
**Placing a call**
| `code` | Means |
| - | - |
| `opted_out` | The customer asked not to be contacted. Nothing you change in the request makes it allowed. |
| `do_not_call` | The number is on the workspace's do-not-call list. Remove it from the list to call it. |
| `stop_list_unknown` | The opt-out and do-not-call lists could not be read, so the call was not placed. Safe to retry shortly. |
| `insufficient_balance` | The balance cannot pay for a call. `reason` says which limit stopped it: `no balance`, `trial spent`, `overdrawn`, `too many calls`, `balance unknown` or `retry buffer`. |
| `outside_calling_hours` | It is outside the calling hours and the request said `ifOutsideHours: refuse`. `nextOpening` says when. |
| `number_never_callable` | The calling hours never open for this number on the days given, in its own time zone. |
| `call_in_progress` | The person is on a call right now, with this agent or another in the workspace. |
| `call_already_scheduled` | A call to this number is already scheduled on this agent. Its `id` and `scheduledFor` come back. |
| `calling_not_set_up` | The workspace has no agent that answers the phone, or its phone provider is not connected. |
| `carrier_unavailable` | The phone provider would not place the call. 503 is worth retrying; 502 is this number. |
| `agent_not_found` | No agent of that name or id answers the phone in this workspace. |
| `agent_ambiguous` | More than one agent answers the phone. Name one in `agent`. |
| `from_number_invalid` | `from` is not a number this agent can call from. |
**Agents**
| `code` | Means |
| - | - |
| `signing_unavailable` | Requests cannot be signed right now, so a setting that needs a signing secret (the who-is-calling lookup) was not saved. Try again later. |
| `agent_on_live_call` | The agent is on a live call, and deleting it would lose that call's recording, transcript and usage. Delete it after the call ends. |
| `phone_number_required` | `voice.surfaces` includes `phone`, but the agent has no phone number to answer or call from. Attach one first. |
**Numbers**
| `code` | Means |
| - | - |
| `invalid_number` | The phone number could not be read. |
| `number_required` | Connect a WhatsApp number first; this needs one. |
| `number_claimed_elsewhere` | That number is already registered here. Get in touch if it is yours. |
| `needs_own_carrier_keys` | The number is on your own carrier account; add that account's API credentials to place calls from it. |
| `number_cannot_forward` | Calls to this number cannot be forwarded: its phone provider does not support it here. |
| `forward_destination_not_allowed` | `forwardTo` is a premium-rate, shared-cost, toll-free, satellite or high-cost international number, which a forward cannot go to. |
| `forward_loop` | `forwardTo` is this number or another number on the platform, which would forward calls in a loop. |
| `number_move_needs_developer` | Moving a number between workspaces needs a developer's key, from its own workspace or acting as one of its customers. |
| `number_move_same_workspace` | `toWorkspace` is the workspace the number is already in. |
| `number_on_live_call` | The number is on a call right now. Move it once the call ends. |
| `number_move_needs_business_details` | An Indian number is held in the name of the business using it: the target workspace's business details must be approved first. |
| `number_move_carrier_account` | A number on your own carrier account moves only when both workspaces have that same account's keys saved. |
| `number_move_in_progress` | That number is already being moved (another request, or a retry still in flight). Wait a few minutes and read it again. |
| `number_move_legacy_number` | This number was set up before workspace-level phone accounts existed and cannot be moved here. Contact support to move it. |
| `number_move_unavailable` | The phone network did not take the move. The number is still where it was, answered by no agent; safe to retry shortly. |
**Messages and codes**
| `code` | Means |
| - | - |
| `template_not_found` | No template of that name in this workspace. |
| `template_not_approved` | The template is not approved yet. |
| `template_language_missing` | The template is not approved in that language. `available` lists the ones it is. |
| `template_unsendable` | The template carries something no send can fill. Edit the template. |
| `code_template_not_found` | There is no approved one-time-code template matching the request. |
| `code_template_ambiguous` | More than one code template is approved; name one with `template` or `language`. |
| `whatsapp_not_connected` | The workspace has no connected WhatsApp number. |
| `whatsapp_blocked` | The WhatsApp account is restricted. Retrying makes a restriction worse. |
| `whatsapp_not_configured` | WhatsApp is not available right now. |
| `free_allowance_used` | The free plan's monthly WhatsApp allowance is used. Pay as you go lifts it. |
| `send_failed` | The message provider would not take the message. Safe to retry with the same idempotency key. |
**Plan and money**
| `code` | Means |
| - | - |
| `plan_required` | The plan does not include this. Upgrade to use it. |
| `plan_limit` | The plan's allowance of this is used (for example customers). Upgrade, or remove one. |
| `payment_required` | Another 402: money or a plan upgrade is needed. Read `error`. |
**Idempotency**
| `code` | Means |
| - | - |
| `idempotency_in_progress` | That `Idempotency-Key` is still being processed. Retry in a moment for its answer. |
**The generic code for a status that has no specific one**
| `code` | Means |
| - | - |
| `validation_failed` | The request was malformed or a value is out of range. Retrying unchanged fails the same way. |
| `forbidden` | Not allowed, for a reason with no more specific code. |
| `not_found` | No such resource in this workspace. |
| `conflict` | The request clashes with the resource's current state. |
| `gone` | It existed and has been deleted or has expired. |
| `unprocessable` | The request is well formed but cannot be acted on in the state things are in. |
| `internal_error` | Something failed on our side. Safe to retry; if it persists, tell us. |
| `service_unavailable` | A dependency is unavailable. Safe to retry shortly. |
## On 409 in particular
When Meta restricts an account, every refused request is a signal that the
business has not noticed. **A retry loop against a restriction extends it.**
The console shows the reason and when it lifts. Go and look rather than
retrying.
`409` also covers the correct-behaviour cases: a **marketing** template
addressed to somebody who opted out is refused. That is not a bug to work
around.
## Retry policy that will not hurt you
```js theme={null}
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
async function send(payload, key, attempt = 0) {
const res = await post(payload, key);
if (res.ok) return res.json();
if (!RETRYABLE.has(res.status) || attempt >= 4) {
// 400, 401, 404 and 409 never succeed on a retry. Surface them.
throw new Error(`${res.status}: ${await res.text()}`);
}
const after = Number(res.headers.get("Retry-After") ?? 0);
const backoff = after > 0 ? after * 1000 : 2 ** attempt * 1000;
await sleep(backoff);
// Same idempotency key on every attempt — that is what makes this safe.
return send(payload, key, attempt + 1);
}
```
The important line is the last one: **the same idempotency key on every
attempt**. Generating a fresh key per retry turns a safe retry into a duplicate
message.
Keys are honoured for 24 hours, so a backoff that runs for minutes is well
inside the window. A job parked overnight and released the next morning is not —
it will send.
# Report Event
Source: https://docs.thinnest.ai/api-reference/events/report-event
/api-reference/openapi.json post /events
Tells us something happened to a customer. Every sequence or tag rule subscribed to that event name runs: sequences that stop on it end first, then tags are added or removed, then sequences that start on it enrol the contact (unless they opted out, and only into an active sequence). The contact is created if the number is new. Nothing subscribed is still a `200`, with `matched: false` — you can send events before anyone has set up what they do. Only send events for people who agreed to hear from you: an event that starts a sequence is taken as their opt-in. Needs a **full** key.
# Create Form
Source: https://docs.thinnest.ai/api-reference/forms/create-form
/api-reference/openapi.json post /forms
Saves a form, **unpublished** — it cannot be sent until Publish Form succeeds. Up to 8 questions; field names are what each answer comes back under. Without `agent`, the form goes to the agent holding your WhatsApp number, else your oldest agent. No connected number is needed to build one. A **build** key may do this.
# Delete Form
Source: https://docs.thinnest.ai/api-reference/forms/delete-form
/api-reference/openapi.json delete /forms/{id}
Deletes the form; it can no longer be sent. Answers already collected stay, marked as from a removed form. A **build** key may do this.
# Get Form
Source: https://docs.thinnest.ai/api-reference/forms/get-form
/api-reference/openapi.json get /forms/{id}
One form, with its questions and whether it can be sent.
# List Forms
Source: https://docs.thinnest.ai/api-reference/forms/list-forms
/api-reference/openapi.json get /forms
Your workspace's WhatsApp forms, newest first, published or not.
# Publish Form
Source: https://docs.thinnest.ai/api-reference/forms/publish-form
/api-reference/openapi.json post /forms/{id}/publish
Publishes the form to your workspace's **own** WhatsApp Business Account, which makes it sendable. Needs a connected WhatsApp number whose connection was finished. If WhatsApp refuses (most often because the business is not verified yet), the form stays saved and you can publish again later; a failed republish leaves the live version on the air. Needs a **full** key.
# Update Form
Source: https://docs.thinnest.ai/api-reference/forms/update-form
/api-reference/openapi.json patch /forms/{id}
Changes any of `name`, `category` and `form` (`form` is replaced whole). **Any** change unpublishes the form, as saving does in the console — the published copy still asks the old questions — so publish it again before sending it. A **build** key may do this.
# API reference
Source: https://docs.thinnest.ai/api-reference/introduction
The transactional API — the messages your systems send, not the ones your marketing does.
This API exists for the messages a **system** sends: an order confirmation, a
delivery update, a one-time password. One customer, one event, right now.
It is not the campaigns API, and the difference is not cosmetic.
| | Broadcasts | This API |
| - | - | - |
| Triggered by | A person, in the console | Your code, on an event |
| Audience | A list computed from consent | One customer |
| Typical category | Marketing | Utility or authentication |
| Reaches an opted-out contact | No | Yes, for utility and authentication |
If you find yourself looping this endpoint over a list of customers, you want
[broadcasts](/whatsapp/broadcasts) instead.
## Base URL
```
https://app.thinnest.ai/api/v1
```
## Endpoints
| Method | Path | What |
| - | - | - |
| `POST` | `/messages` | [Send a template to one customer](/api-reference/messages/send-message) |
| `POST` | `/codes` | [Send a one-time code](/api-reference/one-time-codes/send-one-time-code) |
| `POST` | `/calls` | [Ring one customer](/api-reference/calls/place-call), and get the results back |
| `GET` | `/calls/{id}` | [A call's results](/api-reference/calls/get-call) — summary, fields, transcript, recording |
| `DELETE` | `/calls/{id}` | [Cancel a waiting call, or end a live one](/api-reference/calls/get-call) |
| `POST` | `/calls/batch` | [Ring up to 200 people](/api-reference/calls/place-batch-calls) |
| `GET` | `/calls` | [List calls](/api-reference/calls/list-calls) |
| `…` | `/agents`, `/agents/{id}` | [Agents](/api-reference/agents/create-agent) — create, read, update, delete |
| `…` | `/agents/{id}/knowledge` | [What an agent knows](/api-reference/knowledge/add-knowledge-document) |
| `…` | `/agents/{id}/actions` | [Actions](/api-reference/actions/create-action) — let the agent call your own API |
| `POST` | `/agents/{id}/test-chat` | [Talk to your own agent](/api-reference/agents/test-chat) and read its reply |
| `GET` | `/templates`, `/sequences` | [The template names and sequence ids](/api-reference/templates/list-templates) the sending endpoints take |
| `…` | `/campaigns`, `/campaigns/{id}` | [Campaigns](/api-reference/campaigns/create-campaign) — results, and pause, resume, cancel |
| `…` | `/conversations/{id}/status`, `/assign`, `/members` | [Resolve, hand back, take over and assign](/api-reference/conversations/list-conversations) |
| `GET` | `/usage`, `/agents/{id}/analytics`, `/calls/summary`, `/whatsapp/summary` | [Usage and analytics](/api-reference/usage/get-usage) — the numbers to build a dashboard on |
| `GET` | `/voices`, `/models`, `/phone-numbers` | [The lists an agent is configured from](/api-reference/voices/list-voices) |
| `…` | `/contacts`, `/contacts/{id}` | [Contacts](/api-reference/contacts/create-contact) |
| `…` | `/webhooks`, `/webhooks/{id}` | [Webhook endpoints](/api-reference/webhooks) |
| `POST` | `/events` | Report an event, which can enrol a contact in a [sequence](/whatsapp/sequences) |
| `POST` | `/sequences/{sequenceId}/enrolments` | Enrol a contact directly |
Every id is prefixed by what it is — `ag_`, `act_`, `cmp_`, `cust_`, `doc_`, `wh_` — and every list
takes `limit` (up to 100) and `cursor`, answering `{ "items": […],
"nextCursor": "…" | null }`. Resource endpoints share a limit of 240 requests a
minute per workspace.
## What it will not do
* **Send an unapproved template.** Meta refuses it, so we refuse it earlier and
with a clearer message.
* **Send marketing to somebody who opted out.** Refused with `409`. Utility and
authentication still reach them, and must.
* **Send free-form text.** Outside the 24-hour window WhatsApp carries only
approved templates. Inside it, the conversation belongs to the agent and the
inbox.
## Start here
Creating a key, and where it must not go.
The one endpoint most integrations need.
# Create Invitation
Source: https://docs.thinnest.ai/api-reference/invitations/create-invitation
/api-reference/openapi.json post /invitations
Invites somebody to the workspace and emails them a link that works for 14 days (the email goes out just after the answer). Inviting an address that already has a pending invitation sends it again with a fresh 14 days and its new role, and answers `200` instead of `201`. Every seat counts — the people in the workspace plus pending invitations. Addresses at reserved test domains (`example.com`, `.test`, …) get the invitation but no email. Needs a **full** key.
# Get Invitation
Source: https://docs.thinnest.ai/api-reference/invitations/get-invitation
/api-reference/openapi.json get /invitations/{id}
One invitation, whatever its status — pending, accepted or revoked.
# List Invitations
Source: https://docs.thinnest.ai/api-reference/invitations/list-invitations
/api-reference/openapi.json get /invitations
Pending invitations, newest first. The link in each is only ever in the email, never here.
# Revoke Invitation
Source: https://docs.thinnest.ai/api-reference/invitations/revoke-invitation
/api-reference/openapi.json delete /invitations/{id}
Revokes a pending invitation: its link stops working and its seat is freed. An invitation that was accepted or already revoked is `404`. Needs a **full** key.
# Add Knowledge Document
Source: https://docs.thinnest.ai/api-reference/knowledge/add-knowledge-document
/api-reference/openapi.json post /agents/{id}/knowledge
Adds a web page (`url`) or pasted `text` to what the agent knows. A page is fetched and indexed before this answers, so a slow site is a slow request; `passages` says how much was indexed. A page that cannot be read is answered `422` with the reason and the `failed` document it left behind, which you can delete. Files are uploaded in the console. A **build** key may do this.
# Delete Knowledge Document
Source: https://docs.thinnest.ai/api-reference/knowledge/delete-knowledge-document
/api-reference/openapi.json delete /agents/{id}/knowledge/{documentId}
Removes the document and every passage indexed from it; the agent stops citing it at once. A **build** key may do this.
# List Knowledge Documents
Source: https://docs.thinnest.ai/api-reference/knowledge/list-knowledge-documents
/api-reference/openapi.json get /agents/{id}/knowledge
The agent's documents, newest first, with whether each is ready to be cited. Files uploaded in the console are listed here too.
# Get Member
Source: https://docs.thinnest.ai/api-reference/members/get-member
/api-reference/openapi.json get /members/{id}
One person in the workspace, with their role.
# List Members
Source: https://docs.thinnest.ai/api-reference/members/list-members
/api-reference/openapi.json get /members
Everybody in the workspace, with their role — so a conversation can be assigned by email, or a role changed or a person removed by `id`. Not paged: every member is in one answer, and `nextCursor` is always null. Pending invitations are listed by List Invitations.
# Remove Member
Source: https://docs.thinnest.ai/api-reference/members/remove-member
/api-reference/openapi.json delete /members/{id}
Removes a person from the workspace. Conversations assigned to them go back to the unassigned pool. The workspace's last owner cannot be removed. Needs a **full** key.
# Update Member
Source: https://docs.thinnest.ai/api-reference/members/update-member
/api-reference/openapi.json patch /members/{id}
Changes a member's role to `admin` or `member`. A key cannot make anybody an owner — do that in the console, signed in as one — and the workspace's last owner cannot be demoted. Needs a **full** key.
# Send Message
Source: https://docs.thinnest.ai/api-reference/messages/send-message
/api-reference/openapi.json post /messages
Sends one approved template to one number and records it in that customer's conversation. Marketing templates are refused for a customer who opted out; utility and authentication templates are not, since an order confirmation is not something a customer opts out of. Each send is charged like any WhatsApp template from your number, and the Free plan stops at its included messages. At most 60 sends a minute per workspace; send an `Idempotency-Key` so a retry never sends twice. Needs a **full** key.
# List Models
Source: https://docs.thinnest.ai/api-reference/models/list-models
/api-reference/openapi.json get /models
Every model an agent may answer with, by the name the console shows. `available` says whether your plan may pick it — the rest are listed so you can see what an upgrade adds — and `voice` whether it is quick enough to answer a call (an agent's model must be). On a workspace that brings its own keys there is also `byok`: your LLM provider, your key's own model and every model the key reaches, which is what agents run on while your own keys are on. `voiceOnlyByok` marks the models a call may run on while you bring only your own voice. `callTier` and `callPricePerMinuteMicro` are the per-minute call price the console shows beside each model, for your workspace (plan discount included), in `currency` micro-units: a model marked `premium` bills every call at the premium rate even with a standard voice; a premium or studio voice can raise a call's price, never lower it. These cover the model only, with a standard voice. On a workspace using its own keys, a call bills at the BYOK per-minute rate whatever the model (all three keys, or voice key only — see Bring your own keys), so these prices apply only to an agent whose `byok` is `"off"`. Typical latency is not published.
# Get Notification Preferences
Source: https://docs.thinnest.ai/api-reference/notifications/get-notification-preferences
/api-reference/openapi.json get /notification-preferences
Every notification category, with whether it is muted and what of it is emailed. A category never changed is shown, not muted and not emailed. Not paged: `nextCursor` is always null.
# Update Notification Preferences
Source: https://docs.thinnest.ai/api-reference/notifications/update-notification-preferences
/api-reference/openapi.json patch /notification-preferences
Changes any categories at once, keyed by category: `muted` hides it from the feed and the bell (a critical notice shows regardless), `email` chooses what is emailed. A field you leave out keeps its value. Emails go only through the email sender connected in the console's notification settings; until one is connected nothing is emailed, whatever this says, and that connection is not available by API. Answers with every category. A **build** key may do this.
# Create Code Template
Source: https://docs.thinnest.ai/api-reference/one-time-codes/create-code-template
/api-reference/openapi.json post /codes/templates
Saves an authentication template as a **draft**. WhatsApp writes the words; you choose how long the code lasts, whether to add "For your security, do not share this code.", and the button — `copy_code` (works everywhere) or `one_tap` (hands the code to your Android app, which needs `apps`). Nothing is sent to WhatsApp until Submit Code Template. A **build** key may do this.
# Delete Code Template
Source: https://docs.thinnest.ai/api-reference/one-time-codes/delete-code-template
/api-reference/openapi.json delete /codes/templates/{id}
Deletes the template — at WhatsApp first when it was submitted, then here. WhatsApp keeps a deleted template's name reserved for 30 days, so a replacement needs a different name. Codes stop sending at once if this was your only approved one. A **build** key may do this.
# Get Code Template
Source: https://docs.thinnest.ai/api-reference/one-time-codes/get-code-template
/api-reference/openapi.json get /codes/templates/{id}
One authentication template, with its review status and WhatsApp's reason when it was rejected.
# List Code Templates
Source: https://docs.thinnest.ai/api-reference/one-time-codes/list-code-templates
/api-reference/openapi.json get /codes/templates
Your workspace's authentication templates — drafts, those in review and approved ones — newest first.
# Send One-Time Code
Source: https://docs.thinnest.ai/api-reference/one-time-codes/send-one-time-code
/api-reference/openapi.json post /codes
Sends a code you generated to a WhatsApp number, using your approved authentication template — the code goes into the message **and** the copy-code button. We do not generate, store, expire or check the code; that stays yours. One number may be sent at most 5 codes in 10 minutes, and the workspace at most 120 sends a minute (codes keep the top of the budget it shares with Send Message). Each code is charged at WhatsApp's authentication rate, which is far higher for numbers abroad. Needs a **full** key.
# Submit Code Template
Source: https://docs.thinnest.ai/api-reference/one-time-codes/submit-code-template
/api-reference/openapi.json post /codes/templates/{id}/submit
Sends a draft to WhatsApp for review, on your workspace's own WhatsApp Business Account — so a connected number is needed first. The status moves to `pending` only when WhatsApp took it. Review usually takes minutes; a `template.*` webhook and Get Code Template tell you how it went. A **build** key may do this.
# Update Code Template
Source: https://docs.thinnest.ai/api-reference/one-time-codes/update-code-template
/api-reference/openapi.json patch /codes/templates/{id}
Changes any of a **draft**'s fields; the ones you leave out keep their values. Once a template has been submitted, WhatsApp has the text and it can no longer be edited — make a new one instead. A **build** key may do this.
# Get Business Details
Source: https://docs.thinnest.ai/api-reference/phone-numbers/get-business-details
/api-reference/openapi.json get /phone-numbers/business-details
Where this workspace's business details stand. India issues a number only to a business it has approved, in the business's own name, so there is one application per workspace — a platform with a workspace per customer sends and reads each customer's own (with the `Thinnest-Workspace` header). Returns `{ "status": "none" }` until details are sent. `canRent` is true once the details are approved (`accepted`) — the point from which an Indian number can be rented. `reviewNote` appears when the application was `rejected` and says what to correct. `businessName` is null if the application's name could not be read just now. The document itself is never returned. Any key can read this; the status is checked live, so it is current. Checks usually finish in minutes.
# Get Phone Number
Source: https://docs.thinnest.ai/api-reference/phone-numbers/get-phone-number
/api-reference/openapi.json get /phone-numbers/{number}
One number this workspace holds: who answers it, who calls out on it, and its name. A number held by another workspace is `404`, the same as one that does not exist.
# Import Phone Number
Source: https://docs.thinnest.ai/api-reference/phone-numbers/import-phone-number
/api-reference/openapi.json post /phone-numbers/import
Brings a number you own on your own carrier account. It stays on your account and you keep paying your carrier for it; we never bill it. For carriers that sign their calls with your account's secret, save the account's keys first (Save Carrier Account) — we also use them to check the number is really on that account. `setup` says what is left to do: where the number must send its calls, and whether we pointed it there ourselves. Bringing the same number again is not an error; it corrects `provider`. The number arrives answered by nobody — Update Phone Number points it at an agent. Needs a paid plan and a **full** key.
# List Available Cities
Source: https://docs.thinnest.ai/api-reference/phone-numbers/list-available-cities
/api-reference/openapi.json get /phone-numbers/available/cities
The cities Search Available Phone Numbers accepts as `city`, with how many numbers each has. Use these spellings exactly: "Bangalore" finds numbers where "Bengaluru" finds none. Empty on the Free plan or when the stock cannot be reached. Not paged.
# List Carrier Accounts
Source: https://docs.thinnest.ai/api-reference/phone-numbers/list-carrier-accounts
/api-reference/openapi.json get /phone-numbers/carrier-accounts
Your own carrier accounts we hold verified keys for — the accounts your brought numbers live on — each by the last four characters of its account id, never any part of the secret. A carrier missing from the list has no keys saved: its brought numbers can answer calls (where the carrier allows) but not call out. Not paged.
# List Phone Numbers
Source: https://docs.thinnest.ai/api-reference/phone-numbers/list-phone-numbers
/api-reference/openapi.json get /phone-numbers
Every number this workspace can call from or be called on — rented and brought — with the agent that answers each. Not paged. `label` is always `null` here for a brought number; Get Phone Number reads it.
# Move Phone Number
Source: https://docs.thinnest.ai/api-reference/phone-numbers/move-phone-number
/api-reference/openapi.json post /phone-numbers/{number}/move
In the console, developers do this from the Numbers page: open the number's menu and choose **Move to another workspace…**.
Moves a number, **keeping its digits**, from the workspace the request runs in to another workspace of the same developer: the developer's own, or any of its customers. Send it from the developer's own workspace, or as the customer that holds the number with `Thinnest-Workspace`; a key made for one customer cannot. The number leaves every agent and calling pool where it was and arrives **answered by nobody** — point it with `PATCH /phone-numbers/{number}` (as the target workspace) once it is there.
**Rent** continues from the next period on the target. The month already paid is not charged again, and inside one developer both workspaces bill the developer, so no money moves.
**An Indian number** is held in the name of the business using it: the target workspace's business details must be approved first (`PUT /phone-numbers/business-details` as that workspace), and the move re-registers the number to that business.
**A brought number** (on your own carrier account) moves only when both workspaces have that same account's keys saved. Refused while the number is on a call. **Safe to retry:** repeating a move that already succeeded to the same workspace answers the same `200` (for a day). Needs a **full** key.
# Release Phone Number
Source: https://docs.thinnest.ai/api-reference/phone-numbers/release-phone-number
/api-reference/openapi.json delete /phone-numbers/{number}
Gives a number up; either way it stops being any agent's line and leaves every agent's calling pool. A **brought** number is simply forgotten: it stays yours on your own carrier account, and calls to it are declined until you repoint it. A **rented** number is **released, permanently**: it goes back to the pool, anybody may rent it next, and this month's rent is not refunded — so it is refused unless you add `?confirm=release`. Needs a **full** key.
# Rent Phone Number
Source: https://docs.thinnest.ai/api-reference/phone-numbers/rent-phone-number
/api-reference/openapi.json post /phone-numbers
Rents a number from Search Available Phone Numbers. **This costs money:** the first month's rent (the `monthlyPrice` the search showed) is charged from your balance at once, and again every month on the same day until you release the number. Every refusal happens before anything is bought or charged. An Indian number also needs your business details sent and approved first (Send Business Details, or the console's Phone Numbers page). The number arrives answered by nobody — Update Phone Number points it at an agent. Needs a **full** key.
# Save Carrier Account
Source: https://docs.thinnest.ai/api-reference/phone-numbers/save-carrier-account
/api-reference/openapi.json put /phone-numbers/carrier-accounts/{provider}
Saves the keys of your own account with a carrier, which lets its brought numbers be confirmed, answered and called out from. The keys are checked with the carrier before anything is stored, stored sealed, and never returned. One account per carrier: switching to a different account while numbers are still brought on the first is refused. `telnyx` takes its API key as `token` alone. Needs a **full** key — a carrier credential can spend money on your account.
# Search Available Phone Numbers
Source: https://docs.thinnest.ai/api-reference/phone-numbers/search-available-phone-numbers
/api-reference/openapi.json get /phone-numbers/available
Indian local numbers you can rent, each with its monthly rent at your plan's rate in your balance's currency. Numbers somebody already holds are left out, so a page (up to twenty) can hold fewer and still have more after it: pass `nextCursor` back as `cursor`. Needs a paid plan.
# Send Business Details
Source: https://docs.thinnest.ai/api-reference/phone-numbers/send-business-details
/api-reference/openapi.json put /phone-numbers/business-details
Sends the business's details for approval, as multipart/form-data: the legal name, whether it is registered for GST, and **one** document — the GST certificate if it is, otherwise its Certificate of Incorporation or its Udyam certificate (say which in `documentKind`; incorporation is assumed). PDF, JPG or PNG, at most 5 MB, filename at most 99 characters. This is the Business details form in the console, run by the same code. The details go to India's numbering authority in the business's name, so this needs a **full** key. There is one live application per workspace: sending while one is being checked, or after it was approved, is `409`; sending after a `rejected` one corrects that application in place (`200`), and a first or expired one creates a new application (`201`). Approval usually takes a few minutes — read it with Get Business Details, then rent the number. Needs a paid plan.
# Update Phone Number
Source: https://docs.thinnest.ai/api-reference/phone-numbers/update-phone-number
/api-reference/openapi.json patch /phone-numbers/{number}
Points a number at an agent, lends it to one for calling out, names it, or forwards its calls — any of the four. `agent` makes the number that agent's line (it stops being any other agent's; the agent must have voice switched on); `callingAgent` lets an agent call out on it without answering it (a brought number needs its carrier account's keys saved first); `forwardTo` sends every call to another phone, and works whether or not an agent answers the number (in the console: Numbers page, the number's menu, **Forward calls**). They are applied in the order `label`, `agent`, `callingAgent`, `forwardTo`, and a refusal stops there with the earlier changes kept. Needs a **full** key.
# Delete Call Content
Source: https://docs.thinnest.ai/api-reference/post-call/delete-call-content
/api-reference/openapi.json delete /calls/{id}/content
Deletes ONE call's recording and transcript, and keeps its billing record. Gone: the recording (ours, the phone provider's copy of a call on a number we rent, and any copy made for model training), this call's own turns in its conversation (other calls and chats with the same person stay), its summary, its collected fields, and the summary handed on with a warm hand-off. Kept: the call itself — when it happened, who it was with, how long it lasted, what it cost and `billing`. Afterwards Get Call answers with `recording: null`, an empty `transcript`, `contentDeleted: true` and `contentDeletedAt`. A recording that cannot be deleted at that moment is queued and retried every night until it is gone, and counted in `recordingsPending`. For a call you placed that was retried, every attempt's content goes. This cannot be undone.
This is not Cancel Call: `DELETE /calls/{id}` still calls a call off. Safe to repeat — again answers 200 with the first deletion's time. Needs a **full** key; a developer reaches a customer's call with `Thinnest-Workspace`.
# Get Call Analysis
Source: https://docs.thinnest.ai/api-reference/post-call/get-call-analysis
/api-reference/openapi.json get /calls/{id}/analysis
What one call came to, without the transcript: the outcome as the console's call log shows it ("Answered", "Line busy", "Answering machine"), the summary and the collected fields. `outcome` is null until the call has ended; `analysedAt` null means the summary and fields may still arrive, and `call.analysed` is sent the moment they do. Any id List Calls hands out works.
# Get Post-call Settings
Source: https://docs.thinnest.ai/api-reference/post-call/get-post-call-settings
/api-reference/openapi.json get /agents/{id}/post-call
What an agent does after each call: whether it summarises and records calls, how long your plan keeps a recording, and the details it collects. These are the agent's own settings — the same values Get Agent shows under `voice` and `collectFields` — and `followUp`, the WhatsApp and/or SMS the agent sends the caller when a call ends.
# Update Post-call Settings
Source: https://docs.thinnest.ai/api-reference/post-call/update-post-call-settings
/api-reference/openapi.json patch /agents/{id}/post-call
Changes what an agent does after each call; send only what you are changing. `collectFields` replaces the whole list, and an empty list stops collecting (details already collected are kept). Any other key is refused — the rest of the agent belongs to Update Agent. `followUp` sets the WhatsApp and/or SMS sent to the caller when a call ends, and is partial: what it leaves out keeps what is saved. A WhatsApp template must be an approved Utility one; an SMS needs a connected SMS provider. Answers with the settings as they now stand. A **build** key may do this, except `followUp`: it messages customers, so it needs a **full** key.
# Create Project
Source: https://docs.thinnest.ai/api-reference/projects/create-project
/api-reference/openapi.json post /projects
Adds a project. Every workspace starts with its default project; a second one needs a plan with teams and projects, and is refused with `402` otherwise. A **build** key may do this.
# Delete Project
Source: https://docs.thinnest.ai/api-reference/projects/delete-project
/api-reference/openapi.json delete /projects/{id}
Deletes a project and its team grants. Refused for the default project, and while any agent is still in the project — move them first. A **build** key may do this.
# Get Project
Source: https://docs.thinnest.ai/api-reference/projects/get-project
/api-reference/openapi.json get /projects/{id}
One project, with how many agents are in it.
# List Projects
Source: https://docs.thinnest.ai/api-reference/projects/list-projects
/api-reference/openapi.json get /projects
The workspace's projects, newest first, each with how many agents are in it.
# Update Project
Source: https://docs.thinnest.ai/api-reference/projects/update-project
/api-reference/openapi.json patch /projects/{id}
Renames a project. A **build** key may do this.
# Get Call Recording
Source: https://docs.thinnest.ai/api-reference/recordings/get-call-recording
/api-reference/openapi.json get /calls/{id}/recording
One call's audio as MP3, streamed — it plays inline, or with `download=1` saves as `call-{id}.mp3`. `Range` requests are honoured so a player can seek, and nothing is cached. A recording lands about a minute after the call ends (`404` with `Retry-After` until then) and is deleted when your plan's retention runs out (`410`): 30 days on Free and pay-as-you-go, 49 on Pro, 75 on Scale, 365 on Enterprise. Any id List Calls hands out works.
# Get Recording By Signed Link
Source: https://docs.thinnest.ai/api-reference/recordings/get-recording-by-signed-link
/api-reference/openapi.json get /recordings/{callId}
The signed link a call report hands out as `recording.url`, for a CRM user to click: it needs **no API key**, because the signature is the permission. Use the URL exactly as the report gives it; add `&download=1` to save rather than play. The link stops working at its `expiresAt`, which is when your plan deletes the recording. A forged link and an unknown call get the same `404`. Answers are plain text, not JSON.
# Create Saved Reply
Source: https://docs.thinnest.ai/api-reference/saved-replies/create-saved-reply
/api-reference/openapi.json post /saved-replies
Adds a saved reply, offered in the inbox composer as `/shortcut`. A leading `/` on the shortcut is dropped. A shortcut already used — in any capitalisation — is `409`. A **build** key may do this.
# Delete Saved Reply
Source: https://docs.thinnest.ai/api-reference/saved-replies/delete-saved-reply
/api-reference/openapi.json delete /saved-replies/{id}
Deletes a saved reply. A **build** key may do this.
# Get Saved Reply
Source: https://docs.thinnest.ai/api-reference/saved-replies/get-saved-reply
/api-reference/openapi.json get /saved-replies/{id}
One saved reply.
# List Saved Replies
Source: https://docs.thinnest.ai/api-reference/saved-replies/list-saved-replies
/api-reference/openapi.json get /saved-replies
The workspace's saved replies, newest first.
# Update Saved Reply
Source: https://docs.thinnest.ai/api-reference/saved-replies/update-saved-reply
/api-reference/openapi.json patch /saved-replies/{id}
Changes a saved reply's `shortcut`, `body`, or both. A **build** key may do this.
# SDKs
Source: https://docs.thinnest.ai/api-reference/sdks
Typed TypeScript and Python clients for every endpoint, with retries, idempotency and pagination built in.
Two small clients are generated from the same OpenAPI document as this reference, so every
endpoint here has a method, with typed request bodies and responses.
| | TypeScript | Python |
| - | - | - |
| Package | `@thinnestai/sdk` | `thinnestai` |
| Runtime | Node 18.17+, Deno, Bun, browsers — the global `fetch`, no dependencies | Python 3.9+, `httpx` only |
| Clients | `Client` | `Client` and `AsyncClient`, the same shape |
The packages are **not yet published to npm or PyPI**. Until they are, install them from a copy of
the repository:
```bash theme={null}
npm install ./sdks/typescript # TypeScript
pip install ./sdks/python # Python
```
The compiled `dist/` is not committed, so installing from a path builds it: the package's
`prepare` script fetches the TypeScript compiler with `npx` (the machine needs network access
the first time) and compiles `src/`. If your npm blocks install scripts, or you copied the
folder instead of installing it, run `npm install && npm run build` inside it once.
## Quickstart
```ts TypeScript theme={null}
import { Client, ApiError } from "@thinnestai/sdk";
const client = new Client({ apiKey: process.env.API_KEY! });
// Create an agent
const agent = await client.agents.create({ name: "Skyline Sales" });
// Place a call
const call = await client.calls.place({
to: "+91 98765 43210",
purpose: "I'm calling from Skyline Homes about your enquiry.",
agent: agent.id,
});
// Act in one of your customers' workspaces
const customer = await client.customers.create({ name: "Sunrise Dental Clinic" });
const sunrise = client.withWorkspace(customer.id);
await sunrise.agents.list();
```
```python Python theme={null}
import os
from thinnestai import Client, ApiError
client = Client(os.environ["API_KEY"])
# Create an agent
agent = client.agents.create({"name": "Skyline Sales"})
# Place a call
call = client.calls.place({
"to": "+91 98765 43210",
"purpose": "I'm calling from Skyline Homes about your enquiry.",
"agent": agent["id"],
})
# Act in one of your customers' workspaces
customer = client.customers.create({"name": "Sunrise Dental Clinic"})
sunrise = client.with_workspace(customer["id"])
sunrise.agents.list()
```
`withWorkspace` / `with_workspace` sends the `Thinnest-Workspace` header on every request (see
[Customers](/api-reference/customers)). The `customers.*` methods and `usage.getCustomers` ignore it, because those endpoints are always
called as you, never as one of your customers. To fix one workspace for a whole client, pass
`workspace: "org_…"` (TypeScript) or `workspace="org_…"` (Python) when you create it.
## How methods are named
One namespace per group in this reference — `agents`, `calls`, `customers`, `phoneNumbers`
(`phone_numbers` in Python), `webhooks`, … — and a method per endpoint, named for what it does:
`client.agents.list()`, `client.calls.place(…)`, `client.customers.restore(id)`.
* Path parameters come first, then the request body.
* TypeScript takes query parameters as an object after the body, then `RequestOptions`
(`idempotencyKey`, `headers`, `signal`, `timeoutMs`, `maxRetries`).
* Python takes query parameters as keyword arguments (`client.calls.list(status="completed")`),
plus `extra_headers=`, `timeout=` and, where the endpoint accepts one, `idempotency_key=`.
* Binary answers (call recordings, voice previews) come back as a `Blob` / `bytes`; text answers
(a transcript as text, a CSV export) as a string.
## Pagination
List endpoints answer one page, `{ items, nextCursor }`. Each has a companion that walks every page:
```ts TypeScript theme={null}
for await (const agent of client.agents.listAll({ limit: 100 })) {
console.log(agent.name);
}
```
```python Python theme={null}
for agent in client.agents.list_all(limit=100):
print(agent["name"])
# AsyncClient
async for agent in client.agents.list_all():
print(agent["name"])
```
## Errors
Any answer outside `2xx` raises `ApiError`, carrying `status`, `message` (the `error` sentence
from [the error body](/api-reference/errors)), the response `headers` and the parsed `body`. No
answer at all — the network failed or the request timed out — raises `ApiConnectionError`.
## Retries and idempotency
The clients follow the [retry policy](/api-reference/errors) for you:
* `429` is retried for every method, waiting for `Retry-After` (up to 120 seconds; a longer wait is
thrown rather than retried early).
* `408`, `500`, `502`, `503`, `504` and network errors are retried only for `GET` and `HEAD`, and for
any request sent with an `Idempotency-Key`. A `POST`, `PUT`, `PATCH` or `DELETE` without one is
never replayed, because replaying it could act twice.
* Backoff is exponential with jitter, capped at 8 seconds; two retries by default (`maxRetries` /
`max_retries`).
* These get an `Idempotency-Key` generated when you give none: `calls.place`, `calls.placeBatch`,
`conversations.reply`, `messages.send`, `codes.send` and `customers.create`. **The same key is
sent on every retry**, so a retry never places a second call. A key you pass — as the
`idempotencyKey` option or the `idempotencyKey` body field — is the one sent.
* `400`, `401`, `403`, `404` and `409` are never retried.
# Activate Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/activate-sequence
/api-reference/openapi.json post /sequences/{sequenceId}/activate
Turns a draft or paused sequence on: messages that fall due start going out, and its tags and events start enrolling people. Refused for a sequence with no steps, which would take people in and send nothing. Needs a **full** key.
# Add Sequence Step
Source: https://docs.thinnest.ai/api-reference/sequences/add-sequence-step
/api-reference/openapi.json post /sequences/{sequenceId}/steps
Appends a message at the **end** of the sequence: an approved template, sent `delayHours` after the step before (or after the person joined, for the first step). Steps cannot be edited, reordered or removed — someone partway through a live sequence would skip a message or get one twice — so to change the messages, delete the sequence and make another. A **build** key may do this.
# Create Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/create-sequence
/api-reference/openapi.json post /sequences
Creates a sequence as a **draft**: it sends nothing until you add a step with Add Sequence Step and turn it on with Activate Sequence. Tags are stored lower-case and hyphenated, and event names lower-case with dots, as the console stores them. A **build** key may do this.
# Delete Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/delete-sequence
/api-reference/openapi.json delete /sequences/{sequenceId}
Archives the sequence, which is how the console deletes one: it sends nothing more and leaves every list, while the record of who was sent what stays. There is no undo. A **build** key may do this.
# Enrol in Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/enrol-in-sequence
/api-reference/openapi.json post /sequences/{sequenceId}/enrolments
Puts one existing contact into a sequence, with values its messages can use — an order number your system knows and the console does not. The person must already be a contact; this never creates one. `enrolled: false` is a `200`, not an error: they are already in it, the number is not a contact, they opted out, or the sequence has no first message yet. Prefer Report Event unless your system must choose the sequence itself, since this puts a sequence id in your code. Only enrol people who agreed to hear from you. Needs a **full** key.
# Get Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/get-sequence
/api-reference/openapi.json get /sequences/{sequenceId}
One sequence with its steps in order, the events that start and stop it, and how many people are in it, finished it or left it.
# List Sequences
Source: https://docs.thinnest.ai/api-reference/sequences/list-sequences
/api-reference/openapi.json get /sequences
Your workspace's sequences, newest first. Deleted (archived) ones are left out. Get Sequence adds the steps, events and how many people are in each.
# Pause Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/pause-sequence
/api-reference/openapi.json post /sequences/{sequenceId}/pause
Pauses an active sequence: nothing more is sent until it is activated again. The people in it keep their place. A **build** key may do this.
# Update Sequence
Source: https://docs.thinnest.ai/api-reference/sequences/update-sequence
/api-reference/openapi.json patch /sequences/{sequenceId}
Changes any of `name`, `exitOnReply`, `triggerTags`, `exitOnTags`, `startOn` and `stopOn`. A list you send **replaces** the old one; a list you leave out is kept. Works while the sequence runs, and applies to the people already in it — add a stop tag and they stop when they get it. Steps cannot be changed here. A **build** key may do this.
# Add SMS Template
Source: https://docs.thinnest.ai/api-reference/sms/add-sms-template
/api-reference/openapi.json post /sms/templates
Adds a template exactly as it was approved on your DLT portal, with `{#var#}` wherever the text changes. Each `{#var#}` takes at most 30 characters when sent. Promotional templates are not accepted: an agent may only send transactional or service messages. MSG91 and Fast2SMS send by their own template id, so with them `providerTemplateId` is required. Needs a **full** key.
# Connect SMS Provider
Source: https://docs.thinnest.ai/api-reference/sms/connect-sms-provider
/api-reference/openapi.json put /sms/provider
Connects your SMS provider, or replaces the one connected. A secret you leave out keeps the one saved, for the same provider; switching provider needs every field. The connection's `status` goes back to `untested` until Send Test SMS succeeds. Needs a **full** key.
What each provider asks for in `credentials`:
- `msg91`: `authKey`
- `exotel`: `accountSid`, `apiKey`, `apiToken`, `region` (`in` or `sg`)
- `gupshup`: `userId`, `password`
- `infobip`: `baseUrl` (your own, like `xxxxx.api.infobip.com`), `apiKey`
- `vonage`: `apiKey`, `apiSecret`
- `fast2sms`: `apiKey`
# Delete SMS Template
Source: https://docs.thinnest.ai/api-reference/sms/delete-sms-template
/api-reference/openapi.json delete /sms/templates/{id}
Deletes a template. An agent that sent it after its calls stops sending an SMS then. Needs a **full** key.
# Disconnect SMS Provider
Source: https://docs.thinnest.ai/api-reference/sms/disconnect-sms-provider
/api-reference/openapi.json delete /sms/provider
Forgets the provider and its credentials, and switches `send_sms` off on every agent. Your templates are kept. Needs a **full** key.
# Get SMS Provider
Source: https://docs.thinnest.ai/api-reference/sms/get-sms-provider
/api-reference/openapi.json get /sms/provider
The workspace's SMS provider, sender header and DLT entity id. Secrets are never returned: `secretsSaved` names the ones held.
# List SMS Templates
Source: https://docs.thinnest.ai/api-reference/sms/list-sms-templates
/api-reference/openapi.json get /sms/templates
The workspace's DLT templates, by name.
# Send Test SMS
Source: https://docs.thinnest.ai/api-reference/sms/send-test-sms
/api-reference/openapi.json post /sms/test
Sends one template to one Indian mobile number through your provider — a real SMS, billed by your provider — to prove the provider, header and DLT ids work together. Success sets the connection's `status` to `working`; a provider's refusal sets it to `failing` with the reason. Needs a **full** key.
# Update SMS Template
Source: https://docs.thinnest.ai/api-reference/sms/update-sms-template
/api-reference/openapi.json patch /sms/templates/{id}
Changes any of a template's fields; what you leave out stays. If an agent sends this template after its calls, a body with a different number of `{#var#}` is refused until that agent's follow-up is changed. Needs a **full** key.
# Add Team Member
Source: https://docs.thinnest.ai/api-reference/teams/add-team-member
/api-reference/openapi.json put /teams/{id}/members/{memberId}
Puts a member of the workspace in the team, so they can reach the team's projects. Repeating it changes nothing. Answers with the team. A **build** key may do this.
# Create Team
Source: https://docs.thinnest.ai/api-reference/teams/create-team
/api-reference/openapi.json post /teams
Adds an empty team; put members in it and grant it projects with the team's sub-resources. Teams need a plan with teams and projects, and are refused with `402` otherwise. A **build** key may do this.
# Delete Team
Source: https://docs.thinnest.ai/api-reference/teams/delete-team
/api-reference/openapi.json delete /teams/{id}
Deletes a team and its grants. A project only this team could reach opens to the whole workspace. The people stay in the workspace. A **build** key may do this.
# Get Team
Source: https://docs.thinnest.ai/api-reference/teams/get-team
/api-reference/openapi.json get /teams/{id}
One team, with its members' ids and the projects it can reach.
# Grant Team Project
Source: https://docs.thinnest.ai/api-reference/teams/grant-team-project
/api-reference/openapi.json put /teams/{id}/projects/{projectId}
Lets the team reach a project. A project's first grant **restricts** it: from then on only the teams granted it (plus owners and admins) can see its agents. Repeating it changes nothing. Answers with the team. A **build** key may do this.
# List Teams
Source: https://docs.thinnest.ai/api-reference/teams/list-teams
/api-reference/openapi.json get /teams
The workspace's teams, newest first, each with its members' ids and the projects it can reach.
# Remove Team Member
Source: https://docs.thinnest.ai/api-reference/teams/remove-team-member
/api-reference/openapi.json delete /teams/{id}/members/{memberId}
Takes a member out of the team; they stay in the workspace. Taking out somebody who is not in the team changes nothing. Answers with the team. A **build** key may do this.
# Revoke Team Project
Source: https://docs.thinnest.ai/api-reference/teams/revoke-team-project
/api-reference/openapi.json delete /teams/{id}/projects/{projectId}
Takes a project out of the team's reach. When that was the project's last grant, the project opens to the whole workspace again. Answers with the team. A **build** key may do this.
# Update Team
Source: https://docs.thinnest.ai/api-reference/teams/update-team
/api-reference/openapi.json patch /teams/{id}
Renames a team. A **build** key may do this.
# Create Template
Source: https://docs.thinnest.ai/api-reference/templates/create-template
/api-reference/openapi.json post /templates
Saves a WhatsApp template as a draft, after the console's own checks of WhatsApp's rules for the body, header, footer, buttons and an authentication template's code. Review is a separate, deliberate step — Submit Template, or `submit: true` here — because once a name is approved it can never be reused for different words; if that submission is refused, the draft is kept and `message` says why. Limited-time offers, carousels and buttons that open a WhatsApp form are made in the console. Needs a **full** key.
# Delete Template
Source: https://docs.thinnest.ai/api-reference/templates/delete-template
/api-reference/openapi.json delete /templates/{name}
Deletes a WhatsApp template. One that was sent for review is deleted from WhatsApp first — only that language — and from your workspace only once that works, so a refusal leaves everything as it was. This cannot be undone, and WhatsApp may keep a deleted name reserved for a while, so it may not be reusable straight away. Needs a **full** key.
# Get Template
Source: https://docs.thinnest.ai/api-reference/templates/get-template
/api-reference/openapi.json get /templates/{name}
Returns one WhatsApp template by name, with its parts — header text, footer, buttons — and, when WhatsApp refused it, the reason in `reviewNote`. Poll it after Submit Template to follow the review: `status` moves from `pending` to `approved` or `rejected`. Pass `language` when the name exists in more than one.
# List Templates
Source: https://docs.thinnest.ai/api-reference/templates/list-templates
/api-reference/openapi.json get /templates
Lists your workspace's WhatsApp templates, newest first, with what a send needs from each: how many body values (`variableCount`), how many link-button values (`buttonVariableCount`) and whether the header needs a media link (`headerKind`). Only `approved` templates can be sent.
# Submit Template
Source: https://docs.thinnest.ai/api-reference/templates/submit-template
/api-reference/openapi.json post /templates/{name}/submit
Sends a **draft** template to your own WhatsApp Business Account for review, after the same last checks as the console's Submit. The template moves to `pending` only if WhatsApp accepted it. Review usually takes minutes, occasionally days; follow it with Get Template, whose `status` becomes `approved` or `rejected` (with the reason in `reviewNote`). A workspace with no connected WhatsApp number has no account to submit to. Needs a **full** key.
# Update Template
Source: https://docs.thinnest.ai/api-reference/templates/update-template
/api-reference/openapi.json patch /templates/{name}
Edits a **draft** template. Send only the fields that change; the result is checked against the same rules as Create Template. Once a template has been sent for review WhatsApp has its text, so it can no longer be edited — create a new template instead. Changing `name` or `language` renames it. A **build** key may do this. The one exception is `agentMaySend` (whether an agent may send the template in a call or chat): it goes alone and needs a **full** key.
# Configure Connection
Source: https://docs.thinnest.ai/api-reference/tools/configure-connection
/api-reference/openapi.json patch /agents/{id}/connections/{kind}
Pin a connection to its target, say how a lead's row is found, choose when it saves, and switch its tools. The account must be connected first (records excepted). Changes apply in a fixed order and stop at the first refusal, keeping what came before it. Helpdesks have nothing to configure. A **build** key may do this.
# Connect Account
Source: https://docs.thinnest.ai/api-reference/tools/connect-account
/api-reference/openapi.json post /agents/{id}/connections/{kind}
Connect (or re-key) an account. Every key, token and secret is sealed and never returned. Connecting grants nothing: tools start off and are switched with `PATCH` `tools`. `googlesheets`, `zoho` and `zoho_bigin` sign in on the provider's own consent screen — connect them once in the console (this answers `409`), then configure them here. A helpdesk connect opens one test ticket or case and answers with `inbound` **once**; a records connect that makes the first link answers with `link.passcode` **once**. A **build** key may do this.
# Connect Tool Server
Source: https://docs.thinnest.ai/api-reference/tools/connect-tool-server
/api-reference/openapi.json post /agents/{id}/mcp-servers
Connect an MCP tool server that takes a token (or none). It is asked for its tools at once and every tool arrives **off** — switch each on with `PATCH …/tools/{toolId}`. A server that cannot be reached is still saved and answers `201` with `status: "failed"` and the reason in `message`, so you can fix it and sync. A server that signs in through a browser must be connected in the console. A **build** key may do this.
# Disconnect Account
Source: https://docs.thinnest.ai/api-reference/tools/disconnect-account
/api-reference/openapi.json delete /agents/{id}/connections/{kind}
Disconnect the account; its tools leave the agent and its credential is deleted. A browser sign-in still pending is cancelled too. What already happened in the account (meetings booked, rows written) is untouched, and records already collected are kept. A **build** key may do this.
# Disconnect Tool Server
Source: https://docs.thinnest.ai/api-reference/tools/disconnect-tool-server
/api-reference/openapi.json delete /agents/{id}/mcp-servers/{serverId}
Disconnect the server; its tools leave the agent at once and its token is deleted. This works for servers connected in the console too. A **build** key may do this.
# Get Connection
Source: https://docs.thinnest.ai/api-reference/tools/get-connection
/api-reference/openapi.json get /agents/{id}/connections/{kind}
One connection, connected or not, with the shared tool budget.
# Get Tool Server
Source: https://docs.thinnest.ai/api-reference/tools/get-tool-server
/api-reference/openapi.json get /agents/{id}/mcp-servers/{serverId}
One tool server and its tools.
# List Built-in Tools
Source: https://docs.thinnest.ai/api-reference/tools/list-built-in-tools
/api-reference/openapi.json get /agents/{id}/tools
The agent's built-in tools — the Actions page's switches — with the recall, verification and hand-over settings. The three tools that reach a customer on another channel also say whether they are `ready` (on, and what they need exists) and what they `needs`.
# List Connection Options
Source: https://docs.thinnest.ai/api-reference/tools/list-connection-options
/api-reference/openapi.json get /agents/{id}/connections/{kind}/options
What a connected account can reach, read live each time, so you can choose a `target` for `PATCH …/connections/{kind}`: a Grist document's tables and columns (`?doc=`), a spreadsheet's sheets (`?spreadsheet=`), or the modules a Zoho account can write to. Other kinds have nothing to choose.
# List Connections
Source: https://docs.thinnest.ai/api-reference/tools/list-connections
/api-reference/openapi.json get /agents/{id}/connections
Every account the agent can work in, connected or not — a calendar, two spreadsheets, two CRMs, records kept here and two helpdesks — each with its state, what it is pinned to and its tools. `toolsInUse` / `maxTools` is the one budget every connection's switches share. Credentials never appear.
# List Tool Servers
Source: https://docs.thinnest.ai/api-reference/tools/list-tool-servers
/api-reference/openapi.json get /agents/{id}/mcp-servers
Every tool server the agent is connected to, oldest first, each with its tools. All come in one page. Tokens never appear; `hasSecret` says one is held.
# Replace Records Link
Source: https://docs.thinnest.ai/api-reference/tools/replace-records-link
/api-reference/openapi.json post /agents/{id}/connections/records/link
Make a new link to read the records the agent collects, with a new passcode — shown in this response and never again. The old link stops working at once, which is also how a link shared too widely is revoked. A **build** key may do this.
# Sync Tool Server
Source: https://docs.thinnest.ai/api-reference/tools/sync-tool-server
/api-reference/openapi.json post /agents/{id}/mcp-servers/{serverId}/sync
Ask the server what it offers, again — the console's Refresh, and the closest thing to a test. New tools arrive off; tools it no longer lists leave the agent. A store connection has a fixed list and is refused. A **build** key may do this.
# Update Built-in Tools
Source: https://docs.thinnest.ai/api-reference/tools/update-built-in-tools
/api-reference/openapi.json patch /agents/{id}/tools
Switch built-in tools on or off and set recall, code verification and the hand-over. `search_knowledge` is not a switch and web search cannot be switched on; both are refused. Changes apply in order and stop at the first refusal, keeping what came before. Needs a **full** key. — it can arm tools that call or message customers.
# Update Tool Server Tool
Source: https://docs.thinnest.ai/api-reference/tools/update-tool-server-tool
/api-reference/openapi.json patch /agents/{id}/mcp-servers/{serverId}/tools/{toolId}
Switch one of a server's tools on or off. On, it is callable from every conversation the agent has. Answers with the server and all its tools. A **build** key may do this.
# Get Calls Summary
Source: https://docs.thinnest.ai/api-reference/usage/get-calls-summary
/api-reference/openapi.json get /calls/summary
Call totals for the last `days` — calls, inbound, outbound, answered, missed, failed, seconds, what they cost, by surface — and a series by hour (`days=1`) or by day, bucketed in your workspace's time zone. The dashboard's calls figures. Any key may call this, a **read-only** key included.
# Get Customers Usage
Source: https://docs.thinnest.ai/api-reference/usage/get-customers-usage
/api-reference/openapi.json get /customers/usage
Developers: what each of your customers cost you over a range, in one call — spend net of refunds, how many charges, and spend by kind. Customers with no charges in the range are left out (list them with `GET /customers`). Without `from`/`to` it is this month so far; days are UTC and a range covers at most 366 days. Call it from your own workspace: the `Thinnest-Workspace` header is refused. Any key may call this, a **read-only** key included.
# Get Daily Usage
Source: https://docs.thinnest.ai/api-reference/usage/get-daily-usage
/api-reference/openapi.json get /usage/daily
Replies, calls and call minutes for every UTC day in the window — the Usage page's chart, quiet days as zeros. Give a rolling `days`, or a `from`/`to` range of up to 366 days reaching back at most 730 days; a `to` past today is cut to today. Any key may call this, a **read-only** key included.
# Get Usage
Source: https://docs.thinnest.ai/api-reference/usage/get-usage
/api-reference/openapi.json get /usage
Your plan, this calendar month's (UTC) replies against its allowance, the wallet, and replies per UTC day for the last `days` — the figures on the console's Usage page. Any key may call this, a **read-only** key included.
# Get WhatsApp Summary
Source: https://docs.thinnest.ai/api-reference/usage/get-whatsapp-summary
/api-reference/openapi.json get /whatsapp/summary
WhatsApp messages sent and received, how many were delivered, read or failed (with failure codes), a series, and what they cost you — the WhatsApp dashboard's figures. Either a rolling `days` or a `from`/`to` range (inclusive, up to 366 days), in your workspace's time zone; `from`/`to` wins when both are given. Any key may call this, a **read-only** key included.
# List Call Log
Source: https://docs.thinnest.ai/api-reference/usage/list-call-log
/api-reference/openapi.json get /usage/calls
The Usage page's call log: every call made or taken, newest first, with how it ended and what it was charged. `format=csv` returns the console's export instead — the same calls, up to 10,000, as a `calls.csv` attachment (columns Started, Direction, Surface, Agent, Number, From, Outcome, Status, Seconds, Cost); `limit` and `cursor` do not apply to it. `GET /calls` is the integration view of the same calls; this is the billing view. Any key may call this, a **read-only** key included.
# List Invoices
Source: https://docs.thinnest.ai/api-reference/usage/list-invoices
/api-reference/openapi.json get /invoices
One invoice for every payment taken, newest first — the rows under Settings → Billing → History → Invoices. Amounts are integer **minor** units (paise, cents) of `currency`, as issued — not micro-units. `from`/`to` filter on the issue date. Any key may call this, a **read-only** key included.
# List Wallet Ledger
Source: https://docs.thinnest.ai/api-reference/usage/list-wallet-ledger
/api-reference/openapi.json get /wallet/ledger
The balance history — every top-up, reply, call, WhatsApp message, number rental, refund and adjustment, newest first, with the balance after each (Settings → Billing → History). A developer's charges for its customers land on its own ledger, each naming the customer; `customer` narrows to one (read with your own key, no `Thinnest-Workspace` header). Reads only: money is added in the console. Any key may call this, a **read-only** key included.
# Create Voice Clone
Source: https://docs.thinnest.ai/api-reference/voice-clones/create-voice-clone
/api-reference/openapi.json post /voice-clones
Clones a voice from a 5–30 second recording, sent as multipart/form-data with the audio as `sample`. Both consent fields must be `true` — the two promises the console shows — or nothing is stored or sent anywhere; the refusal carries the two sentences so you can show your own user the same words. Cloning is on the Pro plan and above, up to your plan's number of voices (10 on Pro, 20 on Scale). Needs a **full** key.
# Delete Voice Clone
Source: https://docs.thinnest.ai/api-reference/voice-clones/delete-voice-clone
/api-reference/openapi.json delete /voice-clones/{id}
Forgets the voice everywhere — the voice, its recording and its preview — and moves every agent that spoke with it back to a standard voice; `movedAgents` says how many. It cannot be undone. Needs a **full** key.
# Get Voice Clone
Source: https://docs.thinnest.ai/api-reference/voice-clones/get-voice-clone
/api-reference/openapi.json get /voice-clones/{id}
One cloned voice, with a preview link that works for sixty seconds — long enough to play, too short to share.
# List Voice Clones
Source: https://docs.thinnest.ai/api-reference/voice-clones/list-voice-clones
/api-reference/openapi.json get /voice-clones
Your workspace's cloned voices, newest first, with a short-lived link to each one's preview and the consent it was made under.
# Update Voice Clone
Source: https://docs.thinnest.ai/api-reference/voice-clones/update-voice-clone
/api-reference/openapi.json patch /voice-clones/{id}
Renames, re-describes or re-tags a cloned voice. Its language, recording and consent are what it was made from and cannot be changed; sending any other field is refused. A **build** key may do this.
# List Voices
Source: https://docs.thinnest.ai/api-reference/voices/list-voices
/api-reference/openapi.json get /voices
Every voice your agents may speak with, in the picker's order: standard, then premium, then Studio. `tier` is the price band a call is billed at. Studio voices — the shared Studio catalogue and your own clones (`mine: true`) and, in a developer's customer workspace, the developer's clones (`sharedFromDeveloper: true`) — are listed only on the Pro plan and above, so every id here is one you can set. Every voice speaks every supported language; the agent's language decides which.
# Webhooks
Source: https://docs.thinnest.ai/api-reference/webhooks
Add, list, change and remove the endpoints an agent's events are sent to.
```http theme={null}
POST /api/v1/webhooks
Authorization: Bearer ta_live_…
Content-Type: application/json
```
```json theme={null}
{
"agent": "ag_5c4a5f93-…",
"url": "https://crm.example.com/hooks/thinnest",
"events": ["call.analysed", "call.completed"]
}
```
```json theme={null}
{
"id": "wh_e1c5…",
"agent": "ag_5c4a5f93-…",
"url": "https://crm.example.com/hooks/thinnest",
"events": ["call.analysed", "call.completed"],
"enabled": true,
"delivery": { "failuresInARow": 0, "lastStatus": null, "lastError": null, "lastSentAt": null },
"createdAt": "2026-09-17T09:41:00Z",
"signingSecret": "s3cr3t…"
}
```
**`signingSecret` is in the create response and nowhere else.** Store it.
Every delivery carries `x-thinnest-signature: sha256=…`, an HMAC of the raw
body under this secret, and `x-thinnest-signature-v2`, which also signs the
delivery time — see [Delivery](#delivery).
## Endpoints
| Method | Path | What |
| - | - | - |
| `GET` | `/webhooks` | List, newest first. `agent` filters |
| `POST` | `/webhooks` | Add one. `url`, and `agent` — or `includeCustomers: true` |
| `GET` | `/webhooks/{id}` | One endpoint, with its delivery state |
| `PATCH` | `/webhooks/{id}` | `url`, `events`, `enabled` |
| `DELETE` | `/webhooks/{id}` | Remove it |
| `POST` | `/webhooks/{id}/test` | Send a sample event and say whether it arrived |
| `GET` | `/webhooks/{id}/deliveries` | Recent deliveries, newest first, with attempts and the next retry |
| `POST` | `/webhooks/{id}/redeliver` | Send one delivery again, or everything that failed since a time |
## Fields
Whose events. Required on create — unless `includeCustomers` is `true`.
For a workspace with [customers](/api-reference/customers): one endpoint for the events of every
agent in every one of your customers. It has no `agent` (send one or the other, not both). Each
delivery's `data` carries `workspaceId` — the customer it is about — inside the signed body.
An `https://` address, or an email address — a helpdesk that opens tickets by
mail gets one email per event. Addresses inside our own network are refused.
Which events to send. Leave it out for every event, now and as new ones are
added; the response then reads `["*"]`.
| Event | When |
| - | - |
| `lead.captured` | The agent collected a name, email or phone from somebody interested |
| `conversation.escalated` | It handed a conversation to a person |
| `conversation.resolved` | A teammate marked one done |
| `call.completed` | A call ended — outcome and duration, the moment it ends |
| `call.analysed` | A call's [results are ready](/api-reference/calls/get-call) — summary, fields, transcript, recording link, and what the call cost (`costMicro` in millionths of `currency`; your reseller's price if you are a white-label client; `costMicro` is `null` if the call has not settled yet — read [Get Call](/api-reference/calls/get-call) later) |
| `campaign.finished` | A broadcast or calling campaign finished |
| `contact.opted_out` | During a call, somebody asked not to be called again and the agent put their number on your [do-not-call list](/channels/voice#dont-call-me-again) — `data.phone`, `data.callId`, `data.source` (always `"call"`), `data.optedOutAt`, `data.reason` (their words, or `null`) |
| `call.problem` | During a call that is [still going on](#problems-on-a-live-call): the agent could not answer, your own model key failed, a reply was very slow, or the voice connection failed. At most once per kind per call |
| `contact.erased` | A contact you deleted is [gone completely](#workspace-events), recordings included. Sent once |
| `byok.changed` | Your [own keys](#workspace-events) changed: one was added, replaced, removed or failed its check, or they were switched on or off or set to cover different jobs |
Switching an endpoint back on forgives its past failures.
`call.started` is not a webhook event you subscribe to. It is the request your own system receives
from an agent's [Who-is-calling lookup](/channels/voice#who-is-calling-lookup), signed the same
way, which you answer with values for the agent to use.
## Problems on a live call
`call.problem` is sent **while the call is still going on**, so you can alert
somebody or flag the call before it ends. Each kind is sent at most once per
call, and a burst across many calls is held to about 20 events a minute per
agent. An event held back by that limit is not queued: it is sent if that kind
happens again on the same call once the agent is back under the limit, and
otherwise it is not sent at all. The same problems are also on the call itself,
under **Problems on this call** in [Usage](/workspace/usage#how-a-call-went).
```json theme={null}
{
"id": "evt_4d8b2f6a-…",
"event": "call.problem",
"sentAt": "2026-10-06T04:31:02.715Z",
"data": {
"callId": "out_9f2c1a44-…",
"agentId": "ag_5c4a5f93-…",
"kind": "slow_reply",
"detail": "A reply took 6.2 seconds to start.",
"at": "2026-10-06T04:31:02.702Z"
}
}
```
| `kind` | What happened |
| - | - |
| `no_answer_from_model` | The agent could not get an answer — even from its backup — and apologised to the caller |
| `byok_failed` | Your [own model key](/api-reference/bring-your-own-keys) failed, so the agent apologised instead of answering |
| `slow_reply` | A reply took more than 5 seconds to start |
| `voice_failed` | The call's voice connection failed — the caller may have heard silence or been cut off. It also rings the workspace's notification bell |
`callId` is the id [Get Call](/api-reference/calls/get-call) takes. `detail` is
a sentence you can show a person.
## Call quality
Every call the agent ran carries `quality`, written once when the call ends —
on [Get Call](/api-reference/calls/get-call) and in `data.quality` on
`call.completed` and `call.analysed`:
```json theme={null}
"quality": {
"turns": 14,
"responseMs": { "median": 1180, "p90": 1920, "max": 3410 },
"firstTokenMs": { "median": 610, "p90": 940, "max": 1730 },
"longSilences": 1,
"longSilenceSeconds": 10,
"longestSilenceSeconds": 12.4,
"interruptions": 2,
"modelErrors": 0,
"rescues": 1
}
```
| Field | What it is |
| - | - |
| `turns` | Caller turns the agent answered — noises that got no reply, such as an echo of the agent's own voice, are not counted |
| `responseMs` | How long the caller waited: from when they stopped speaking to the agent's first audio — median, 90th percentile and slowest, in milliseconds |
| `firstTokenMs` | Our side of that wait: from the turn reaching the model to its first word |
| `longSilences` | Silences on either side at least `longSilenceSeconds` long — the agent's "are you still there?" time, or 10 seconds if it has none |
| `longestSilenceSeconds` | The longest stretch where nobody spoke |
| `interruptions` | Times the caller started speaking while the agent was talking |
| `modelErrors` | Turns the agent apologised for because it could not get an answer. `null` when the count was not available for the call |
| `rescues` | Turns where the agent's first choice failed and a backup answered, so the caller still got a reply. `null` when the count was not available for the call |
`quality` is `null` for a call that was not measured (a call from before these
figures were recorded, or one that never connected), and a figure inside it is
`null` when it could not be measured on that call. On `call.completed` it can
also be `null` when the call ended before its figures were ready;
`call.analysed` and Get Call have them by then. In the console the same
figures are under **How the call went** when you open the call in
[Usage](/workspace/usage#how-a-call-went).
## Calls handed to another agent
When an agent passes a live call to another of your agents
([how](/channels/voice)), `call.analysed` and Get Call list it in `handedTo`,
oldest first — empty when the call stayed with `agent`, which is always the
agent the call was placed or answered as:
```json theme={null}
"handedTo": [
{
"agent": { "id": "ag_7b1d4e90-2c3a-4f6e-8d15-9a0b3c2e4f71", "name": "Support" },
"from": { "id": "ag_3f6a9c21-7d4e-4b58-9a1f-0c2e8b7d5a34", "name": "Sales" },
"at": "2026-10-09T10:04:12Z"
}
]
```
A hand-off to another agent is not a hand-over to a person, so it does not
send `conversation.escalated`.
**Which agent an event names.** The call-level events — `call.completed`,
`call.analysed` and `call.problem` — stay with the agent the call started
with, which is also the one the call is billed and summarised under. Events
the agents themselves raise mid-call — `lead.captured`,
`conversation.escalated` — name the agent that ran the tool in `data.agentId`
and carry `data.callId`, so on a handed call you can join them to the call
report and to `handedTo`.
**Webhooks belong to an agent.** An event raised by the agent a call was handed
to goes to *that* agent's webhooks, not to the agent that took the call first.
If you only subscribe the first agent, you will get `call.completed` and
`call.analysed` for the whole call but miss any `lead.captured` or
`conversation.escalated` the second agent raises. Add the same endpoint to every
agent a call can be handed to.
## Delivery
Each event is a `POST` of JSON:
```json theme={null}
{ "id": "evt_9d1c…", "event": "lead.captured", "sentAt": "2026-10-07T09:12:03.551Z", "data": { … } }
```
with these headers on every attempt (an email destination gets the ticket email only, without them):
| Header | What it is |
| - | - |
| `x-thinnest-signature` | `sha256=` + hex HMAC-SHA256 of the raw body under your signing secret |
| `x-thinnest-signature-v2` | `sha256=` + hex HMAC-SHA256 of `.` — the delivery time, signed |
| `x-thinnest-delivered-at` | When this attempt left (ISO 8601) |
| `x-thinnest-event-id` | `evt_…`, the same as the body's `id` — the same on every retry and re-send |
| `x-thinnest-attempt` | `1`, then `2`, `3`, … |
### Retries
Anything other than a 2xx answer — an error, a timeout after 10 seconds, an
address we cannot reach — is tried again after **1 minute, 5 minutes, 30
minutes, 2 hours and 6 hours**: six attempts over about 8½ hours. Every attempt
sends the **same bytes**, so `sentAt` is when the event happened, not when the
attempt left. Payloads are kept for **7 days**, so anything that still did not
arrive can be [sent again](#re-send-what-failed) within that week.
Re-sending a delivery starts its retry schedule afresh. Retries and re-sends
go to the endpoint's **current** address — change the URL and pending retries
follow it. A Slack or helpdesk destination that accepted a post but answered
after the 10-second timeout can receive it twice, since neither can dedupe on
the event id.
### What your receiver should do
1. **Verify** `x-thinnest-signature-v2` (or `x-thinnest-signature`) with a
constant-time compare.
2. **Check freshness on `x-thinnest-delivered-at`** — reject one more than five
minutes from your clock. Not on `sentAt`: a retry's `sentAt` is hours old by
design. The delivery time is covered by the v2 signature, so it cannot be
moved without breaking it.
3. **Dedupe on `x-thinnest-event-id`.** A retry after a timeout can reach you
after you already handled the first attempt.
4. **Answer 2xx quickly**, then do the work.
```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, headers) {
const at = headers["x-thinnest-delivered-at"];
const expected = "sha256=" + createHmac("sha256", secret).update(`${at}.${rawBody}`).digest("hex");
const given = headers["x-thinnest-signature-v2"] ?? "";
const fresh = Math.abs(Date.now() - Date.parse(at)) < 5 * 60_000;
return fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
```
### Switched off
An endpoint is switched off — `enabled: false`, `delivery.lastError` says why —
after **five events in a row that each failed every attempt**. A failed attempt
that a retry then delivers does not count. Events that happen while it is off
are recorded as not sent; fix the endpoint, `PATCH { "enabled": true }`, and
re-send them — nothing within the last 7 days is lost.
## Event payloads
Every delivery is the same envelope. What changes is `data`, shown here in full for each event.
Ids in `data` are the same prefixed ids the API returns and takes: `conv_…` for a conversation
(pass it to [List Conversation Messages](/api-reference/conversations/list-conversation-messages)), `cmp_…` for a
campaign, and `out_…`, `sch_…` or the call reference for a call (pass it to
[Get Call](/api-reference/calls/get-call)). `conversationId` and `campaignId`
always carry their `conv_` and `cmp_` prefix, so a value from a webhook can be
passed straight to the API. If your receiver stored these ids as bare
identifiers before, store the prefixed value from now on.
An endpoint created with `includeCustomers: true` also gets `data.workspaceId` (`org_…`), the
customer the event is about.
### `lead.captured`
The agent collected contact details. `name`, `email` and `phone` are each `null` when not given; `note` is what the customer wants, written for whoever follows up. `agentId` is the agent that captured it and `callId` the call it was on (the id [Get Call](/api-reference/calls/get-call) takes; `null` off a call).
```json theme={null}
{
"id": "evt_9d1c4b2a-7e3f-4a6b-8c1d-2e5f7a9b0c13",
"event": "lead.captured",
"sentAt": "2026-10-06T04:31:55.204Z",
"data": {
"conversationId": "conv_3f6b2d1e-8a4c-4f7b-9e21-6c0d5a7b8e94",
"agentId": "ag_3f6a9c21-7d4e-4b58-9a1f-0c2e8b7d5a34",
"callId": "out_6d1c2e84-3b7a-4f95-8e10-5a9b7c3d2f41",
"name": "Asha Rao",
"email": "asha@example.com",
"phone": "+919876543210",
"note": "Wants a 2 BHK at Sky Towers, budget around 80 lakh, and a site visit on Saturday."
}
}
```
### `conversation.escalated`
The agent handed the conversation to a person. `reason` is the agent's own words. `agentId` is the agent that handed over and `callId` the call it was on (`null` off a call).
```json theme={null}
{
"id": "evt_1a7f3c9e-5b2d-4e8a-9f6c-0d3b7e1a4c25",
"event": "conversation.escalated",
"sentAt": "2026-10-06T04:32:12.880Z",
"data": {
"conversationId": "conv_3f6b2d1e-8a4c-4f7b-9e21-6c0d5a7b8e94",
"agentId": "ag_3f6a9c21-7d4e-4b58-9a1f-0c2e8b7d5a34",
"callId": null,
"reason": "The customer asked to speak to someone about a refund."
}
}
```
### `conversation.resolved`
A teammate marked the conversation done. Sent once, when the status actually changes.
```json theme={null}
{
"id": "evt_6e2b8d4f-1c7a-4f3e-a9b5-8d0c2e6f1a37",
"event": "conversation.resolved",
"sentAt": "2026-10-06T05:10:03.551Z",
"data": {
"conversationId": "conv_3f6b2d1e-8a4c-4f7b-9e21-6c0d5a7b8e94"
}
}
```
### `call.completed`
The moment a call ends, before it has been read. `outcome` is `completed`, `missed` or `failed`; `hangup` is `answered`, `no_answer`, `busy`, `rejected`, `unreachable`, `cancelled`, `voicemail`, `before_agent` or `failed`; `phone` is null on a web call. `campaignId` is present only for a campaign's call, and `id`, `reference` and `metadata` only for a call placed through the API.
```json theme={null}
{
"id": "evt_3c9a5e1b-7d4f-4b2a-8e6c-1f0d9a3b5c49",
"event": "call.completed",
"sentAt": "2026-10-06T04:33:12.020Z",
"data": {
"callRef": "out_9f2c1a44-6b0e-4c1d-8a7f-2e5b3c9d1f60",
"conversationId": "conv_3f6b2d1e-8a4c-4f7b-9e21-6c0d5a7b8e94",
"outcome": "completed",
"hangup": "answered",
"seconds": 182,
"direction": "outbound",
"surface": "phone",
"phone": "919876543210",
"campaignId": "cmp_7a1e9c3b-2d4f-4b8a-a6e0-1f5c8d2b9e47",
"id": "out_9f2c1a44-6b0e-4c1d-8a7f-2e5b3c9d1f60",
"reference": "LSQ-42",
"metadata": {
"deal_id": "D-19",
"owner": "Ravi"
},
"quality": {
"turns": 14,
"responseMs": { "median": 1180, "p90": 1920, "max": 3410 },
"firstTokenMs": { "median": 610, "p90": 940, "max": 1730 },
"longSilences": 1,
"longSilenceSeconds": 10,
"longestSilenceSeconds": 12.4,
"interruptions": 2,
"modelErrors": 0,
"rescues": 1
},
"endedAt": "2026-10-06T04:33:11.902Z"
}
}
```
`quality` is how the call went in numbers (see [Call quality](#call-quality)); it
can be `null` when the call ended before its figures were ready. `forwardedTo`
appears only on a call your number forwarded to a phone, and is that phone's
number; it is left out of every other call.
### `call.analysed`
The call once it has been read: exactly the object [Get Call](/api-reference/calls/get-call) returns. `quality` can be `null` (see [Call quality](#call-quality)), `handedTo` is empty unless the call was [handed to another agent](#calls-handed-to-another-agent), and `forwardedTo` is `null` unless the call was forwarded to a phone.
```json theme={null}
{
"id": "evt_8f4d2a6c-3e9b-4c1f-b7a5-6d2e0f8c4a51",
"event": "call.analysed",
"sentAt": "2026-10-06T04:33:20.402Z",
"data": {
"id": "out_9f2c1a44-6b0e-4c1d-8a7f-2e5b3c9d1f60",
"callRef": "out_9f2c1a44-6b0e-4c1d-8a7f-2e5b3c9d1f60",
"reference": "LSQ-42",
"metadata": {
"deal_id": "D-19",
"owner": "Ravi"
},
"attempt": 1,
"status": "completed",
"direction": "outbound",
"phone": "919876543210",
"from": "918045678901",
"agent": {
"id": "ag_5c4a5f93-2b1e-4d7a-9f60-8e2d1c3b4a71",
"name": "Skyline Sales"
},
"scheduledFor": null,
"startedAt": "2026-10-06T04:30:02.118Z",
"answeredAt": "2026-10-06T04:30:09.540Z",
"endedAt": "2026-10-06T04:33:11.902Z",
"seconds": 182,
"hangup": "answered",
"variables": {
"lead_name": "Asha",
"project": "Sky Towers"
},
"summary": "Asha is keen on a 2 BHK at Sky Towers with a budget around 80 lakh. She asked to visit the site on Saturday.",
"fields": {
"budget": 8000000,
"interest": "Hot",
"site_visit": true
},
"transcript": [
{
"speaker": "agent",
"text": "Hello Asha, I'm calling from Skyline Homes about your enquiry for Sky Towers.",
"at": "2026-10-06T04:30:10.204Z"
},
{
"speaker": "customer",
"text": "Haan, boliye. I wanted a 2 BHK.",
"at": "2026-10-06T04:30:16.880Z"
}
],
"recording": {
"url": "https://app.thinnest.ai/api/v1/recordings/3b7e1c90-5a2d-4e8f-9c61-0d4b7a2e8f13?expires=1762403402&sig=8c1f0e7a92b4d3c6e5f1a0b9c8d7e6f5a4b3c2d1",
"expiresAt": "2026-11-05T04:30:02.118Z",
"ready": true
},
"analysedAt": "2026-10-06T04:33:20.311Z",
"retryScheduledFor": null,
"costMicro": 6370000,
"currency": "INR",
"quality": {
"turns": 14,
"responseMs": { "median": 1180, "p90": 1920, "max": 3410 },
"firstTokenMs": { "median": 610, "p90": 940, "max": 1730 },
"longSilences": 1,
"longSilenceSeconds": 10,
"longestSilenceSeconds": 12.4,
"interruptions": 2,
"modelErrors": 0,
"rescues": 1
},
"handedTo": [
{
"agent": { "id": "ag_7b1d4e90-2c3a-4f6e-8d15-9a0b3c2e4f71", "name": "Site Visits" },
"from": { "id": "ag_5c4a5f93-2b1e-4d7a-9f60-8e2d1c3b4a71", "name": "Skyline Sales" },
"at": "2026-10-06T04:31:40.000Z"
}
],
"forwardedTo": null,
"error": null
}
}
```
### `campaign.finished`
Everyone on a broadcast (`kind: "whatsapp"`) or a calling campaign (`kind: "voice"`) has been reached or given up on. `skipped` is people who withdrew consent before their turn.
```json theme={null}
{
"id": "evt_2b6e0c8a-9f3d-4a7e-8c4b-5e1f3d7a9b63",
"event": "campaign.finished",
"sentAt": "2026-10-06T13:02:44.311Z",
"data": {
"campaignId": "cmp_7a1e9c3b-2d4f-4b8a-a6e0-1f5c8d2b9e47",
"name": "Diwali site visits",
"kind": "voice",
"recipients": 120,
"sent": 104,
"failed": 9,
"skipped": 7,
"finishedAt": "2026-10-06T13:02:44.180Z"
}
}
```
### `contact.opted_out`
During a call, somebody asked not to be called again and their number went on the do-not-call list. `callId` is the call's id; `source` is always `"call"`; `reason` is what the caller said, as recorded (up to 160 characters, in the language they spoke), or `null` when the agent did not record their words.
```json theme={null}
{
"id": "evt_5a3f7d1e-2c8b-4e6a-9d0f-7b4c1e5a3d75",
"event": "contact.opted_out",
"sentAt": "2026-10-06T04:32:40.640Z",
"data": {
"phone": "+919876543210",
"callId": "out_9f2c1a44-6b0e-4c1d-8a7f-2e5b3c9d1f60",
"source": "call",
"optedOutAt": "2026-10-06T04:32:40.511Z",
"reason": "please don't call me again"
}
}
```
### `call.problem`
Something went wrong while a call was live. `kind` is `no_answer_from_model`, `byok_failed`, `slow_reply` or `voice_failed`; `detail` says what happened in a sentence; `callId` is the id [Get Call](/api-reference/calls/get-call) takes. Sent at most once per kind per call.
```json theme={null}
{
"id": "evt_4d8b2f6a-1e3c-4a9d-b5f7-0c6e2a8d4b87",
"event": "call.problem",
"sentAt": "2026-10-06T04:31:02.715Z",
"data": {
"callId": "out_9f2c1a44-6b0e-4c1d-8a7f-2e5b3c9d1f60",
"agentId": "ag_5c4a5f93-2b1e-4d7a-9f60-8e2d1c3b4a71",
"kind": "slow_reply",
"detail": "A reply took 6.2 seconds to start.",
"at": "2026-10-06T04:31:02.702Z"
}
}
```
## Workspace events
`contact.erased` and `byok.changed` are about your whole workspace rather than one agent. They go to
**every endpoint in the workspace that subscribed to them**, whichever agent you added it on, and
**once per address**: if three agents post to the same URL, that URL still gets one delivery. If you
are a developer, an endpoint that receives every customer's events (`includeCustomers`) gets them for
your customers too, with `workspaceId`. An endpoint subscribed to every event (`["*"]`) receives them
as well. Neither event carries personal details or any part of a key.
### `contact.erased`
A contact you deleted with [Delete Contact](/workspace/data-and-erasure) is gone completely, recordings
included. It is sent **once**: straight away when Delete Contact answered `204`, or — when it answered
`recordingsPending` because a recording could not be deleted at that moment — from the nightly retry,
once the last of those recordings is gone. A recording that was still being written when you deleted
the contact can take up to three nights to be confirmed gone. `contactId` is the id you deleted.
```json theme={null}
{
"id": "evt_7c2e9a41-5d3b-4f8e-a6c0-1b9d4e7f2a58",
"event": "contact.erased",
"sentAt": "2026-10-07T02:30:11.402Z",
"data": {
"contactId": "cust_2b7e4c19-8a3d-4f60-b1e5-9c0d6a3f7e24",
"erasedAt": "2026-10-06T11:42:05.118Z"
}
}
```
### `byok.changed`
Your [own keys](/api-reference/bring-your-own-keys) changed. `change` is one of:
| `change` | What happened |
| - | - |
| `key_added` | A key was added for a job that had none |
| `key_replaced` | A key was saved over the one you had for that job |
| `key_removed` | A key was removed |
| `key_failed_verification` | You tried to save a key and the provider refused it. Nothing was saved |
| `byok_enabled` | Your own keys were switched on |
| `byok_disabled` | Your own keys were switched off; calls and replies run on ours |
| `scope_changed` | Your own keys were set to cover different jobs |
`kind` (`stt`, `llm` or `tts`) and `provider` name the key for the four key changes, and are `null`
for the others. `byokEnabled` and `scope` say where your workspace stands **after** the change.
Choosing a different model for a key is not sent. Saved keys are not re-checked on a schedule: a model
key that stops working on a call shows up as [`call.problem`](#problems-on-a-live-call) (`byok_failed`).
```json theme={null}
{
"id": "evt_1f6b3d8e-9a2c-4e75-b0d4-6c8a2e5f9b13",
"event": "byok.changed",
"sentAt": "2026-10-06T09:15:40.226Z",
"data": {
"change": "key_replaced",
"kind": "tts",
"provider": "cartesia",
"byokEnabled": true,
"scope": "voice"
}
}
```
## Re-send what failed
```http theme={null}
POST /api/v1/webhooks/{id}/redeliver
```
One delivery, now — the answer says whether it arrived:
```json theme={null}
{ "deliveryId": "whd_2b6f…" }
```
```json theme={null}
{ "delivered": true, "status": 200, "error": null, "attempts": 4 }
```
Or everything that has not succeeded since a time, at most 7 days back — queued
and sent within a minute:
```json theme={null}
{ "since": "2026-10-06T00:00:00Z" }
```
```json theme={null}
{ "queued": 12 }
```
The same body, event id and `sentAt` as the first attempt, with
`x-thinnest-attempt` one higher. The endpoint must be switched on. The console's
**Re-send failed** button on the endpoint does the same for the last 7 days. A
build key may do this.
## Test an endpoint
```http theme={null}
POST /api/v1/webhooks/{id}/test
```
```json theme={null}
{ "delivered": true, "status": 200, "error": null }
```
Sends one sample `lead.captured` — marked `"test": true`, with obviously fake
details — the same way a real event goes: signed JSON, Slack's own format for
a Slack URL, or a ticket email for an email address. A failed test counts
towards the five failures that switch an endpoint off.
## Delivery history
```http theme={null}
GET /api/v1/webhooks/{id}/deliveries
```
```json theme={null}
{
"items": [
{
"id": "whd_2b6f…", "eventId": "evt_9d1c…", "event": "call.analysed",
"status": 500, "delivered": false, "error": "Internal Server Error",
"attempts": 2, "nextRetryAt": "2026-10-05T09:18:04Z",
"at": "2026-10-05T09:12:03Z", "lastAttemptAt": "2026-10-05T09:13:04Z"
},
{
"id": "whd_7c3e…", "eventId": null, "event": "test",
"status": 200, "delivered": true, "error": null,
"attempts": 1, "nextRetryAt": null,
"at": "2026-10-05T08:58:41Z", "lastAttemptAt": "2026-10-05T08:58:41Z"
}
]
}
```
One row per event, however many attempts it took, newest first — the last 7
days (or the last 50, whichever is more). The answer to "why did leads stop
reaching our CRM on Tuesday?". `status` and `error` are from the latest
attempt; `0` means we could not reach it. `nextRetryAt` is when the next
automatic attempt is due — null once it arrived or ran out of retries. The
payloads themselves are not returned: they carry your customers' details.
# Create Webhook
Source: https://docs.thinnest.ai/api-reference/webhooks/create-webhook
/api-reference/openapi.json post /webhooks
Adds an endpoint for one agent's events — or, from a developer workspace, with `includeCustomers: true`, one endpoint for every agent in every one of your customers, each delivery's `data` carrying the `workspaceId` it is about. The response carries `signingSecret` **once**: store it to verify `x-thinnest-signature` on every delivery, because it is never shown again. `url` is an `https://` address or an email address (a helpdesk that opens tickets by mail gets one email per event); addresses inside our own network are refused. A **build** key may do this.
# Delete Webhook
Source: https://docs.thinnest.ai/api-reference/webhooks/delete-webhook
/api-reference/openapi.json delete /webhooks/{id}
Removes the endpoint. Nothing more is sent to it, and its delivery history goes with it. A **build** key may do this.
# Get Webhook
Source: https://docs.thinnest.ai/api-reference/webhooks/get-webhook
/api-reference/openapi.json get /webhooks/{id}
One endpoint, with its delivery state: how many deliveries in a row have failed, and the last status, error and time. Never the signing secret.
# List Webhook Deliveries
Source: https://docs.thinnest.ai/api-reference/webhooks/list-webhook-deliveries
/api-reference/openapi.json get /webhooks/{id}/deliveries
The endpoint's recent deliveries, newest first — one row per event, however many attempts it took; 7 days (or the last 50, whichever is more) are kept. `status` and `error` are from the latest attempt, `0` when your endpoint could not be reached. `attempts` counts them, and `nextRetryAt` says when the next automatic one is due (null once delivered or out of retries). Test sends are listed as `test` with no `eventId`. Payloads are not returned: they carry your customers' details. Returns at most 50 rows and has no cursor: `nextCursor` is always null. A delivery counts toward switching the endpoint off only when it used up all six attempts: five such events in a row switch it off, and since each takes about 8½ hours to give up, a dead endpoint can take up to about two days to be switched off.
# List Webhooks
Source: https://docs.thinnest.ai/api-reference/webhooks/list-webhooks
/api-reference/openapi.json get /webhooks
The workspace's webhook endpoints, newest first — those added by API and those added in the console alike — each with its delivery state. Filter to one agent's with `agent`.
# Redeliver Webhook Events
Source: https://docs.thinnest.ai/api-reference/webhooks/redeliver-webhook-events
/api-reference/openapi.json post /webhooks/{id}/redeliver
Sends events to this endpoint again — the same body, event id and `sentAt` they first had, with `x-thinnest-attempt` one higher. Either `{ "deliveryId": "whd_…" }` for one delivery, sent now (the answer says whether it arrived; if it fails it joins the retry schedule), or `{ "since": "" }` for every delivery that has not succeeded since then — at most 7 days back, when payloads are dropped — queued and sent within a minute (`202 { queued }`). The endpoint must be switched on. A **build** key may do this: it reaches only your own endpoint.
# Test Webhook
Source: https://docs.thinnest.ai/api-reference/webhooks/test-webhook
/api-reference/openapi.json post /webhooks/{id}/test
Sends one sample `lead.captured` event to the endpoint — its `data` marked `"test": true`, with obviously fake details — the same way a real event goes: signed JSON, Slack's own message format for a Slack URL, or a ticket email for an email address. It is recorded in the delivery history as `test`, and a failed test counts towards the five failures in a row that switch an endpoint off. The answer says whether it arrived; a failure to deliver is still a `200` here. A **build** key may do this.
# Update Webhook
Source: https://docs.thinnest.ai/api-reference/webhooks/update-webhook
/api-reference/openapi.json patch /webhooks/{id}
Changes an endpoint's `url`, `events` or `enabled`; fields you leave out are kept. Switching an endpoint back on (`enabled: true`) forgives its past failures, so after an outage: fix the receiver, catch up from the API on what it missed, then `PATCH { "enabled": true }`. The agent an endpoint belongs to cannot be changed. A **build** key may do this.
# Get Workspace
Source: https://docs.thinnest.ai/api-reference/workspace/get-workspace
/api-reference/openapi.json get /workspace
The workspace's id, name, console address (`slug`), time zone and when it was made. Plan and billing are not settings and are not here.
# Update Workspace
Source: https://docs.thinnest.ai/api-reference/workspace/update-workspace
/api-reference/openapi.json patch /workspace
Renames the workspace, changes its time zone, or sets how many days calls are kept (`callRetentionDays`) — any of them. The name appears in invitation emails, so it follows the rules for a person's name: 2 to 80 characters (a longer one is refused with a 400), no web or email address, no markup. Every date in the console is shown in the time zone. The `slug` never changes. Needs a **full** key.
# Channels overview
Source: https://docs.thinnest.ai/channels/overview
Where customers talk to your agent, and what each channel can carry.
A channel is a place a customer **talks to you**. There are four.
| Channel | Inbound | Agent replies | Broadcasts | Recognises signed-in customers |
| - | - | - | - | - |
| [Website widget](/channels/website) | Yes | Yes | Never — a campaign reaches somebody who is not there | Yes, [with signing](/install/signed-in-customers) |
| [WhatsApp](/channels/whatsapp) | Yes | Yes | Yes | Yes, by phone number |
| [Telegram](/channels/telegram) | Yes | Yes | Not built | No |
| [Voice](/channels/voice) | Yes, by phone | Yes, out loud | No — a call is not a broadcast | Yes, by phone number |
An agent has at most one channel of each kind.
**Shopify, WordPress and PrestaShop are not channels.** They are doors onto
the website widget — ways to get it onto your pages without editing theme
code. See [Put it on your site](/install/shopify).
## One agent across all of them
The same knowledge, the same instructions, the same tools. You do not configure
a separate WhatsApp bot.
Each channel renders what it can. A card that appears as a rich card in the
website widget arrives as a native WhatsApp card, and where a channel cannot
render something it degrades to text rather than to a blank message.
## One customer across all of them
A person who asks on your website and then messages on WhatsApp is one contact
with one history — provided the two can be joined on evidence they did not
supply themselves. [How that works](/concepts#how-two-channels-become-one-person).
## What is not built
* **Instagram and Messenger.** Not available. Meta's rules also forbid marketing
content on those outside the 24-hour window, so a campaign lane would not
exist there even if the channel did.
* **Multi-channel campaigns.** The picker is real and only WhatsApp can send.
* **WhatsApp calling.** Not yet. [Voice](/channels/voice) answers on a phone
number and in the website widget today.
# Phone numbers
Source: https://docs.thinnest.ai/channels/phone-numbers
Get a number your customers ring, or point one you already own at your agent.
Your agent answers on a phone number. You can get one from us — searched,
bought and ready on the same screen — or use a number you already own.
Numbers belong to the **workspace**, not to one agent. They live on the **Phone
Numbers** page in the main sidebar, which lists every number you hold, where it
came from, and two things for each: the **Inbound** agent, which answers it, and
the **Outbound** agent, which calls out on it. They can be the same agent or
different ones, or either can be empty — see [Inbound and
outbound](#inbound-and-outbound-are-separate). A **Dial-out ready** column says
whether the number can place calls as well as answer them. You can search the
list and move any number to another agent.
You do not need a number to try voice. The website call button works without
one, on any plan, and it is the same agent with the same voice. A number is
what lets somebody ring you from a phone — and if you already own one, you can
point it at your agent on any plan, free included.
## Getting a number from us
In the main sidebar choose **Phone Numbers**, then **Buy / Import number**.
Pick a country, and a city if you want a local code. You will see the
numbers available now, each with what it costs a month.
Choose a number. Before anything is charged you get a summary — the number,
what it costs a month, what leaves your wallet today and what is left in it —
and the number is yours the moment you confirm. There is nothing to configure
with a phone company and no settings to copy anywhere.
A new number is not pointed anywhere yet. On the **Phone Numbers** page,
choose the agent under **Inbound**. That agent starts answering immediately.
Call the number. You should hear your greeting, then the agent.
### What it costs
Each number has a monthly price, shown before you take it, and it repeats every
month until you give the number up. Your plan may include one — if it does, the
page says so before you choose, and a number your plan includes needs nothing in
your wallet because nothing is taken for it.
**Buying a number needs a paid plan**, because we rent it for you every month.
Pay as you go has no monthly fee of its own and can hold numbers.
**Pointing a number you already own at your agent works on every plan,
including the free one.** It costs us nothing to answer it — you are already
paying your own provider for the number — and a phone call is usually the thing
you want to hear before you decide to pay for anything. Free-plan calls come out
of the same trial minutes as calls from your website.
That covers calls in **both directions**. On the free plan your agent can answer
a number you brought and, once you have added that account's credentials, ring
people from it — your own provider bills you for the line either way. Buying a
number from us is the part that needs a paid plan.
### India needs your business verified first
An Indian number is only issued to a registered business, and the check is in
**your** name rather than ours. It has its own tab — **KYC**, beside Imported on
the Phone Numbers page — because it takes a little time to clear and it is worth
being able to come back and see where it got to.
This is not our rule and we cannot shortcut it. Numbers in most other countries
need nothing beyond choosing one.
This is not the same as [business verification for
WhatsApp](/meta/verification), which Meta runs and which unlocks a different
set of things. Passing one does nothing for the other, and you may well need
both.
#### What it asks for
Your registered business name, and **one** document. Which one depends on a
single question:
| Registered for GST? | The document to send |
| - | - |
| Yes | Your **GST certificate** |
| No | Your **Certificate of Incorporation**, or your **Udyam certificate** — whichever you hold |
That is the whole form. PDF, JPG or PNG, up to 5 MB.
Send it as registered. The name you type is checked against the certificate you
attach, so an abbreviation or a trading name is the usual reason an application
comes back.
#### Following it up
The **KYC** tab shows where the application stands, asked afresh each time you
open the page rather than remembered from last time:
* **Being checked** — sent, usually decided within minutes.
* **Approved** — you can take an Indian number.
* **Not accepted** — the reason is shown with it. **Correct and resend** puts the
form back up.
* **Suspended** or **Expired** — send your documents again.
Each application has a **reference**. If you ask us to chase one, that is the
thing to quote.
**We keep a copy of the document you sent**, so correcting an application does
not mean going and finding the file again. You can download it from the same
tab; that needs an owner or admin.
You cannot buy an Indian number until this is approved — pressing Buy takes you
to the form instead. Numbers in every other country need none of it.
#### Doing it from the API
If you run a platform with one workspace per customer, each customer's business
is verified in **its own workspace** — the application belongs to the workspace,
not to you. Send the customer's details with the `Thinnest-Workspace` header
(see [Build a platform](/guides/build-a-platform)) and the same form the console
shows is filled in for them.
For each business you need three things: its **legal name**, whether it is
**registered for GST** (yes or no), and **one document** — the GST certificate if
it is, otherwise the Certificate of Incorporation or the Udyam certificate. PDF,
JPG or PNG, up to 5 MB, with a file name of 99 characters or fewer.
```bash theme={null}
curl -X PUT https://app.thinnest.ai/api/v1/phone-numbers/business-details \
-H "Authorization: Bearer $API_KEY" \
-H "Thinnest-Workspace: org_3f6a9c21-7d4e-4b58-9a1f-0c2e8b7d5a34" \
-F businessName="Acme Traders Private Limited" \
-F gstRegistered=true \
-F document=@gst-certificate.pdf
```
Without GST, send `-F gstRegistered=false` and the certificate you hold, adding
`-F documentKind=udyam` if it is a Udyam certificate (`incorporation` is
assumed). The answer is the application, and the status in it is the same set the
KYC tab shows:
```json theme={null}
{
"status": "submitted",
"businessName": "Acme Traders Private Limited",
"gstRegistered": true,
"documentKind": "gst",
"submittedAt": "2026-10-08T09:41:12.380Z",
"canRent": false
}
```
[Get Business Details](/api-reference/phone-numbers/get-business-details) reads it
back at any time, and checks it afresh each time. The check usually finishes in a
few minutes; when `canRent` turns `true`, [Rent
Phone Number](/api-reference/phone-numbers/rent-phone-number) will issue an Indian
number. If the application comes back `rejected`, `reviewNote` says what to
correct, and sending again corrects that same application. Sending while one is
being checked, or after it was approved, is refused with `409`.
This needs a **full** key and a paid plan: the document goes to India's numbering
authority in your customer's name, so a build or read-only key cannot send it
(any key can read it). We never return the document itself.
### Marketing calls need a different number
Indian rules give every number a purpose, and it is worth knowing before you buy
one rather than after you build a campaign.
| What you are calling about | The number for it |
| - | - |
| Reminders, order updates, renewals, anything the customer is expecting | The numbers on this page — landlines, bought in a few clicks |
| **Marketing** — an offer, a promotion, anything selling | A **140** number, which is applied for in your business's name |
A 140 number is not sold from a list. It is issued to your business after you
register on the DLT portal, so choosing **Promotional (140)** when adding a
number shows you the steps rather than a search.
As both the *principal entity* and the *telemarketer* — the calls are yours,
so the registration is yours. Two to three working days.
Your GST certificate and both DLT registrations. We apply for the number and
hand you back the approval letter it needs.
On the same portal. Scripts are approved one at a time — a marketing call
says an approved thing, not whatever you type on the day.
Five to ten working days end to end. Get in touch and we will start it.
Check this before anything else: the telemarketer registration has to be in
**Maharashtra** or **Karnataka**. Registered anywhere else and we cannot get
you a 140 number today.
Until you hold one, a campaign marked *promotional* is refused rather than
quietly sent from a landline. That refusal is the rule working — it is the
number that carries the penalty, not us — and it names the number it is about
so you know which one to change.
## Using a number you already own
If your number is with **Plivo**, **Vobiz**, **Twilio** or **Telnyx**, you can
point it at your agent and keep it where it is. You go on paying them for the
number and we charge you nothing for it — we only answer it. **Twilio and Telnyx
work a little differently** — we set the number up for you; see
[Numbers from Twilio](#numbers-from-twilio) and
[Numbers from Telnyx](#numbers-from-telnyx) below. The steps here are for Plivo
and Vobiz.
**A Plivo, Twilio or Telnyx number needs your account's credentials before it
can answer.** Plivo and Twilio sign every call with your Auth Token, and we use
it to confirm each call really came from your account — without it, calls to
the number are refused. On Telnyx your API key is how we set the number up at
all. A Vobiz number answers without them; there, the credentials are only
needed for outgoing calls.
Choose **Import**, then say which carrier the number is with.
Paste the **Auth ID** and **Auth Token** from your Plivo console. Plivo signs
every call with your Auth Token, and that signature is how we tell a real
call to your number from somebody pretending to be one — so until we have it,
calls to the number are refused. The same keys are what your outgoing calls
go out on. (Vobiz needs nothing here to answer calls.)
Exactly as it is dialled, including the country code.
Set the address shown as the number's **Answer URL** in your carrier's
console, with the method set to **POST**. Copy the whole thing — everything
after the question mark matters, and a shortened address will not be
recognised.
For one of the two carriers the address is the same for every number and is
shown straight away, so you can set your console up first if you prefer. For
the other it carries a key unique to the number, so it appears once the
number is added. Either way that address belongs to the number, so it keeps
working if you later move the number to a different agent.
Back on the **Phone Numbers** page, choose the agent under **Inbound**.
**A Plivo number does not answer until its keys are saved.** If you added one
before the keys were asked for, its row on the **Phone Numbers** page says so —
open the row's menu, choose **Edit credentials**, and save them.
Until the Answer URL is set, the number rings and nothing picks up. Nothing on
this page can tell you that has happened, because the call never reaches us —
so if a test call goes unanswered, check the Answer URL first.
A number on any other carrier cannot be pointed at us. We check that each call
genuinely came from the carrier it claims to, and a call from anywhere else is
refused before it is answered.
### Numbers from Twilio
A Twilio number is set up the other way round from the two above. Instead of you
pasting an address into your carrier's console, **you give us your Twilio account
and we set the number up for you.**
The credentials box comes first, because Twilio is the one carrier where it
is needed before the number.
Both are on your Twilio console's home page. The Account SID starts with
**AC**. We check them with Twilio before saving, and the token is stored
encrypted and never shown again.
For example **+1 415 555 0100**. A number typed without a country code is
read as an Indian one, and we will then not find it on your account.
We confirm the number is on your Twilio account and point it at us there.
The dialog still shows the two addresses we set, in case you ever want to
check them or set them yourself.
On the **Phone Numbers** page, choose the agent under **Inbound**.
Pointing a Twilio number at us **replaces whatever it was set to**. If the
number already runs something of yours — an IVR, a forwarding rule — that stops
receiving its calls. If it had an address set, the dialog tells you what it was
so you can put it back, and removing the number here clears the address we set
and does not restore the old one. Import a number that is free to use.
We need your Auth Token because Twilio signs every call with it, and that
signature is how we tell a real call to your number from somebody pretending
to be one. **If you change your Auth Token in Twilio, save the new one** from
the number's menu (**Edit credentials**) — until you do, calls to the number
are refused and we tell you so.
A workspace works with **one Twilio account**. If you save a different Account
SID while Twilio numbers from the first account are still added here, we
refuse it — those numbers would stop receiving calls. Remove the Twilio numbers
first, then connect the other account. Saving a new Auth Token for the *same*
account is always fine.
If we could not set the number up for you — your Twilio account may have
restricted it — the dialog says so and shows what to set: in Twilio, open the
number and set **A call comes in** to the Voice URL and **Call status changes**
to the status callback, both **HTTP POST**.
What a Twilio number does not do yet: **answering-machine detection** (so no
voicemail message) and **transferring a call to a person**. Call recording works
on it — see [Recording calls on a number you brought](#recording-calls-on-a-number-you-brought).
### Numbers from Telnyx
A Telnyx number is set up for you too, and it takes **one API key** rather than
an ID and a token.
The key comes first, for the same reason as on Twilio: we need it before we
can find the number and set it up.
Create one under **API Keys** in the Telnyx portal. We check it with Telnyx
before saving, and it is stored encrypted and never shown again. Saving it
creates an application on your Telnyx account called **ThinnestAI voice
agent** — that is what answers your number and places your outgoing calls, so
leave it in place.
For example **+1 415 555 0100**. A number typed without a country code is
read as an Indian one, and we will then not find it on your account.
We confirm the number is on your Telnyx account and assign it to that
application. Nothing to paste.
On the **Phone Numbers** page, choose the agent under **Inbound**.
**Telnyx only calls the countries your account allows.** A new Telnyx account
allows the United States and Canada. To call India — or anywhere else — add the
country to your **outbound voice profile** in the Telnyx portal (Voice →
Outbound Voice Profiles) before you place calls; until then Telnyx refuses
them, and the call shows as failed here. Telnyx may ask you to verify your
account before it allows some countries.
Assigning a Telnyx number to our application **replaces whatever it was
connected to**. If it was connected to something else of yours, the dialog
tells you its name so you can move it back; removing the number here takes it
off our application and does not restore the old one.
A workspace works with **one Telnyx account**. A key from a different account
is refused while numbers from the first are still added here — remove those
numbers first. A new key for the *same* account is always fine, and if you had
deleted our application in Telnyx, saving a key makes a new one and moves your
numbers onto it.
If we could not assign the number for you, the dialog says so. In the Telnyx
portal, open the number and set its connection to the application called
**ThinnestAI voice agent**.
When we turn a call to a Telnyx number away — no agent set to answer it, or the
workspace out of balance — the caller hears a **busy tone** rather than a
sentence.
A Telnyx number does what a number from us does: it **records**, it **detects
answering machines** (so a campaign can leave a voicemail), and it **transfers a
call to a person**. Telnyx charges your Telnyx account a small fee for each call
it checks for an answering machine, and the call to the person you transfer to
goes on your Telnyx bill like any other call.
### Recording calls on a number you brought
With **Record calls** on, phone calls on a number you brought are recorded too,
whichever company it is with. Your carrier is not asked to record anything — your
agent hears both sides of the call and keeps the recording here, so it plays
with its call in [Usage](/workspace/usage), downloads, and is deleted on your
plan's schedule exactly like any other recording.
### A transferred call on a number you brought
When your agent puts a caller through to a person on a number you brought, **we
stop charging at the moment of the transfer.** From then on the two people talk
on your carrier's line, on your carrier's bill, with no agent involved. (On a
number you took from us the call is billed to its end, because we pay the
carrier for those minutes.)
### Calling out from a number you brought
Outgoing calls go out through the account the number belongs to — and for your
own number, that account is yours, even if your carrier happens to be the same
one we buy numbers from. So there is one more step, and only if you want
outgoing calls: in the same **Import** panel, add the **Auth ID** and **Auth
token** from your carrier's console. (For Plivo, Twilio and Telnyx you have
already done this — it came first.)
We check them against your carrier before saving, so a wrong pair is refused
while you are still looking at the page you copied it from rather than on a call
weeks later. The token is stored encrypted and never shown again.
You can change them later without re-adding the number: the row's menu on the
**Phone Numbers** page has **Edit credentials**. They belong to the carrier
account rather than to one number, so saving applies to every number you brought
from it.
The **Dial-out ready** column on that page says which of your numbers can
already place calls and which are still waiting for their credentials — a number
can answer perfectly while being unable to ring anybody, and that column is where
you would see it.
Once they are saved, calling campaigns and the call API work from that number
exactly as they do from one taken from us.
Answering needs none of this. If the number only ever receives calls, the
Answer URL above is the whole setup.
A number can answer for **one agent**. Typing a number another agent already
answers is refused rather than quietly moved — that is almost always a
mistake. Moving one deliberately is a different act: choose the new agent
under **Inbound** on the **Phone Numbers** page.
Whenever an agent is about to lose a number — moved to another agent, or set
back to no agent at all — we ask first and name the agent losing it. Giving a
number that nobody answers to an agent for the first time takes nothing from
anybody, so that one just happens.
**Getting, moving, renaming, releasing and removing numbers need an owner or
admin.** Everyone else on the workspace sees the list and what each number is
doing, which is usually what they came to check.
An agent's **Voice** page shows which number that agent answers, and links here
to change it. Getting a number and choosing who answers it both happen on this
page, because a number belongs to the workspace rather than to one agent.
## What you can do to a number
On the **Phone Numbers** page every row has a menu:
* **Edit credentials** — for a number you brought, the API credentials of the
carrier account its outgoing calls go through. Saving applies to every number
you brought from that account.
* **Rename** — a name for your own eyes, so a list of numbers reads as
"Bengaluru showroom" rather than six strings ending in different digits.
Callers never see it.
* **Forward calls** — send every call to this number to another phone, with
the agent not answering at all. See [Forwarding a whole
line](#forwarding-a-whole-line).
* **Move to another workspace…** — only for developers building on the
[customers API](/api-reference/customers): hand the number to one of your
customers, or back to your own workspace, keeping its digits. See [Moving a
number to another workspace](#moving-a-number-to-another-workspace).
* **Release** — only on a number you rent from us. See below; it is permanent.
* **Remove** — only on a number you brought. We forget it; you keep it. For a
Twilio number we also clear the address we set on it, and a Telnyx number is
taken off our application, so it stops ringing at us — but only if it still
points at us, so anything you have since pointed it at yourself is left alone.
You can also search the list, filter it by where the numbers came from, and
export what you are looking at as a spreadsheet.
### Forwarding a whole line
A number can send **every** call straight to another phone — your front desk,
your mobile — without the agent answering. It is meant as a fallback: if an
agent is not working, or the number has no agent at all, point it at a person so
the number never goes quiet, then clear the forward and the agent answers again.
* **It rings for 30 seconds.** If nobody picks up, the caller of a number from
us, Plivo or Twilio hears that nobody could take the call, and the line ends.
On a number brought from Telnyx the caller gets no spoken message: the line
rings out or returns busy, then ends.
* **A forwarded call has a time limit.** On a number you took from us it ends
when your balance can no longer pay for it, and never later than **20
minutes**, the longest any call can run. On a number you brought it ends after
**30 minutes**.
* **Who the phone shows as calling.** On a number from Twilio or Telnyx, the
original caller's number. On a number from us or from Plivo, your own number —
those lines may only present a number on the account.
* **It works whether or not an agent answers the number.** If an agent does, the
forward replaces it. If none does — no agent attached, the agent deleted, or
its **Answer calls** switched off — the forward answers the call itself. Your
plan, your call lines and your balance are checked first, as for any call, so
a workspace that cannot pay for a call, or has every call line busy, does not
forward it: the caller hears that the number is not answering.
* **What it costs** is what a call put through to a person costs: on a number
you took from us, every minute the caller is connected, at the standard voice
rate; on a number you brought, nothing from us — the minutes are on your
carrier's bill.
* **Not to one of these numbers.** A forward cannot point at the number itself
or at another number on the platform, which would send calls round in a loop.
* **Not to premium or special numbers.** Premium-rate, shared-cost and toll-free
numbers, satellite and international-network numbers, and a short list of
high-cost international destinations are refused
(`forward_destination_not_allowed`): a forward runs with nobody watching, and
those numbers charge by the minute to whoever dials them. An ordinary landline
or mobile is always fine. If you have a real need for one of them, tell us.
* **Nothing is said or sent after a forwarded call.** The agent never took it,
so there is no summary, no extracted details and no follow-up message — you
still get the `call.completed` webhook, with `forwardedTo` set, when an agent
answers the number. When no agent does there is no agent to send it for, so
the call shows in Usage, Get Call and the call list (with no agent) and not as
a webhook or an inbox conversation. Get Call and
the call list carry `forwardedTo` too, and `seconds` is how long the caller was
connected, even on a number you brought where nothing is billed. A forward
nobody picked up is reported as `missed`, with a `hangup` of `no_answer`,
`busy`, `rejected` or `unreachable`, and `seconds` 0. On a Telnyx number
a caller who hangs up while the phone is still ringing may be reported as a
completed call instead.
* **Not on Vobiz.** A Vobiz number cannot forward calls, so the setting is
refused (`number_cannot_forward`).
From the console it is **Forward calls** in the number's menu. From the API it
is `forwardTo` on [Update Phone Number](/api-reference/phone-numbers/update-phone-number):
```json theme={null}
{ "forwardTo": "+919812345678" }
```
Send `"forwardTo": null` to stop. A refusal says which rule stopped it in its
`code`: `forward_loop` or `number_cannot_forward`.
### Moving a number to another workspace
If you build on the [developer API](/api-reference/customers), a number can move
between your own workspace and your customers' — **keeping its digits**, so
nobody who has it saved needs a new one.
**In the console**, open the **Phone Numbers** page, open the number's menu, choose **Move to another workspace…** and
pick where it goes: your own workspace or one of your customers. The option is
there only for developers, and only on a number that can be moved. A refusal
says why (the number is on a call, an Indian number whose new workspace has no
approved business details, and so on).
**Through the API** (or the `move_phone_number` [MCP tool](/mcp/tools)):
```bash theme={null}
curl -X POST https://app.thinnest.ai/api/v1/phone-numbers/918041234567/move \
-H "Authorization: Bearer $KEY" \
-H "Thinnest-Workspace: org_…the customer that has it now…" \
-H "Content-Type: application/json" \
-d '{ "toWorkspace": "org_…the workspace it goes to…" }'
```
Leave the `Thinnest-Workspace` header out when the number is in your own
workspace. What happens:
* The number stops being any agent's line, and leaves every agent's spare
numbers, in the workspace it leaves.
* It arrives in the other workspace **answered by nobody**. Point it at an agent
there, as with a number you have just rented.
* **Rent** carries on from the next month in the new workspace. The month
already paid is not charged again — and since your customers are billed to
you, no money moves either way.
* **An Indian number** is held in the name of the business using it, so the
workspace it moves to must have its own business details approved first (see
[India needs your business verified first](#india-needs-your-business-verified-first)).
The move registers the number to that business.
* **A number you brought** moves only when both workspaces have the same
carrier account's keys saved. A Telnyx number set up for one workspace cannot
move this way: remove it and add it again in the other workspace.
* It is refused while the number is on a call, or while the same number is
already being moved.
* Any call forwarding set on the number is cleared: it was the old workspace's
choice.
* If the phone network cannot take the move, the number stays where it was,
answered by nobody, and you can try again shortly (`number_move_unavailable`).
* Repeating a move that already succeeded, to the same workspace, gives the same
result, so a lost response is safe to retry.
* A few long-held numbers were set up before workspace-level phone accounts
existed and cannot be moved this way; write to us for those.
Only workspaces of one developer can trade numbers. A key you gave one customer
cannot move its numbers anywhere.
## Inbound and outbound are separate
Every number has two pickers on the **Phone Numbers** page, and they are
independent:
| | What it decides |
| - | - |
| **Inbound** | Which agent **answers** when somebody rings the number. One agent, or nobody. |
| **Outbound** | Which agent **calls out** from the number — in campaigns, test calls and the call API. |
They can be the same agent, which is the ordinary case, or different ones.
Some things that is useful for:
* **A receptionist and a caller on one number.** One agent answers; a different
one makes the outbound calls, and the people it rings see your usual number.
* **A number that only calls out.** Leave **Inbound** empty and choose an agent
under **Outbound**. Nobody answers when it is rung, but the agent can place
calls from it.
* **An agent that only calls out.** It does not need a number of its own at all.
Give it a number under **Outbound** and it can run campaigns and test calls
straight away — we switch its phone on for you when you do.
**Inbound empty means nobody answers.** Whoever rings the number hears that it is
not taking calls — including somebody ringing back after a campaign call. A
number that is only lent out for calling is never answered by the agent calling
from it. If you want a number to answer, choose an agent under **Inbound**.
When you let a different agent call out on a number that another agent answers,
we ask first, because of exactly that: a customer ringing it back will reach the
agent that **answers** it, not the one that rang them.
### Numbers from different phone companies on one agent
An agent can call out from numbers on **different phone companies** — a
ThinnestAI number and one you imported from Twilio, say — like a phone with two
SIM cards. Each call goes out through **its own number's** company, on its own
account, and hangs up there too.
* **In the Playground**, **Call from** lists every number the agent can call
from, each with its company's logo. A number that cannot call yet (one you
imported without saving its credentials) is shown but greyed out.
* **Campaigns** spread their calls across all of them.
* **Some features depend on the company.** Transferring a call to a person and
leaving a voicemail work on ThinnestAI numbers and numbers from Plivo or
Telnyx, but not on Vobiz or Twilio numbers. Recording works on every number. A
call from a number that can't do one of these still goes out — that feature is
simply off for it, and the Playground and the campaign builder tell you before
you call.
One limit to know about: **a number you brought needs its credentials saved**
before an agent can call from it, the same as any number of yours that places
calls.
## Spare numbers for calling out
A calling campaign goes out from your agent's number. That is fine for a few
hundred calls and starts to hurt at a few thousand: phone companies and phones
themselves watch for a line that makes a burst of calls, and once a number is
marked as one, **everything it does afterwards suffers** — including a customer
ringing you back and seeing a warning on their screen.
Spare numbers spread that out. Give an agent several numbers under **Outbound**,
and campaigns share their calls across the agent's own number, if it has one, and
the others.
Any number on the page — one you bought and have not pointed anywhere, one
you brought and have not used yet, or one another agent answers.
Under **Outbound**, choose the agent. That is the whole setup.
**What it does not do.** A spare does not become the agent's number. Nothing
about who answers changes, the number you gave your customers stays the number
they ring, and you can take a spare back at any time by choosing the agent that
answers it, or **Nobody** if none does.
**One person always sees the same number.** Whichever of your numbers rings a
customer is the one that rings them again — including a second attempt days
later. Three attempts from three different numbers is what a nuisance call looks
like, and avoiding that is half the point, so it does not happen.
**Each number has a daily limit.** 200 calls a day, which is also the point:
without one, spreading calls over four numbers is the same burst four times
over. If a campaign uses up every number it says so, and carries on the next
morning — or sooner, if you lend it another.
A spare can be on a different phone company from the agent's other numbers. A
spare you brought needs its carrier credentials saved, the same as any number of
yours that places calls. The page says which of these is missing
rather than letting you find out on a campaign.
Changing who answers a number does not change who calls out on it. Moving or
clearing **Inbound** leaves **Outbound** exactly as it was.
A spare must be licensed for the same kind of calling as the campaign. In
India promotional calls need a 140-series number, so a campaign marked
promotional is refused if **any** of the numbers it would use is not one — and
it names which. It is the number that carries the penalty.
## Disconnecting, giving up, and removing
Three different things and only one of them is permanent.
**Disconnect** takes the number off the agent so it stops answering. The number
stays yours either way — your own number stays with your carrier, and one you
rent from us stays rented and still charged. It shows on the **Phone Numbers**
page with nothing under **Inbound**, ready to point at another agent. An agent
you had chosen under **Outbound** keeps calling out on it.
**Give up** hands a number you rent from us back to the phone company. The
monthly charge stops and the number goes back to the pool.
**Remove** applies only to a number you brought. It takes our record of it away,
so we stop answering it and stop listing it — the line stays yours and your
carrier keeps billing you for it exactly as before. Remember to unset its Answer
URL in your own console afterwards, or calls to it will still reach us and be
declined. Bringing it back later is the same three fields.
Releasing is permanent. The number goes back to the phone company and somebody
else can take it — we cannot get it back for you, even a minute later. If it
is printed anywhere, or anybody has it saved, keep it.
## What callers hear when nothing is set up
* **Answer calls off** — the agent does not answer. A caller on a Plivo, Vobiz or Twilio
number hears a short spoken message and the call ends; a Telnyx caller hears a busy tone.
You can write the message yourself ([When Answer calls is off](/channels/voice#when-answer-calls-is-off)).
* **Answer calls on, Phone number switched off** — the same: the agent does not answer. You
have switched voice on without telling it to pick up the phone.
* **Everything on, no greeting** — the agent waits for the caller to speak
first. That is a real choice, and some lines are better that way, but it
surprises people who expected a greeting.
## Things worth knowing
Two agents cannot both **answer** one number. If you want different behaviour
for different callers — sales and support, say — that is one agent with
instructions covering both, or two agents on two numbers.
Who *calls out* on a number is a separate choice, and an agent can call out
on several numbers — see **Inbound and outbound are separate** above.
Answering is still one to one.
The **Playground** has a **Try it on a real phone** box under the browser
call. Type your own number and press **Place call**: the agent rings you from
its number, so you hear exactly what a customer hears — a phone line, not a
browser. If the agent can call out on more than one number, a **Call from**
list lets you choose which, so you can hear a spare as well.
It is a real call and costs the same per minute as any other. The browser
call beside it is free and instant, which is the one to use while you are
editing what the agent says; this one is for hearing how it sounds.
When your agent rings somebody, it calls from its own number, or from one you
have given it under **Outbound**. That is what they see, and what they will
ring back — and it is answered by whichever agent is under **Inbound** on that
number, which may not be the one that rang them. See **Inbound and outbound
are separate** above.
How long those calls ring before they count as not answered is a setting of
the agent, not of the number: **Voice page → Ring time** in the console, or
`voice.ringSeconds` in the API (and `ringSeconds` on a single call). See
[How long it rings](/channels/voice#how-long-it-rings).
A normal business number is licensed for service and transactional calls.
Promotional calling in India needs a 140-series number, and using an
ordinary one for it can get the number blocked. See [Calling
campaigns](/channels/voice-campaigns).
Ring your own number and ask the hardest question you get — not "what are
your hours". The awkward one, with a condition attached, where the honest
answer is "it depends". That is the answer worth hearing out loud before a
customer does.
# SMS
Source: https://docs.thinnest.ai/channels/sms
Let your agent text customers in India — through your own SMS provider, under your own DLT sender ID.
Your agent can send a customer an SMS **while it is talking to them** (a booking
link mid-call, an address they asked for) and **after the call ends** (a thank-you
with your booking link, or a "sorry we missed you"). See
[Actions](/agent/actions#reaching-the-customer-on-a-different-channel) and
[After the call](/agent/after-the-call).
**SMS goes through your own provider account.** You connect your SMS provider, it
sends under your own sender ID, and **it bills you directly**. Nothing for SMS is
taken from your wallet here.
**SMS reaches Indian mobile numbers only.** Every business SMS in India has to
follow the DLT rules below, and those apply to Indian numbers. For customers
elsewhere, use [WhatsApp](/channels/whatsapp).
## What DLT asks of you first
India's telecom rules (TRAI's DLT) mean an operator only delivers a business SMS
that matches what you registered in advance. You register once on any one DLT
portal (Jio, Airtel, Vi, BSNL…), and it works on every network. If your business
already sends OTPs or order updates, you probably have all of this.
| You need | What it is | Where it comes from |
| - | - | - |
| **Entity (PE) ID** | Your business's registration on DLT, usually 19 digits | Your DLT portal, once your business is approved |
| **Sender ID (header)** | The six letters the message shows as coming from, like `ACMEIN` | Registered on your DLT portal |
| **Templates** | The exact words of each message, with `{#var#}` wherever the text changes | Registered and approved on your DLT portal; each gets a **DLT template ID** |
| **Your provider in your PE–TM chain** | Telling DLT which provider sends for you | Your DLT portal — add your provider's telemarketer ID |
**Do the PE–TM chain step, or every message is dropped.** Since 2024 operators
reject a message unless your DLT registration names the provider that delivered
it. Your provider's support team will give you their telemarketer ID if it is
not below.
## Providers you can connect
| Provider | You'll need | Templates also need | Telemarketer ID |
| - | - | - | - |
| **MSG91** | Auth key | MSG91's own template ID (from its dashboard) | `1302157225275643280` |
| **Exotel** | Account SID, API key, API token, region (`in` or `sg`) | — | ask Exotel |
| **Gupshup Enterprise SMS** | User ID, password | — | ask Gupshup |
| **Infobip** | Your API base URL (like `xxxxx.api.infobip.com`), API key | — | `110200001152` |
| **Vonage** | API key, API secret | — | ask Vonage |
| **Fast2SMS** | API key | Fast2SMS's own message ID (from its DLT manager) | ask Fast2SMS |
MSG91 and Fast2SMS send by **their own** copy of each template, so you add the
template on their dashboard too and paste their ID here. The others send your
template's text along with your DLT IDs.
Using a different provider? Tell us which one. We add a provider once we can test
its DLT sending API against its own documentation.
## Connect
Only owners and admins can change it.
Then enter your **DLT sender ID (header)** and your **DLT entity (PE) ID**.
Keys are sealed as soon as they arrive and never shown again. To change one
field later, leave the secret blank and the saved one is kept.
Under **DLT templates**, add each template you want the agent to use:
* **Name** — your own, like `booking_link`. The agent sees it, so make it say what the message is for.
* **Text** — copied from your DLT portal **exactly**, with `{#var#}` where it varies.
* **DLT template ID** — the number your DLT portal gave it.
* **Category** — Transactional, or Service (implicit or explicit). Promotional templates can't be sent by an agent.
* **Agent may send this during a call or chat** — tick it for the templates you want the agent to be able to use mid-call. New templates start unticked. The **after the call** message doesn't need the tick.
* **MSG91 template ID / Fast2SMS message ID** — only with those two providers.
The page shows how many blanks the template has and how many SMS it will be
sent as.
Under **Send a test**, send one template to your own mobile. A success marks
the provider **Working**; a refusal shows the provider's reason, so you can fix
it before a customer is waiting.
## Writing templates the agent can use well
Each `{#var#}` takes **at most 30 characters** when it is sent, so keep the
moving parts short: a first name, a date, a short link.
```text Good theme={null}
Hi {#var#}, thanks for calling Acme Clinic. Book your visit here: {#var#}
```
```text Good theme={null}
Your appointment at Acme Clinic is on {#var#} at {#var#}. Reply on WhatsApp to change it.
```
```text Won't work well theme={null}
{#var#}
```
A template that is nothing but a blank was never going to pass DLT, and the agent
cannot write free text into an SMS anyway. Every message is one of your approved
templates, with only the blanks filled in.
**Plain English fits 160 characters in one SMS.** Hindi, other Indian scripts
and emoji fit 70, so the same message may be sent as two or three SMS, and your
provider charges for each one.
## Using it
* **During a call or chat** — tick the templates the agent may use, then turn on
**Send the customer an SMS** on the agent's **Actions** page. See [Actions](/agent/actions#reaching-the-customer-on-a-different-channel).
* **After a call** — choose an SMS template in the **After the call** card on the
same page. See [After the call](/agent/after-the-call).
Every SMS the agent sends shows up as a note in that conversation, so your team can
see what went out.
## When something goes wrong
| What you see | Usually means |
| - | - |
| The provider's test fails with a DLT or template error | The text here doesn't match the DLT template word for word, or the DLT template ID is wrong |
| The test succeeds but nothing arrives | Your provider isn't in your PE–TM chain yet, or the sender ID isn't approved for that template |
| "SMS can only be sent to an Indian mobile number" | The customer's number is a landline or outside India |
| "MSG91 needs the MSG91 template ID" | Add the template on MSG91's dashboard too, and paste its ID |
| Can't change a template's text | An agent sends it after calls and fills a different number of blanks. Change that agent's **After the call** first |
## From the API
Everything above is also in the API: [SMS](/api-reference/sms/get-sms-provider).
```bash theme={null}
curl -X POST https://app.thinnest.ai/api/v1/sms/templates \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "booking_link",
"body": "Hi {#var#}, thanks for calling Acme Clinic. Book your visit here: {#var#}",
"dltTemplateId": "1207169876543210987",
"category": "service_implicit"
}'
```
# Telegram
Source: https://docs.thinnest.ai/channels/telegram
The quickest channel to connect — no approvals, no templates, no waiting.
Telegram is the one channel you can connect entirely by yourself, in about two
minutes. There is no business verification, no template approval and no 24-hour
window.
## Connect
Message [@BotFather](https://t.me/BotFather) on Telegram and send
`/newbot`. Choose a display name and a username ending in `bot`.
BotFather replies with a token that looks like
`123456789:AAF-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.
On the agent's **Channels** page, open Telegram and paste it.
That token controls the bot completely. Treat it like a password — anyone
holding it can read and send everything your bot does. It is sealed on
arrival and never shown again.
Open your bot in Telegram and press **Start**. The agent answers with the
same knowledge and instructions it uses everywhere else.
## What it can do
Text, buttons and images. Cards degrade to text with the buttons preserved.
Long replies are split across messages rather than truncated — Telegram rejects
a message over 4,096 characters outright, so splitting is the only behaviour
that does not silently lose the end of an answer.
## Opting out
A customer who asks the agent to stop is recorded as withdrawn, exactly as on
WhatsApp, and the agent stops replying to them.
## Two things it does not do
**Telegram does not join a customer's identity.** There is no phone number
unless the user volunteers a contact card, and matching on display name would
merge two different people called Priya into one memory. Telegram threads are
read once a customer has been linked by other means — they never do the
linking.
**Broadcasts do not send on Telegram.** A bot may message anyone who has
pressed Start, so this is buildable; it is simply not built. Only WhatsApp
carries a campaign today.
## Why start here
If you are evaluating the platform and do not yet have WhatsApp connected,
Telegram gives you a real, external channel to test against in minutes — a
proper end-to-end run rather than the Playground.
## Disconnecting
Disconnecting the bot stops it reaching you and frees the connection, so you can
put a different bot on the same agent afterwards.
**Your Telegram conversations and their messages stay.** Disconnecting removes
the connection, not the history — the transcripts remain in your
[inbox](/workspace/inbox) and in the record of what your customers were told.
# Voice
Source: https://docs.thinnest.ai/channels/voice
Your agent answers the phone — with the same memory of each customer it has everywhere else.
Voice is your agent picking up a call and holding a conversation out loud. It
listens, thinks and speaks, and the person on the line can interrupt it.
It is the same agent. The same knowledge, the same instructions, the same memory
of the same customer. Somebody who messaged you on WhatsApp last week and rings
you today is one person with one history.
Voice has its own page in the agent sidebar, under **Deploy** — not a tile on
the Channels page. Everything about it lives on that one screen.
## Turn it on
In the agent sidebar, choose **Voice**.
Off means the number does not answer at all. Nothing on the page runs until
this is on.
You can set everything up first and turn it on last. The settings are never
greyed out, because the worst version of this is a real caller reaching a
half-configured agent.
Open **Website** and switch on **Answer here**, and your agent answers calls
from the chat widget straight away — no number needed, on any plan.
To answer a number people dial, go to **Phone Numbers** in the main sidebar —
the Voice page links there too.
Take one from us — that needs a paid plan — or bring one you own with Plivo,
Vobiz, Twilio or Telnyx, which works on every plan including the free one. A
Plivo or Twilio number also needs your account's Auth ID and Auth Token before
it answers, and a Telnyx number your account's API key. Then choose this agent beside the number, and it starts answering.
See [Phone numbers](/channels/phone-numbers).
Ring the number. You should hear your greeting, then the agent.
Through the API: `voice.surfaces`, a list of `"web"`, `"whatsapp"` and `"phone"`. A new agent starts with
`["web", "whatsapp"]`, and `phone` is added when a number is attached. Adding `phone` while the agent answers
calls and has no number is refused (`phone_number_required`); with calls off you can set it first; while a number is attached, `phone` stays on — to stop it
ringing, switch off `answersCalls` or take the number off. Whether a WhatsApp number takes calls is its own
**Calls** switch on Connect WhatsApp.
## How the Voice page is laid out
The settings are in seven sections, with links along the top that jump to each:
| Section | What is in it |
| - | - |
| **Calls** | Whether the agent answers, the message callers hear when it is off, and where it answers |
| **How Agent speaks** | The rules it speaks by, and the lines it says while it works something out |
| **Listening** | Noise cancellation, how it decides a caller has finished, and when a caller can cut in |
| **Sound and recording** | Background sound and recording |
| **Ending calls** | Voicemail, do-not-call, how long an outbound call rings, hanging up when done |
| **Memory** | What it remembers from earlier conversations, and the summary after each call |
| **Call length and silence** | The longest call, and what happens when nobody speaks |
**Save changes** stays at the bottom of the screen however far down you are, and
what you save applies from the next call. On a wide screen **Test a call** sits
beside the settings and stays in view while you scroll, so you can change
something, call, and listen again; on a narrower screen it comes first, above
them.
## The settings
### Replies use
Which model does the thinking. Leave this on the recommendation unless you have
a reason to change it.
The list here is deliberately shorter than the one for chat. A model is only
offered if it can answer fast enough to hold a conversation — one that pauses to
think reads as a dropped call, not as a careful answer.
Your chat model is not used on calls. Voice is its own choice, because what
makes a good writer and what makes a good speaker are not the same thing.
### Voice
Which voice your customers hear. Press play on any of them to listen — reading
the name tells you very little, and the sample is the whole point.
Voices come in three tiers, on their own tabs:
The everyday voices, and the cheapest per minute. Natural enough that most
callers never think about it.
Warmer and more expressive, at a higher per-minute rate — and what a new agent
starts on (Kavya). Worth it on a line
where the voice is the first thing people judge you by.
The largest catalogue we offer — hundreds of voices — plus [your own cloned
ones](/channels/voice-clone). Highest per-minute rate, on the **Pro
plan and above**.
**The per-minute price sits on each tab**, so you can see what a choice costs
before you make it, and again beside **Voice** once you have chosen. A call is
billed at the rate of the voice answering it.
**Standard and Premium are open on every plan, including the free trial.**
Try a Premium voice on your first day if you want to — nothing is held back,
and the only difference is the per-minute rate shown on the tab.
The Studio tab is shown on every plan, and one voice on it — a professional
customer-support voice — can be **previewed on any plan** so you can hear what
the tier sounds like. Putting any Studio voice on your agent, including that
one, needs the **Pro plan or above**. Below Pro the tab is visible with an
upgrade button over the rest of it, rather than hidden — you can see what you
would be buying before you buy it.
On the Pro plan and above, record a short sample and your agent can speak
in it — a founder's own voice, or someone from your team. Up to 10 on Pro
and 20 on Scale. Full details on its own page.
If your workspace uses its own voice account, the voice comes from that
account instead, and a Cartesia voice can be tuned per agent: speed, volume,
emotion, the Sonic model and a pronunciation dictionary. See [Tune a Cartesia
voice](/api-reference/bring-your-own-keys#tune-a-cartesia-voice).
#### Speaking speed
How fast the agent talks, set on the agent's **Playground**, right under the
voice, with a **slider** from 0.7× (slower) to 1.3× (faster); the middle is
**Normal**, the voice's own pace, and **Reset to Normal** puts it back. If
callers say the agent rushes, or many of them are listening in a second language
or on a poor phone line, a little under Normal (0.9×) is usually enough — it
changes the pace, not the voice.
Most Standard and Premium voices follow it. Studio voices, cloned voices and
voices from your own voice account keep their own pace, and the setting has no
effect on them.
Through the API: `voice.speakingSpeed` (0.7 to 1.3, as a multiple of the voice's
own pace; `null` for normal).
Every voice speaks every language we support. The tier changes how a voice
*sounds*, never what it can say — which language it uses comes from your agent's
setting, covered below.
Through the API, `PATCH /api/v1/agents/{id}` with `"voice": { "voice": null }`
puts the agent back on the voice a new agent starts with (currently Kavya, a
Premium voice), and `"model": null` puts it back on the default model (Prana
\[Voice]). An empty string is refused: `null` is the way back.
### How Agent should speak
The rules the agent follows on calls, and only on calls. Your chat and WhatsApp
replies are untouched.
It comes filled in, so you are editing real sentences rather than facing an
empty box. Change what you like; clear the box entirely to go back to the
supplied wording.
Two of the supplied lines are not style, and are worth keeping.
**Write titles and abbreviations in full.** Speech is produced one sentence at
a time, and a full stop inside a name ends a sentence early — an agent that
writes "Dr. Iyer" can be heard stopping halfway through the name.
**No brackets or bullet points.** Nothing pronounces a bracket. An aside in
brackets is either read out as though it were the main sentence or lost. If
your agent writes one anyway, such as `[pause]` or `(waiting for the caller)`,
it is left out of what is spoken and of the transcript. Only short notes in
square brackets are left out: a figure such as `[1]` or `[Rs 500]` is kept,
and a link written as `[label](address)` is spoken as its label.
It replaces the supplied wording rather than adding to it, and on a call it is
read after your agent's own instructions. Up to 4,000 characters, counted
separately from the agent's instructions; a longer box is refused, never cut.
Through the API: `voice.callInstructions` (a string, or `null` to go back to the
supplied wording). The name must be exactly that; `voice.instruction` is refused.
### Opening line
The first thing a caller hears, in your agent's own voice, the moment the call
connects. It is set on the agent's **Playground**, under **What Agent says**, and
it is also the first message in the chat. Leave it empty and the agent waits to
be spoken to.
Keep it short. It is spoken, and a caller who hears a paragraph before they can
speak will talk over it.
### Earlier conversations
What happens when somebody who has spoken with your agent before calls again,
or is called — whether that was an earlier call from the same number, or a chat
on WhatsApp or your website.
* **Remember quietly** — the default. Your agent knows what was said before and
uses it, without mentioning it. Nobody has to repeat themselves.
* **Remember and recap** — your agent opens by saying you have spoken before and
what it was about, then asks whether they would like to carry on or talk about
something else. On a call you placed, it answers what they say first and
recaps right after.
* **Start fresh** — every call starts clean. Your agent is given nothing from
earlier calls or other channels, and never greets anyone as a returning
caller.
A [call back](#booking-a-call-back) the customer asked for is the exception:
it always carries on from the conversation they booked it in, whichever you
choose, and its opening line already says why it is calling, so it is never
recapped on top.
The recap is in your agent's own words and in the language of the call — there
is nothing to write. It names what you talked about, never what they told you:
no amounts, dates or addresses until they bring them up, because anyone can
pick up a phone. A WhatsApp conversation that is kept hidden until the
caller is confirmed (the **Remembering the customer across channels** setting
on the Actions page, see [how two channels become one person](/concepts#how-two-channels-become-one-person))
stays hidden — your agent only says you have been in touch there.
It only happens when there is something to recap, and only once per call. A
first-time caller is greeted as one.
### Who is calling
When somebody rings from a number that is in your **Contacts** — one you
imported, or sent through the API — your agent is told their name. It can use
it in the greeting and the instructions as `{{name}}` and `{{first_name}}`
(and `{{phone}}`), the same placeholders a campaign fills, and it is told in a
sentence that the caller is probably that person.
*Probably*, because the number a call arrives from can be faked. So the agent
is asked to confirm who it is talking to before discussing their account, the
caller's **email is never given to the agent**, and the call is kept on its
own line in your inbox rather than being filed into the contact's record. If
the agent is set to remember people across channels only once they are
verified, it is not given the name either.
On a call your agent **places** — through the API, a campaign or a call back —
none of that caution is needed: you chose the number. The call is filed on the
contact, and their name, phone and email fill the placeholders. Anything you
send in a call's own `variables` wins over the contact's fields.
### Who-is-calling lookup
Give this an https address of your own and, when somebody rings this agent,
we ask your system who it is **before the agent answers** — so you can hand
it the caller's name, their last order, their plan, whatever your CRM knows.
It is the inbound counterpart of the `variables` you send when you place a
call.
We `POST` this, signed exactly like a [webhook](/api-reference/webhooks):
```json theme={null}
{
"event": "call.started",
"callId": "CA0c7c…",
"surface": "phone",
"agentId": "ag_…",
"from": "+919876543210",
"to": "+918000000000",
"direction": "inbound",
"contact": { "id": "cust_…", "name": "Asha Menon", "phone": "+919876543210", "email": "asha@example.com", "externalId": "CRM-1183" },
"sentAt": "2026-10-09T10:30:00.000Z"
}
```
`surface` is `phone` or `whatsapp`. `to` is the agent's voice line, on a
WhatsApp call too. `contact` is the person in your Contacts with the caller's
number, or `null`. Answer within **two seconds** with:
```json theme={null}
{ "variables": { "name": "Asha", "plan": "Gold", "last_order": "A-1183 delivered Tuesday" } }
```
Up to 20 values, each cut to 150 characters, names lower-cased with spaces
turned into `_` — the same rules as a call's `variables`. They fill
`{{plan}}`, `{{last_order}}` and the rest in the greeting and the agent's
instructions, and they win over the contact's own fields.
If your system does not answer in two seconds, answers with an error, or sends
anything else, the call goes ahead without them. We never retry it, and the
caller only ever hears a little more ringing. A phone provider that repeats its
own request to us (it does, when it did not get our answer in time) asks your
system again **with the same `callId`**, so treat `callId` as the key and ignore
one you have already answered.
It is asked only for a call your agent is going to answer. A number you have set
to forward calls to a person is not announced to your system.
Set it on the agent's **Actions** page, in the **Who-is-calling lookup** card, or with
`voice.callStartUrl` on [Update Agent](/api-reference/agents/update-agent).
Saving a new address shows a **signing secret once** — copy it into your
receiver then, it is not shown again. Verify `x-thinnest-signature-v2` with it
as you would a webhook. Clearing the address switches the lookup off.
To replace the secret without changing the address, press **New secret** on the
Actions page, or send `"callStartSecret": "rotate"` in `voice`. The old secret
stops working at once, and the new one is shown once.
The address must be https, must not carry a username or password, and must not
point inside a private network. If signing is not available at that moment the
API answers `503` with the code `signing_unavailable` and saves nothing; try
again shortly.
**Duplicating an agent does not copy this address.** The copy gets no signing
secret of its own, so it opens with the lookup switched off and the Agents page
tells you to set it again on the copy. Do that on the copy's Voice page, and it
gets its own secret.
The lookup runs for phone and WhatsApp calls, and only on inbound calls. Treat
what it is sent like caller ID: `from` is the number the call claims to come
from. Before your system hands the agent anything sensitive, decide whether a
matching number is enough.
### Fillers before tool run
On, and worth leaving on. Checking your documents or calling one of your tools
takes a second or two, and this is what the caller hears instead of silence.
Leave **What Agent says** blank and the agent says it in whichever language it is
speaking — if you have not fixed a language, it works that out from the caller,
so somebody who rings you in Tamil is answered in Tamil from the first word.
Fill it in and it says exactly that, every time, in whatever language you wrote
it. Keep it to a few words: it is covering a pause of a second or two, and
something longer just leaves a new silence after it.
Turn it off if you would rather the line stayed quiet.
You can write up to eight lines, one per line, each up to 60 characters. The
agent picks among them, and only uses lines written in the caller's script — so
a Hindi line is said on Hindi calls and an English line on English ones. Words
of an Indian language typed in Roman letters are converted to that language's
script when you save.
Through the API: `voice.thinking` (on or off, on unless set) and
`voice.thinkingLines` (a list of up to 8 lines of 60 characters; `null` or `[]`
for the built-in lines in the caller's language). The name must be exactly that;
`voice.thinkingPhrase` is refused.
#### When it speaks
When your agent decides to look something up or run one of your tools, it says
its line **straight away**, while the lookup runs, rather than going quiet
first. If the answer still has not started a little later, it says one more
line, then waits for the answer. It never says more than that for one question.
With **Fillers before tool run** off, the agent says nothing extra while it
works, however long the lookup takes.
Two boxes under it set the timing. Leave either blank to use the standard.
| Setting | What it controls | Standard | Range |
| - | - | - | - |
| **How long Agent waits first** | Quiet time before the agent speaks on a slow reply | 900 ms | 200–5,000 ms |
| **How long before Agent says another** | The gap before a second line, and how long a lookup that is still running waits for it | 2,500 ms | 1,000–10,000 ms |
Set the second one longer if your lines take a while to say, so the second
never lands on top of the first. A value outside the range is pulled back into
it when you save.
Through the API: `voice.thinkingDelayMs` and `voice.thinkingRepeatMs` (whole
milliseconds; `null` for the standard). The API refuses a value outside the
range instead of pulling it in.
#### Fillers after the customer stops speaking
Off unless you turn it on. When on, the agent says a short acknowledgement
("Okay.") the moment the caller stops, before **every** reply, rather than only
when a reply is slow. It suits a slower model; on a quick one it puts a syllable
in front of everything.
Through the API: `voice.thinkingAtTurnEnd` (`false` unless set).
## What language it speaks
Voice follows the language on your agent — set it on the **Playground** or
**Settings** page. Set the agent to Marathi and it answers calls in Marathi
**only**, and the speech recogniser listens for Marathi too.
Leave it on **\[Auto] Match the customer** and it works out what they are
speaking and answers in that. The recogniser then has to decide the language
afresh for every sentence, so a short or mixed reply can be heard as the wrong
one. On a line where people open in English, Hindi or a mix of both, this is
usually the right setting; if your callers all use one language, fix it.
An agent has one language. There is no "second language" setting: to serve two,
leave the agent on **Match the customer**.
## Where calls appear
Each call is its own conversation in the inbox, with its transcript, alongside
that customer's chats. One customer record, one memory, one history.
A call is genuinely a different thing from a message thread — it has a start, an
end and a duration — so it reads as its own entry rather than being appended to
a chat.
### The call log
**Usage → Calls** lists every call the workspace has made or taken, newest
first, with the transcript and the recording behind each row. Each one says how
it actually ended rather than just whether it finished:
| Outcome | What happened |
| - | - |
| Answered | A person picked up |
| No answer | It rang out — the commonest by far, and what retries are for |
| Line busy | Engaged. Worth trying later; they are near their phone |
| Declined | They pressed decline. Never called again automatically |
| Not reachable | No such number, barred, or out of service |
| Answering machine | Voicemail took it — see [Hang up on voicemail](#hang-up-on-voicemail) |
| Ended before the agent spoke | Something picked up, but the call ended before your agent started — usually somebody who hung up while the voicemail check was listening, or a caller who put the phone down straight away |
On an outbound call the row also shows **which of your numbers placed it**,
which is the question worth asking once an agent has spare numbers to call from.
Export gives you all of it as a spreadsheet.
A lot of *Not reachable* usually means a stale list. A lot of *Declined*
usually means the wrong audience or the wrong opening. A lot of *No answer* is
usually the wrong time of day. That is the whole reason the reason is shown.
Open a call to see **How the call went** — how long the caller waited for
each reply, silences, interruptions, and any turns the agent could not answer
or that a backup answered — and **Problems on this call**, if anything went
wrong while it was live. The same figures come through the API as `quality` on
[Get Call](/api-reference/calls/get-call), and each problem is sent as a
`call.problem` [webhook](/api-reference/webhooks#problems-on-a-live-call) while
the call is still going on. See [Usage](/workspace/usage#how-a-call-went).
### Call summaries
Off unless you turn it on, under **Summarise each call** on the Voice page.
With it on, two or three sentences are written once a call ends — what the
customer said and how it finished — and they appear as a **Summary** tab on the
call in **Usage → Calls**, beside the recording and the transcript.
The transcript was always there and always free. What this buys is the time:
forty calls to get through is forty transcripts to open, or forty pairs of
sentences to skim.
It is written from the transcript afterwards by a small model, so it is a
reading of the call rather than a record of it. The transcript is what was
actually said, and it is still there underneath.
**A summary covers one call, not the customer.** Somebody rung three times
has three calls in the log and three separate summaries. The whole history
with that person is the conversation in the inbox.
If your agent writes into a [spreadsheet](/agent/spreadsheet) and you have made
a column called **Summary** — or Notes, or Remarks — the same switch fills that
too. Nothing is summarised anywhere while it is off, including into that
column: making the column does not start it.
## Your knowledge on calls
Your agent searches your knowledge base on calls, the same as it does in chat.
Both are on by default.
They are **two switches**, on the **Actions** page, because looking something up
costs a little more on a call than on a page — the agent has to find the answer
before it can start speaking, so the caller hears a short pause. It is a pause,
not a wait, and for most businesses being right is worth it.
Turn the calls one off if you want the quickest possible line and your callers
mostly ask things your instructions already cover. Changing one switch does not
change the other.
**The agent says something while it looks.** When it has to check your
documents — or reach any of your connected tools — it says a short "one
moment" first rather than going quiet. A caller cannot see an agent thinking,
and a few seconds of silence on a phone line reads as a dropped call.
It is on to begin with, and it is yours to change under **Fillers Before Tool
Run** on the Voice page.
## Booking a call back
"Can you call me after five?" — the agent books it, and your number rings them
at that time. It works on calls and in WhatsApp chats, because that is where
people ask.
Say it however you like: *in half an hour*, *after five*, *tomorrow morning*.
The agent repeats back the time it booked, so nobody is left guessing.
**It only promises what it can keep.** If it cannot book one it says so plainly
instead — and there are a few reasons it might:
| Why | What the agent says |
| - | - |
| The agent has no number that can dial out | It cannot arrange a call and offers to help now |
| We have no number for that person | It asks for the best number to reach them on |
| Sooner than five minutes away | Too soon — a redial is not a callback |
| Further ahead than two weeks | Too far ahead to promise |
| Three already booked for them | It confirms the ones already booked instead |
**Outside your calling hours, it moves the time and says so.** "Call me at
eleven tonight" becomes nine tomorrow morning, out loud, on the call — never
silently, because somebody waiting by the phone at eleven is worse than being
told. The hours are checked again when the call is actually placed, so a
callback held up for any reason still cannot ring at a rude hour.
The booking appears on the conversation in your inbox, and when the call
happens it lands in that same thread — the request and the call it produced,
in one place. Everything still waiting is listed together on
[Voice Campaigns](/channels/voice-campaigns), with who, when, and what the agent
said it would call about — cancel one there if it is no longer needed, any time
before it goes out. Somebody who asks you to stop contacting them between booking and
the call is not rung.
Switch it off per agent under [Actions](/agent/actions) if you would rather
your team called people back themselves. With it off, the agent tells anybody
who asks that it cannot book one rather than promising a call nobody will
make.
## "Don't call me again"
When a caller asks not to be called again — "don't call me", "remove my
number", in any language — your agent puts their number on your
**do-not-call list** itself, tells them they will not be called again, and may
then say goodbye and end the call. It does this only when the caller clearly asks
that you not call them again. It does not do it when someone just wants to end
this call ("that's all", "you can hang up", "thank you, bye"), and not for "not
now", "call me later", "I'm busy" or "wrong number"; those are about this moment,
not about every future call. When it is not sure what the caller means, it is told to ask
"Would you like us not to call you again?" first.
From then on **no outbound call rings that number**: a call placed by the API is
refused, a batch refuses that entry, a calling campaign skips the person and
records why, and a call back already booked is given up. If they ring you, the
agent answers as usual.
The number is the caller's own caller ID, so this works on **phone calls and
WhatsApp calls** where that number is known. On a web call, or when the caller
withholds their number, the agent says honestly that it could not note it here
and tells them to ask the business directly to be removed from its calling
list.
The list is the one on the **Contacts** page under *Do not contact*. An entry the
agent made reads *Asked on a call*, with what the caller said; remove it there
(an admin) or with the API if the person later asks to hear from you again. A
developer can also read, add and remove numbers with the
[do-not-call API](/api-reference/do-not-call/list-do-not-call-numbers), and hear about each
new one through the `contact.opted_out` [webhook event](/api-reference/webhooks).
This works on every voice agent, whatever else is switched on. It is separate
from WhatsApp marketing consent.
To take this away from one agent, switch off **Do-not-call list** on its Voice
page, or send `voice.recordDoNotCall: false` to the
[agent API](/api-reference/agents/update-agent). The agent then never adds
anyone to the list itself; everything you add by hand or with the API still
works. It is on by default.
## Ending the call
Your agent can hang up. It does so when the conversation is genuinely
finished — after it has said goodbye and there is nothing left to do — and after
it has told somebody with an emergency to hang up and call their local
emergency number.
**The closing line is always spoken first.** The call ends when the agent has
finished talking, not part way through.
It is deliberately reluctant. It will not hang up because somebody went quiet,
because it could not help, or because the caller sounds annoyed — a person
thinking, hunting for an order number, or talking to somebody else in the room
is still on the call. If it cannot help, it says so and offers to have a person
follow up, and stays on the line.
Every voice agent decides this for itself, and the call record shows a short
note saying why the call ended.
If your agent says a goodbye and forgets to end the call, we end it for it. It
recognises goodbyes in English, Hindi, Telugu, Tamil, Kannada, Malayalam,
Bengali and Marathi. A plain "okay" or "thank you" never counts as one.
### Releasing the line
**Hang up when the conversation ends**, on the Voice page, starts **off** on a
new agent, whether you create it in the console or through the API. Switch it on
when you want the line released as soon as the agent is done. An agent made
earlier that has never had this setting saved still hangs up when its
conversation ends, and the API reports it as `true`.
It exists because a finished conversation and a finished *call* are not the
same thing. The agent saying goodbye does not put the phone down — somebody has
to, and if it is not us it is the caller. Most callers do. The ones who set the
handset on a desk and walk away leave a line open, and **you are charged for
every minute of it**.
With it off, the caller is the one who ends every call: if they never hang up,
the call stays connected and **you keep being charged until it ends**. Leave it
off only if you want the caller to be the one who ends every call.
Through the API: `voice.endCallOnFinish` (`true` or `false`).
**On a number you brought yourself, this needs your carrier keys.** Ending a
call is an instruction to your carrier, and your carrier will only take it
from an account it recognises. Without those keys your number answers
perfectly and we cannot hang up — the line stays open until the caller does
it. Add them under **Bring your own number** and the switch starts working.
Numbers you rent from us are unaffected. We already hold the account.
### Your own limits
Under **When the call ends** on the Voice page. Leave any of them empty and that
rule is off — none of them is on to begin with.
Through the API:
| Field | What it is | Unless set | Range |
| - | - | - | - |
| `voice.reaskAfterSeconds` | After the agent asks a question and hears nothing: how long before it asks the question again, once | `null` (2.5 s) | `0` (never) or 1.5–10 s |
| `voice.nudgeAfterSeconds` | Silence before the agent asks whether the caller is still there | `null` (never asks) | 5–180 s |
| `voice.nudges` | How many times it asks | 1 | 1–5 |
| `voice.nudgePhrase` | What it asks | `null` ("Are you still there?" in the caller's language) | up to 120 characters |
| `voice.endAfterSilenceSeconds` | Silence after which the call ends | `null` | 5–180 s |
| `voice.endPhrases` | Phrases that end the call. They are kept in lower case without punctuation, and a phrase with fewer than 2 characters left after that is refused | `[]` | up to 12, each 2–60 characters |
When the agent also asks, the call ends no earlier than
`nudgeAfterSeconds × (nudges + 1)`, so the caller always has time to answer the
last question. Reading the agent back shows `endAfterSilenceSeconds` as you set
it, even when a call runs longer because of this. A value outside a range is refused.
The agent starts wrapping up before your limit and ends the call at it, so a
long call finishes with a sentence rather than a dead line. Calls stop at
twenty minutes whatever you set.
When the agent ends on a question — "Can I take two minutes of your time?" —
and hears nothing back, it asks that question again after **2.5 seconds**,
once, in its own words and language. A soft or distant answer can slip under
the microphone, and a caller who was not listening gets a second chance. It
never asks over a caller who is still making a sound, never repeats its own
repeat, and never repeats "Are you still there?".
Change the wait under **If a question gets no answer, ask again after**: 1.5
to 10 seconds, or 0 to turn it off. The "Are you still there?" timer below
carries on as before.
Two settings, and the pair is the point. A twenty-second pause is usually
somebody finding an order number, so the agent **asks** before it gives up:
"Are you still there?", up to five times, and only then does the call end.
Write your own wording or leave it blank, in which case it asks in whichever
language it is speaking. **The count starts again the moment the caller says
anything**, so a long call with several natural pauses in it never creeps
towards hanging up.
Ten to twenty seconds suits most support lines. Give people longer where
they are working something out — and remember two or three of those seconds
are audio still being processed, so five is really nearer eight.
Leave **End the call after** empty and the call ends once the **last ask
goes unanswered** — the agent never sits on a silent line after it has
stopped asking. With neither set, a call where **nobody says anything for
five minutes** is ended anyway, so a line somebody walked away from does not
run on to the twenty-minute limit.
Your own phrases, separated by commas — "bye", "that's all", "thank you
goodbye". The agent still says its closing line first.
**Short words match inside longer ones.** "bye" would also end a call on
"goodbye for now, but first…", so prefer whole phrases.
Matched in any language, ignoring capitals and punctuation, anywhere in what
the caller says. Up to twelve phrases, each 2 to 60 characters.
### Handing a call on
**The agent can put a caller through to a person — on a phone call.** On the Actions page,
turn on **Hand over to a person**, choose **Call transfer** and give the number (with its
country code). When a caller asks for a person, the agent says a line you choose and connects
them; the number rings for 30 seconds, and if nobody answers the caller is told the team will
get back to them. It transfers on **phone calls** over a number we rent you, or a Plivo or
Telnyx number you brought; on web and WhatsApp calls, and on carriers that cannot transfer, it
hands over as a **chat** transfer (your team is told by email, Slack or webhook and the agent
says somebody will follow up). Through the API: [`PATCH /agents/{id}/tools`](/api-reference/tools/update-built-in-tools)
with `handOver` and `tools.escalate_to_human`. Details and billing: [Phone numbers](/channels/phone-numbers).
**Cold or warm.** Under **Call transfer**, choose how the person is reached:
* **Cold** (the default): the phone rings and the caller is connected as soon as someone answers.
* **Warm**: the person who answers first hears a short spoken summary of the call — who is
calling and what they need — and only then is the caller connected. The caller keeps hearing
the phone ring meanwhile. The agent writes the summary as it hands over; if it writes none, the
summary is made from the caller's last few sentences. It is read in the agent's language when
the agent is set to one we can speak (Hindi, Bengali, Gujarati, Kannada, Malayalam, Marathi,
Odia, Punjabi, Tamil, Telugu or English), otherwise in English.
With **Warm** you also choose:
* **Press 1 to accept**: after the summary the person hears "Press 1 to take this call", and the
caller is connected only when they do. If they do not press it in time, it counts as not
answered and the caller is told the team will get back to them — so a voicemail or an
automatic attendant never takes the call.
* **Connect after the summary**: the caller is connected as soon as the summary has played.
**Press 1 to accept works on numbers we rent you and on Plivo numbers you brought.** On a Telnyx
number the person hears the summary and the call connects; saving **Press 1 to accept** on an
agent that answers on a Telnyx number is refused with that reason. Through the API: `handOver.transferMode`
(`cold` or `warm`) and `handOver.acceptKey` (`true` or `false`).
**The agent can also pass a call to another of your agents — sales to support, say.** On the
Actions page, under **Hand calls to another agent**, add the agents it may pass calls to and say
when, in a sentence ("the caller has a problem with an order they already placed"). Each must
answer calls (voice switched on). When the caller needs one of them, the agent says it is passing
them over, and the other agent takes the call — its own instructions, knowledge, actions and
model — on the **same line**, in the **same conversation**. It works on phone and website calls.
The **voice stays the same** for the whole call.
Each agent on the list is handed over **warm** or **cold**:
* **Warm** (the default): the other agent gets a short summary from the first one and the
conversation so far. Its first reply opens with one sentence saying who it is and showing it
knows why the caller is there, and it does not ask again for what the caller already said.
* **Cold**: the other agent starts fresh. It does not see the conversation before the hand-off,
and greets the caller straight away with its own opening line, as on a new call.
Either way your team sees the whole call as one conversation.
* Up to **3** hand-offs per call, and never back to an agent already on it.
* The call is billed once, as one call, at the rate it started at. If the other agent would
change that — it is set to run on different keys than the call started on (your own keys versus
ours), or it uses a premium model on a call that started at the standard rate — the agent keeps
the call instead of passing it.
* The hand-off is noted in the conversation for your team, and listed as `handedTo` on
[Get Call](/api-reference/calls/get-call) and `call.analysed`, with its `mode` (`warm` or `cold`)
and, when warm, the `summary` the other agent was given. It is not a hand-over to a person, so it
does not send `conversation.escalated`.
Through the API: [`PATCH /agents/{id}/tools`](/api-reference/tools/update-built-in-tools) with
`handOver.agents: [{ "agent": "ag_…", "when": "…", "mode": "warm" }]` — it replaces the list; `[]`
clears it. Leave `mode` out and an agent already on the list keeps its setting; a new one is warm.
Saving a list that names an agent whose voice is off is refused until that agent is removed from
the list. An agent that turns its voice off after it was added stays on the list and is skipped
on calls.
## Recording calls
Off unless you turn it on, under **Record calls** on the Voice page. With it on,
the calls this agent answers or places are recorded — on your website and by
phone — and each recording sits with its call in [Usage](/workspace/usage).
**Website calls** are recorded on every agent that has it on. A caller sees
*This call is recorded* as the call connects, before anybody speaks.
**Phone calls** are recorded on every number — one you took from us, and one
you brought from any phone company. On a number you brought, your agent keeps
the recording itself, so nothing has to be switched on at your carrier — see
[Phone numbers](/channels/phone-numbers#recording-calls-on-a-number-you-brought).
**If a caller asks whether the call is recorded, the agent tells the truth** —
yes when Record calls is on, no when it is off — in the caller's language. Your
call rules cannot change that answer. It says "recorded" only on phone and web
calls with Record calls on, never on a WhatsApp call, which is not recorded.
**Recordings are deleted when your plan says** — after 30 days on
pay-as-you-go, 49 on Pro and 75 on Scale. Download anything you need to keep before
then. Once it is deleted it is gone, and we cannot get it back.
Erasing a contact deletes their recordings along with their calls — the ones we
store, the phone provider's copy on a number you took from us, and any copy made
for model training — and a deletion that cannot finish at that moment is retried
every night. See [Your data: erasure and training](/workspace/data-and-erasure).
**Recordings and transcripts are never used for training on Pro and above.** On
pay-as-you-go they may be, and a workspace can be excluded on request.
Telling people they are being recorded is your responsibility. On a website call
we show the line above for you; on a phone call, in most places it has to be said
at the start of the call, and the wording that satisfies your regulator is not
something we can write for you — put it in your agent's first words.
## Interrupting
A caller can talk over the agent and it stops. This is how people actually use
phones, and an agent that finishes its sentence regardless sounds like a
recording.
### Words needed to cut the agent off
**Three by default.** The noises people make while they are listening — "okay",
"achha", "mm", "right" — are not attempts to interrupt, and an agent that stops
dead at every one of them is exhausting to talk to. Somebody agreeing with you
should not be treated as somebody cutting in.
Raising this never makes the agent slower to answer. The count applies only
while it is speaking; the moment it falls quiet, a single word starts your
caller's turn as it always did.
The trade is worth knowing. At three, a caller who says a single **"no"** or
**"stop"** while the agent is talking is not heard — the agent finishes its
sentence. In practice somebody trying to interrupt keeps going, and "no, stop
there" gets through on the third word. But a one-word command does not.
Lower it to **one** if that matters more to you than backchannels — a line
people ring when something has gone wrong, where the first thing out of their
mouth should stop the agent dead. The cost is that "okay" and "achha" stop it
too. Raise it if callers keep getting cut off mid-thought while the agent talks.
Through the API: `voice.interruptWords` (1 to 6; `null` for three).
**On a website call, the agent stops while the caller is still talking.** Once
somebody has talked over it for about as long as it takes to say that many
words, it stops right there, rather than finishing its sentence and stopping
only when they do. Anything shorter — a hum, a quick "okay" — still changes
nothing. If what stopped it turns out not to be words at all (a television,
someone else in the room), the agent asks the caller to go on rather than
waiting in silence. On a phone call the agent stops as soon as the caller's
words are heard, which is just after they finish.
**With Ear, the agent can stop while the caller is still talking, on the phone
too.** When **How agent knows the caller has finished** is set to **Ear**, the
speech recogniser listens while the caller speaks, and the agent stops as soon
as it has heard that many of their words, or a clear "wait" or "stop", usually
a second or two into their sentence. It ignores its own voice coming back down
the line, so it does not cut itself off, and a hum or "haan ji" still changes
nothing. When the recogniser cannot make out the words that early, the agent
stops just after the caller finishes, as it does in the other modes.
However it is stopped, the agent goes quiet at that moment. On some voices it
used to carry on for up to half a second first.
When a caller cuts the agent off, the agent knows its last sentence was left
unfinished: it answers the caller first, and goes back to the unfinished part
only if it still matters. The transcript is corrected to the part the caller
actually heard whenever that can be worked out; in rare cases the transcript,
and a summary made from it, may still show the full reply.
### Never interrupt the agent
Off unless you turn it on. When on, nothing the caller says cuts the agent off,
on any line, however long — for a disclosure, terms or a price that must be read
out in full. It still answers a single word once it has finished speaking, so it
is never slower to reply. It covers the greeting too, which makes **Let the
greeting finish** unnecessary while it is on.
Through the API: `voice.neverInterrupt`.
### Answer what was said over the agent
On unless you turn it off. Words said while the agent is talking, too few to cut
it off, are kept and answered as soon as it stops, so nothing the caller said is
lost. Off throws them away — and if that was all the caller said, the agent
finishes and waits in silence.
Through the API: `voice.holdShortSpeech`.
### Let the greeting finish
Off unless you turn it on. On a phone, most callers say "hello" as they pick up
and cut the greeting off a second in; with this on, the greeting plays to the
end on phone calls and what they said is answered next. On the website it is
left alone, because a visitor expects to be able to talk over it.
Through the API: `voice.greetingUninterruptible`.
### How long a pause ends the caller's turn
Under **How agent knows the caller has finished**, you choose how the agent
decides a caller is done talking: **Smart** (a model decides, and does not
interrupt people who pause mid-sentence), **Timer** (a stretch of silence ends
the turn: faster, and it does interrupt) or **Ear** (real-time speech recognition,
below). Leave the choice on **Whatever the platform is set to** unless you have
a reason: that is **Timer** today, and the Voice page shows it.
#### Ear: real-time speech recognition
In the other modes the agent's speech recognition hears each thing the caller
says once they have stopped. **Ear recognises speech in real time**, the whole call
through, and that changes four things:
* **Callers can cut the agent off mid-sentence, on the phone too.** It stops as
soon as it has heard enough of their words, or a clear "wait" or "stop",
rather than once they have finished. The agent's own voice coming back down
the phone line is not mistaken for the caller. [Interrupting](#interrupting)
* **It tells a listener from an interruption.** "Haan ji, bilkul, theek hai"
said while the agent is talking lets it carry on, however many words it is;
"no, how much is it for a year?" stops it. It reads the words against what the
agent was saying, so a "haan" that answers the agent's question counts as an
answer.
* **It waits through a pause mid-thought.** A caller who stops for a second
halfway through explaining something is not answered on half a sentence: the
agent keeps listening, and may say a short "haan ji" or "okay" in the caller's
language to show it is following. On a website call it says it in the short
gaps while the caller is still talking (once they have been going for a few
seconds, never more than twice in one turn); on a phone call, only at a longer
pause, because the line can carry the agent's own voice back as if the caller
had said it. That acknowledgement is not part of the conversation record.
* **It knows sooner when the caller has finished**, because the end of their
speech is detected as it happens rather than after a timer.
You can tune the first two on the Voice page, under **How agent knows the caller
has finished**, whenever Ear is the mode in use. Each has a switch and a box for
your own words, one per line; leave a box empty and the agent uses its own
defaults.
* **Backchannel detection** (on by default) switches the first behaviour. With
it on, **Caller backchannel words** are words that always mean the caller is
listening, such as "haan ji", "bilkul", "theek hai"; a caller who says only
those is never taken as interrupting (one per line; commas also separate, up
to 30 entries of 40 characters). With it off, only **Words needed to cut the
agent off** decides, and that box appears right there.
* **Agent speaks backchannel / acknowledgement while the caller explains** (on by
default) switches the second. **Agent backchannel phrases** are what it says,
taken in turn ("haan ji", "achha", "okay"), one per line and each said whole,
commas included (up to 30 lines of 40 characters); empty means a short word in
the caller's own language. A box over those limits is refused when you save.
**Ear costs nothing extra:** a call is billed at the rate of the voice answering
it, in Ear mode as in any other. If the agent uses [your own speech-recognition account](/api-reference/bring-your-own-keys)
and it cannot listen in real time, the agent uses Timer instead.
Through the API, the Ear behaviours are `voice.backchannelDetection`,
`voice.backchannelWords`, `voice.acknowledgements` and
`voice.acknowledgementWords` (the lists take up to 30 entries of 40 characters;
`null` or `[]` is the default).
Through the API: `voice.turnDetection` is `"smart"`, `"timer"` or `"ear"`, and
`null` follows the platform's choice, which is Timer today (a read shows
`null` until you choose one). Anything else is refused.
The box under it takes 0.2 to 3 seconds. With Timer it is **Silence that ends a
turn**: how much quiet means the caller has finished (0.25 seconds when the box
is empty). With Smart it is **Stop waiting after**: the longest the model may
keep waiting once it thinks a sentence is unfinished, and a ceiling rather than a
delay (0.6 seconds when the box is empty). Shorter answers sooner; longer gives
slow speakers room but makes every reply arrive later. A value outside the range
is pulled back into it when you save in the console.
Through the API: `voice.turnSilenceSeconds` (`null` for the standard, and a read
shows `null` until you set a number). The API refuses a value outside 0.2 to 3.
## Background sound
**Off, and most numbers should leave it off.** A clean line is what a support
number sounds like, and silence behind a voice is not a fault to be fixed.
Turned on, the caller hears a room behind the agent — an office, with the
distant keyboards and voices of a working desk. It can make a call feel like it
is happening somewhere rather than nowhere.
It can also do the opposite. The same sound that reads as a busy office to one
caller reads as a bad connection to another, and which one you get depends on
your business, your caller, and the handset they are holding. Ring your own
number and listen before you leave it on.
Through the API: `voice.ambience` — `"office"`, `"office-quiet"`, or `null` for
none.
### How loud it sits
Quiet. Ambience works when nobody notices it and would notice its absence —
if a caller can point at it, it is too loud, and one who has to strain past it
to hear your agent would have been better off with silence.
The default is a good starting point. The slider stops well below your agent's
own volume on purpose: there is no setting here that turns the room into
something a caller has to talk over.
Through the API: `voice.ambienceVolume` (0.05 to 1, with at most two decimal
places; `null` for the default of 0.15).
## Calling customers
Your agent can also ring people, from your own systems — a reminder the day
before an appointment, a call when an order is ready, a follow-up after a visit.
There are two ways to start one. For a list of people, use a [calling
campaign](/channels/voice-campaigns) — **Voice Campaigns** in the main sidebar,
where you pick which agent does the calling.
For one call at a time, from your own systems, it is one request to
[the calls API](/api-reference/calls/place-call), which takes the reason for the call
as words the agent speaks first:
```json theme={null}
{
"to": "919876543210",
"purpose": "I'm calling from Sunrise Clinic about your appointment with Doctor Iyer tomorrow at four."
}
```
You can only call people who are **already your contacts** and who have not
asked you to stop. Those are our rules and they are not the law — automated
calling is regulated nearly everywhere, and having a lawful basis for each
call is yours to get right.
### How long it rings
A call your agent places rings for **30 seconds** before it is given up. Change
it in the console under **Voice page → Ring time** (10 to 60 seconds; leave it
blank for 30), or through the API with `voice.ringSeconds` on [the
agent](/api-reference/agents/update-agent). To change it for one call only, send
`ringSeconds` with that call on [Place Call](/api-reference/calls/place-call).
It is a setting of the agent, not of a number, so it applies to every call the
agent places whichever number it dials from: API calls, campaigns, call-backs
and test calls.
A call nobody answers within that time is reported as **`no_answer`** — the
`hangup` of the `call.completed` webhook — and a campaign or a call with
`retry` rings it again as one. Much past 30 seconds, many networks hand the
call to the person's own voicemail, which is a machine picking up, not them.
### Hang up on voicemail
Off unless you turn it on, under **Hang up on voicemail** on the Voice page. It
applies to the calls your agent places on your behalf — campaigns, the calls API
and test calls — and never to calls somebody makes to you. A call-back a
customer asked for never checks either: they are expecting the call, so the
check could only cut them off.
With it on, your agent **speaks as soon as the call is answered**, and the phone
company listens at the same time. When what comes back is a voicemail greeting —
"leave a message after the beep", "the number you have dialled is switched off",
"आप जिस नंबर पर कॉल कर रहे हैं" — **the call ends right there**.
What that means for you:
* **Nobody waits.** A person who answers hears your greeting straight away.
* **A person is not hung up on for sounding unsure.** The phone company's own
guess is often wrong on mobile numbers, so on its own it ends nothing: only the
words of a voicemail greeting do. Somebody who picks up and says "Hello?" is a
person, however it sounded.
* **A machine hears the first sentence of your greeting** before the call ends,
and the call is charged like any other for those few seconds. A voicemail with
a long silence before its greeting can be talked to a little longer before it
is recognised.
It is worth it on a long list where most calls reach voicemail. Try it on your
own number first.
A campaign with a **voicemail message** works differently: it waits to hear the
greeting so it can leave the message — see
[If it reaches voicemail](/channels/voice-campaigns#if-it-reaches-voicemail).
Not every phone provider can tell a machine from a person. On a number from
one that cannot, the switch has no effect and your agent talks to whatever
answers.
## A call button in your chat
When **Website** is switched on, a call button appears beside the message box in your
widget — visitors press it and start talking, without dialling anything.
That is the whole of it. The button appears in the widget on every page the
chat is already on, and disappears again the moment you untick it or switch
**Answer calls** off.
The playground shows the same widget your visitors see, so the button is
there too. Press it and talk — this is the quickest way to hear your agent
before anybody else does.
The visitor's browser asks for microphone permission the first time. Until they
allow it nothing is recorded, and if they refuse the button says so rather than
failing silently.
A call from the widget is the same conversation as their chat — same person,
same memory, same inbox thread — so somebody can type a question, call to
discuss it, and the agent knows what they already asked.
## WhatsApp
Customers press call inside the WhatsApp thread and reach the same agent.
The **WhatsApp** row on this page is on by default. Whether a number takes
calls is its own **Calls** switch on Connect WhatsApp, which turns this row on
and off with it.
## Texting the caller
During a call the agent can send the caller a WhatsApp or an SMS (a booking link,
your address) when you turn those on in [Actions](/agent/actions#reaching-the-customer-on-a-different-channel).
And when the call ends it can send one more, the same message every time, for
answered calls, missed calls or both. See [After the call](/agent/after-the-call).
## When Answer calls is off
A number you still own should not leave a customer wondering. When **Answer
calls** is off, or the phone is not switched on as a place the agent answers,
what the caller hears depends on the number's provider:
| The number is with | The caller hears |
| - | - |
| Plivo | A spoken sentence, then the call ends: your own message if you wrote one and it is in English, otherwise "Sorry, this number is not taking calls right now. Please try again later." |
| Twilio | The same, and your message can be in English or Hindi. |
| Vobiz | No message of yours. The call is declined as it always was. |
| Telnyx | A busy tone. Telnyx numbers cannot play a message from us. |
| WhatsApp calling | The call is rejected. WhatsApp has no way to speak to the caller. |
**Message when Agent is off**, on the Voice page under Answer calls, is your
sentence for the Plivo and Twilio rows — up to 300 characters, for example "We are closed
today and open again at 9 am tomorrow." Leave it blank to keep the standard
one. Via the API it is `voice.unavailableMessage` (send `null` to clear it).
* It is read aloud by the phone provider's own voice, not by your agent's, so
only text that voice can read is spoken. English, or English written in Roman
letters ("Hum kal subah 9 baje khulenge"), is read in an Indian-English voice
on Plivo and Twilio numbers. Hindi in Devanagari is read only on Twilio. A
message in any other script (Tamil, Bengali and so on), or Hindi on a Plivo
number, plays the standard sentence instead. We keep what you wrote and
tell you beside the field, and in the API as `voice.unavailableMessageNote`.
* It is only for a line you switched off. A call that cannot be taken for any other
reason — the plan, the balance, every line busy — always hears the standard
sentence, so that a caller can never learn anything about your account by
dialling.
* Line breaks and other control characters become spaces, invisible marks are
removed (the joiners inside a Hindi conjunct are kept), and a message over
300 characters is refused instead of cut short.
## Things worth knowing before you go live
Both switches matter. **Answer calls** off, or **Answer calls** on with no
surface switched on, means the agent does not pick up — you have not told it
to, or not told it where. What a caller hears then depends on the number's
provider; see [When Answer calls is off](#when-answer-calls-is-off).
Calls have a ceiling. A call that runs unusually long — usually a line left
open by accident rather than a customer with a lot to say — winds down and
ends rather than running indefinitely.
The same rule as every other channel: an agent that does not know something
says so and offers to have a person follow up. On a call this matters more,
because a caller cannot scroll back and check what they were told.
Not "what are your hours". Ring your own number and ask the thing your staff
dread — the awkward one, the one with a condition attached, the one where
the honest answer is "it depends". That is the answer worth hearing out
loud before a customer does.
# Calling campaigns
Source: https://docs.thinnest.ai/channels/voice-campaigns
Your agent rings a list and has the conversation itself.
A calling campaign is your agent ringing people one after another and talking to
them — the same audience rules and the same consent as a
[broadcast](/whatsapp/broadcasts), on the phone instead.
## Where to find it
**Voice Campaigns** is in the main sidebar, just under Contacts — under it
because a calling campaign is made *of* a contact list. Every campaign in the
workspace is on that one page, whichever agent places the calls.
## Which agent does the calling
You choose, when you build the campaign. It matters more than it sounds: a call
goes out from **that agent's number**, opens with **that agent's first words**,
and is priced by **that agent's voice**.
If you only have one agent that can call, there is nothing to choose and you are
not asked. If you have several, the builder asks first — before the audience,
because the answer changes what the rest of the campaign is — and the list shows
which agent each campaign belongs to.
An agent can only be picked if it can actually place calls: it needs a number
pointed at it on [Phone Numbers](/channels/phone-numbers), and it has to be set
to answer on the phone rather than only on the web. The builder says which of
the two is missing rather than making you guess.
Calling campaigns used to live on each agent's own page. They moved to the
main sidebar so one workspace has one list — with three calling agents you
had three lists that could not see each other, and finding a campaign meant
remembering which agent you made it on. Old links still work.
## Who gets called
Two answers, and they are alternatives rather than a pair.
**People already in Contacts.** The same four lists a
[broadcast](/whatsapp/broadcasts) offers, each showing its count before you
commit, and the same tags and [groups](/agent/leads#groups) to narrow within
one.
**Upload a list.** A CSV, mapped and rung. This is the one most people want:
the spreadsheet already exists, and nobody wants to import it, tag it, and then
come back here.
**Numbers you must never call** belong on the do-not-contact list, on the
**Contacts** page under *Do not contact*. Paste them or upload a file — from a
regulator, a complaint, or a lawyer's letter — and no campaign will ever reach
them, on any channel.
It is not the same as somebody opting out. Opting out is about a customer you
already have; this is about numbers, most of which are not in your contacts at
all, and an import cannot undo it. It is checked at the moment each call is
placed, so a list you upload at eleven stops a campaign that is already running
by the time it reaches three.
Somebody who tells your agent on a call not to be called again goes on this
list by themselves — see ["Don't call me again"](/channels/voice#dont-call-me-again).
A campaign skips them and shows why.
### Uploading a list
Drop a CSV with a header row. We guess which column is the phone number, the
name and the email — "Phone", "Mobile", "WhatsApp Number" and "Contact" all
land on the same guess — and you can change any of them. Any other column can be
pointed at something your agent says: see
[saying something different to each person](#saying-something-different-to-each-person). The panel shows the
numbers **as we will actually dial them**, not as the file wrote them, because
that is where spreadsheet damage shows up.
Pick the country for numbers with no country code. A number starting `+` or
`00` keeps its own.
**Excel eats long numbers.** A phone number in a General cell can be saved as
`9.19E+11`, and those digits are genuinely gone — we refuse the row rather
than invent a number that reaches a stranger. Format the column as text and
export again.
Rows we cannot read are listed with their line numbers and the reason. Leaving
them out is a tick you have to make: a list of a thousand that quietly calls
nine hundred is worse than one that stops and tells you.
Repeats within the file are dropped and counted. The same customer twice in an
export is ordinary; ringing them twice is not.
### What an upload does to your contacts
**The people on the file become contacts.** They are in Contacts afterwards,
and the campaign is aimed at exactly them — a label we put on those rows is
what keeps this campaign to this list.
There is a tick saying these people agreed to be called. It is a claim you are
making, recorded with the date, and it decides which *later* campaigns reach
them:
| The tick | Who reaches them afterwards |
| - | - |
| Left alone | Campaigns aimed at everybody, or at imported contacts |
| Ticked | Those, plus campaigns aimed at people who opted in |
Uploading a spreadsheet is not consent, and a phone call is not a message
somebody deletes. Calling a number on India's DND register without consent is
an offence under TCCCPR, and the complaint lands on the number you called
from.
Anybody who has asked you to stop is left out of the list even when they are in
the file. That is decided in the database rather than by the screen, so it
holds however the campaign was built.
The label stays on those contacts afterwards, so you can aim a second campaign
at the same people without uploading the file again.
## What it says
There is no message to write. A calling campaign opens with **the agent's own
first words** — the greeting on its Voice settings, the same one somebody hears
when they ring in — and talks from there.
> Hello, this is Meera from Sunrise Dental.
That is deliberate, and it is the one place a calling campaign is simpler than a
broadcast. A box here would have been a second version of what your agent
already says, and then nobody could answer the only question that matters: which
one does a customer actually hear? The builder shows the opening before you
launch, beside everything else, and links to where it is changed.
Worth reading it before a campaign goes out. A greeting written for people who
rang **you** — "Thank you for calling Sunrise Dental" — is the wrong sentence
coming from somebody who rang **them**.
It is a real conversation, not a recording. People interrupt, ask something
unrelated, and the agent answers from your knowledge like it does on any other
call — then the whole thing lands in the inbox with its transcript.
Calls go out a few at a time rather than all at once. A hundred people are not
all rung in the same minute, which is deliberate: it is what a phone line can
actually carry, and what does not look like a machine.
**Promotional calling in India needs a 140-series number.** An ordinary
business number is licensed for service and transactional calls, and using it
to promote something can get it blocked.
You mark each call campaign as **service** or **promotional**, and we refuse a
promotional one from a number that is not licensed for it — with a reason, not
a silent failure. Marking a promotional campaign as service to get past this
is the thing that gets numbers blocked.
## Saying something different to each person
Put `{{first_name}}` — or any name you like in double braces — into your
agent's greeting or its instructions, and every call fills it in from the row
that person came in on.
> Hello , calling from Sunrise Dental about your appointment on
> .
When you upload a list, the campaign shows you what your agent asked for and you
point each one at a column. Nothing to declare anywhere: we read the braces out
of your agent's own words, so the list is always what it actually says.
### What fills what
| Written in the agent | Filled from |
| - | - |
| `{{first_name}}`, `{{name}}`, `{{phone}}`, `{{email}}` | the contact, unless your file says otherwise |
| anything else | the column you mapped it to |
Your file wins where the two disagree — a column of preferred names beats the
name we have stored — but a **blank cell does not**. An empty column leaves the
contact's own details alone rather than wiping them for that one call.
`{{first_name}}` follows whichever name won, so a list that carries names for
people you have never spoken to still greets them properly. Map a `first_name`
column of your own if you want to control exactly what they are called — "Dr
Menon" rather than the first word of a full name.
**Keep values short.** These are words your agent says out loud, so anything
past about 150 characters is trimmed. A date, an order number, a name or a
short phrase is what belongs here; a notes column mapped by accident is not.
### What happens when it is not filled
Nothing is said. "Your order is ready" with no order mapped becomes
"Your order is ready" — a sentence, spoken normally, with the stray space and
the double space cleaned up.
**Nobody ever hears the braces.** That is the one rule this feature is built
around: an unmapped, unknown or misspelled variable is left out rather than
read aloud. A cell that itself contains `{{something}}` is stripped too — you
cannot smuggle a placeholder in through a spreadsheet.
### Careful with instructions
A variable in your agent's **greeting** only affects calls it makes.
A variable in its **instructions** is part of every conversation that agent ever
has — someone ringing your number, a website chat, a WhatsApp message. None of
those came from a list, so there is nothing to fill from and it resolves to
nothing for them. The upload screen says so when it spots one, and it is worth
reading: an instruction written for one campaign quietly applies to everybody.
## Looking someone up before you call them
Your list is fixed when you build the campaign. The facts about the people on it
are not — an order that was "packed" on Monday is out for delivery by Tuesday,
and an agent that opens with Monday's answer is worse than one that never
mentioned the order at all. The customer knows which is true.
**Pull Details/Data from API**, step 4 of a new campaign, closes that gap. Moments
before each person is called, we ask your own system about them and use what
comes back in what the agent says.
### What you need first
An **action** on the Actions page: your endpoint, its method, and any keys it
needs, saved once. The campaign picks one from a list — it never asks you to
paste a URL or a key a second time, and changing the action later changes it
everywhere it is used.
The action needs somewhere to put the number. If it declares exactly one field
we choose it for you; if it declares several, say which one is the phone number.
### Try it before you launch
Type one real number and press **Try it**. You will see exactly what the agent
would know about that person, written the way you would use it:
```
{{order_id}} A-8841
{{status}} out for delivery
{{eta_minutes}} 20
```
Those are the placeholders you put in your opening line. This really calls your
endpoint; it places no phone call and changes nothing about the campaign.
Use it. Two dropdowns cannot tell you your endpoint expects `msisdn` where the
campaign is sending `phone` — but one press of **Try it** does, before a
single call goes out.
### What we can use from the answer
Plain values at the top level of your JSON. `"status": "out for delivery"` and
`"eta_minutes": 20` both work; a number, a word, or true/false.
Anything nested is skipped rather than read out. An agent saying "your order
\[object Object] is ready" is worse than one that never mentions the order, so we
leave it out and tell you which fields were ignored when you press **Try it**.
Whatever your system says wins over the same column in your uploaded file — that
is the entire point — except when it comes back empty, which does not erase what
you uploaded.
### If the lookup fails for someone
Your choice, and the default is the careful one.
**Don't call them, try again in a few minutes.** Nobody is rung until their
details come back, so a sentence built around `{{order_id}}` is never spoken
with a hole in it. They stay in the queue and are tried again shortly. If the
lookup fails for *everyone*, the campaign pauses and tells you why rather than
working through your list saying nothing useful.
**Call them anyway, without that data.** The call goes out using what you
uploaded, and anything the lookup would have added is simply missing from what
the agent says. Choose this when the extra detail is nice to have and reaching
people on time matters more.
Every person on the list is one request to your endpoint, sent shortly before
their call. A campaign of ten thousand people is ten thousand requests, paced
by the campaign's own speed limit. If your system is rate-limited, set **At
most, per hour** to something it can live with.
## When it calls, and when it stops
A campaign carries its own clock. Everything below is read in it, and it starts
as your workspace's — change it if you are setting this up from somewhere else,
and what you type is what the campaign does.
| Setting | What it means |
| - | - |
| **It starts** | Now, or at a time you pick |
| **Hours it may call** | The same stretch of every day the campaign runs |
| **Stop after** | Anything still unanswered by then is left uncalled |
Calls outside the hours **wait** rather than being dropped. A campaign started
at midnight begins in the morning, and its row says so rather than looking
stalled.
Indian rules restrict commercial calling to daytime, and 9am to 9pm is the
window every campaign keeps unless you narrow it. Narrowing is normal — a
clinic ringing about appointments and a shop ringing about an offer do not
want the same hours.
**The hours are read where each person is.** If your list has numbers in more
than one country, "9am to 6pm" means nine in the morning *to them* — a London
number is rung at 9am London time, not at 9am yours. That is what the rules say
and what anybody being rung would expect. Nothing to set: it comes from the
number.
Two consequences worth knowing:
* A campaign spanning countries is never all callable at once, so it works
through the list in waves as each country's morning comes round. The row says
it is waiting rather than looking stalled.
* For a country that spans several time zones — the United States, Canada,
Australia, Russia, Brazil — a phone number does not say which one. We stay
inside your hours at **both ends** of the country rather than guessing, which
makes the callable stretch shorter but never rings anybody at six in the
morning. If your hours are very narrow, a list like that may not be callable at
all; the campaign says so instead of trying for ever.
## Calling from more than one number
A big campaign from a single number is the fastest way to get that number marked
as a nuisance line — and once it is, everything it does afterwards suffers,
including customers ringing you back.
If you hold spare numbers, give them to the agent under **Outbound** on
[Phone Numbers](/channels/phone-numbers) and the campaign spreads its calls
across them. Nothing changes in the builder and there is nothing to switch on.
An agent that has **no number of its own** can run a campaign too, from a number
you give it under **Outbound** — the builder offers it like any other agent.
The numbers can be on **different phone companies** — a ThinnestAI number and one
you imported from Twilio, for instance — and each call goes out through its own
number's company. If the campaign leaves a voicemail and only some of its numbers
can (ThinnestAI, Plivo and Telnyx numbers can), it still runs: calls on the others talk to the
answering machine instead, and the builder tells you how many numbers that is
when you create the campaign. If none of them can, it asks you to remove the
message.
Three things are worth knowing, because they are what makes it safe:
* **Whoever we ring keeps the same number.** A second attempt comes from the
number that rang them the first time.
* **Somebody ringing back reaches whoever answers that number**, which is the
agent under **Inbound** — not necessarily the one that rang them, and nobody at
all if **Inbound** is empty. Decide that on the Phone Numbers page before a
large campaign.
* **Each number has a daily limit** of 200 calls. A campaign that uses up every
number pauses with a line saying so and carries on the next morning.
## Which days, and how fast
Two things worth setting on any list longer than a few hundred.
**Days it may call.** By default a campaign calls every day it is running,
Sunday included. Pick the days in the builder and it waits for the next allowed
one. The day is read where each person is, the same as the hours.
**At most, per hour.** By default a campaign uses every line your plan gives it,
which is the fastest it can go. That is the wrong setting when somebody has to
answer the calls that connect — a team of two cannot hold thirty conversations,
and the rest of your customers hear a hold tone. A cap only ever slows a
campaign down; your plan's lines are still the ceiling.
The cap is a rolling hour, not a clock hour. "Thirty an hour" means thirty in
any sixty minutes — not thirty at 10:59 and thirty more at 11:01.
### Trying again the next day, at a time you choose
Pick **The next day** as the wait and a time appears beside it. Leave it empty
and the retry goes out 24 hours after the call it missed — so an evening call
is tried the next evening, and later again each round. Set it to 9:00 and
everyone who did not answer is queued for nine the next morning instead.
A busy line is not affected: that is someone holding their phone right now, so
it keeps its own short wait in minutes.
Your calling hours still apply. The time says when somebody becomes due, not
that the call ignores your window.
### When your balance runs low
Two things stop, and not at the same point.
**Trying somebody again stops first.** Once your balance is down to about ten
calls, anyone who did not answer is left where they are and the campaign keeps
ringing numbers it has never tried — the last of a balance is better spent on
somebody who might pick up. The campaign says so on its page.
**Calling stops when there is not enough for one call.** Nothing is lost:
everybody still waiting keeps their place and their turn.
Top up and both resume by themselves within a minute or two. Nobody is dialled
twice for it, and no attempt is used up while calls are held.
## If it reaches voicemail
Most people do not pick up. There are two ways to handle a machine:
* **Leave a message.** Write one in the builder and, when an answering machine
takes the call, your agent leaves it — in its own voice — and then hangs up.
* **Just hang up.** With no message and the agent's
[Hang up on voicemail](/channels/voice#hang-up-on-voicemail) switch on, the
call ends as soon as what comes back is recognised as a voicemail greeting, and
is charged like any call for the few seconds before that.
With neither, nothing listens for a machine and your agent talks to it like any
other call.
Writing a message is enough — you do not have to turn anything else on. It is
not available on every phone provider, and if yours is one of those you are
told when you write the message rather than after the campaign has run.
### How we tell a machine from a person
Your agent speaks as soon as the call is answered, and the phone company listens
to the first few seconds at the same time and guesses whether a machine answered.
**That guess is often wrong**, and more so on Indian mobile numbers: on one
campaign it called 18 real conversations "machine" out of 32 — people saying
"Hello" or "हां" and then talking to the agent for minutes. So the guess on its
own never ends a call. It only makes us listen more carefully to the first thing
said on the line, and **the words decide**:
* **A voicemail greeting** — "please leave a message after the beep", "बीप के बाद
अपना संदेश छोड़ें", "the number you have dialled is switched off" — is a
machine. With a voicemail message written for the campaign, your agent leaves
it, then the call ends. With none, the call just ends.
* **Anything else** — "Hello", "हां", "बोलिए", a question — is treated as a person,
and the conversation carries on as normal, whatever the guess was.
The words are also checked on a call the phone company took for a person, on its
first two turns, so a machine it missed is still recognised from its greeting.
We would rather miss a machine than hang up on a person: a greeting worded in
a way we do not recognise is treated as a person, so occasionally your agent
will talk to a voicemail box for a few seconds. And because your agent speaks
at once, a machine always hears the first sentence of your greeting before the
call ends. Test it on your own number first.
Calls that reached a machine are counted separately from calls somebody
answered — and a call where the customer said anything counts as answered, even
if the phone company first guessed otherwise. A recording is not a conversation,
and a person is not a recording.
## Testing two openings
Most of why somebody stays on a call or hangs up happens in the first few
seconds, so the opening is the thing worth testing.
Fill in **Test a second opening** and the campaign is created as two: "(A)" and
"(B)", each calling half the list, each with its own progress and its own
numbers. Nobody is called twice — the halves never overlap — and you compare
them by opening the two rows.
## How it went
A calling campaign shows more than how many calls went out:
| Number | What it means |
| - | - |
| **Called** | The people whose phones rang, with the total number of dials — somebody who was retried counts once here and once per dial in that total |
| **Answered** | People who picked up on their last call, as a percentage of the people called |
| **Average call** | How long the answered ones lasted |
| **Reached voicemail** | The phone company reported an answering machine and the person never spoke. If they did speak, the call counts as answered instead |
Beside them, **Minutes and cost** adds up every dial the campaign made —
retries and calls an answering machine took included, because each was charged
(a machine the call hung up on is charged only the phone network's fee):
the time on calls, how much of it was conversation, what was charged to your
wallet, and what that comes to per answered call and per minute. A call that never left for the phone network is not counted. Each call's own
charge is in Usage → Calls, and the two add up.
Underneath, **How the rest ended** breaks down everything that did not connect —
no answer, line busy, declined, not reachable, ended before the agent spoke —
and each person in the list shows how their own call ended rather than just
"Called", plus how many times they were dialled when it was more than once.
**Click anybody in the list to open their call** — the summary, the transcript and
the recording, in the same panel as Usage → Calls. The list shows each person's
most recent call; every earlier attempt is in Usage → Calls too. It shows 25
people a page — use the page numbers underneath, or search by name or number.
That breakdown is what tells you which problem you have. A lot of "Not reachable"
is a stale list. A lot of "Declined" is the wrong audience or the wrong opening.
A lot of "No answer" is usually the wrong time of day.
Declined and unreachable numbers are never called again. Busy, no-answer and
answering machines the call hung up on are what retries are for — and anybody
who rings your number back in the meantime is not called again either.
## Ringing again
A person who does not pick up is not called again unless you say so. Whether a
second attempt is reasonable depends on why you rang, so it is a choice on the
campaign rather than a default.
Choose **how many times** to try again and **how long to wait** between tries.
Two retries half an hour apart means three calls at most, and the wait starts
when a call goes unanswered rather than when the campaign started — so somebody
rung late in a long run still gets the gap you asked for.
**A busy line gets its own, shorter wait.** The two are not the same thing and it
is worth setting them apart:
* **Engaged** means they are holding their phone right now. That is the most
reachable anybody ever is, so the default is five minutes.
* **No answer** means they are away from it. Ringing back five minutes later is
pestering, so the default there is half an hour.
Leave the busy wait alone and it does what it always did — the same wait for
both.
Anyone who answers is never called again, and retries keep to the calling hours
like every other call.
**And anyone who declined is never called again either.** We read what the
carrier says about how the call ended, so a retry means what you would want it
to mean:
| How it ended | Tried again |
| - | - |
| No answer | Yes — this is what retries are for |
| Line busy | Yes |
| Answering machine, and the call hung up on it | Yes, after the no-answer wait — they were not reached, and the guess may have been wrong |
| Answering machine, and your agent left the message | No — the message was delivered |
| Picked up, but ended before your agent spoke | Yes, after the no-answer wait — usually somebody who hung up while the voicemail check was listening |
| They declined | **No.** Pressing decline is an answer |
| Number does not exist, or is barred | No |
You see the same words on the call in your inbox — "Declined" rather than
"Missed call" — so the reason a number was or was not tried again is on the
record rather than something to work out.
A phone ringing twice from a number somebody did not recognise the first time
is the complaint that costs a number. Space the attempts out, and keep them
few.
## Being told it started
A campaign set for nine in the morning starts with nobody watching — which is
the point of scheduling it, and also why "did it actually go?" is the question
it creates.
Pick anybody in your workspace under **email when it goes live** and they get an
email and a notification the moment it starts, with how many people it is
calling. Nobody is picked by default: a campaign that emails four people every
time it starts is a campaign whose emails get filtered.
## Cost
Calls are charged in half-minute steps, rounded up, and only for calls that
actually connect — a number that rings out costs nothing. The per-minute rate is
your agent's voice rate; the campaign builder shows it before you commit.
An answering machine the call hung up on never reached your agent, so it is
charged only what the phone network charges for those seconds, not your voice
rate. Once a person picks up, the call is your agent's and is charged at the
voice rate, however short. So is a machine your agent leaves a message on,
because your agent spoke.
See [Voice](/channels/voice) for the rest of what an agent does on the phone.
# Voice Clone
Source: https://docs.thinnest.ai/channels/voice-clone
Record a short sample and your agent can speak in that voice — a founder's own, or someone from your team.
Fifteen seconds of somebody reading a line becomes a voice your agent can
answer calls in. It is a Studio voice like any other on the [Voice
page](/channels/voice) — same per-minute rate, same tab — except this one is
yours rather than picked from a catalogue.
**Voice Clone** has its own page in the main sidebar, under the same group as
Phone Numbers and Voice Campaigns. It lists every voice this workspace has
made, not one agent's.
**This is on the Pro plan and above.** Free and pay-as-you-go see the offer
over a blurred page rather than the recorder — cloning somebody's voice and
then telling them they cannot use it would be worse than not offering it.
## Clone a voice
In the main sidebar, choose **Voice Clone**.
Before anything records, you confirm two separate promises — not one
checkbox that quietly covers both:
* The recording is your own voice, or you hold the rights and permission to
clone the voice in it.
* You will not use it to impersonate anyone, or for anything illegal,
harmful or misleading.
Both are recorded against the voice, with who agreed and when — this is
what makes the clone lawful to hold, not a formality on top of it.
Record straight from the page, or upload a file — WAV, MP3, M4A or WebM.
Fifteen seconds is the sweet spot: five is the least the engine will accept,
and thirty is the most it wants — read your longest sentence twice if you
are short, rather than pausing partway through and starting again.
Read normally, in one take, without music or anyone else talking over you.
A treated room helps, but is not required — noise removal is on by default
and can be turned off if it flattens a voice recorded somewhere already
quiet.
Give it a name your team will recognise on the Voice page later — "Rohan"
or "Founder voice", not "Recording 3". Pick the language it will be
registered under, and an accent within it where the language has more than
one — Indian English and American English are not interchangeable for a
voice, even though the words are the same.
Description and tags are optional and are yours alone — a note for your own
team about when to use this voice, not something a caller ever sees or
hears.
The moment it is made, it says your line back to you in its own voice — the
same sentence you read, spoken by the clone. That is the one time it is
synthesized for free; every play after this comes from that same recording,
never a fresh one.
## Using it
A cloned voice appears on the **Studio** tab of the [Voice page](/channels/voice),
above the shared catalogue, marked as yours. Choose it like any other voice and
save — nothing else changes about the agent.
It answers calls at the **same per-minute rate as every other Studio voice**.
Cloning costs nothing; what it speaks on a call is billed the same as Anjali,
Rakesh Khanna or any of the hundreds of others on that tab.
## The limit
**Up to 10 cloned voices on Pro, and 20 on Scale.** Enterprise is agreed per
contract. Delete one to make another once you reach it.
If your workspace genuinely needs more, ask us — a higher limit can be set for
your workspace alone. We keep the default modest on purpose: every clone is a
recording of a real person, and a growing pile of them is a growing
responsibility.
## Removing one
Delete a voice from the **Voice Clone** page and it is gone everywhere — the
recording, the preview, and the voice itself at the engine that spoke it. This
cannot be undone.
**An agent already speaking with it is moved to a standard voice automatically**,
so a deleted voice never leaves an agent silently billing the Studio rate for
a voice that no longer exists. Check the agent's Voice page afterwards if you
want to pick something else.
## Your customers can use your voices
If you are a developer building for [customers](/api-reference/customers), **every
voice you clone can be used by all your customer workspaces**, automatically.
It shows on their **Studio** tab, and in
[`GET /voices`](/api-reference/voices/list-voices) with `"sharedFromDeveloper": true`,
and their agents speak with it like any other Studio voice. Their calls are billed
to them as usual.
It stays yours: a customer cannot rename or delete it, and it counts against **your**
clone limit, not theirs. A customer's own clones stay theirs alone — you and your other
customers do not see them. If you delete one of your voices, every agent speaking
with it, yours and your customers', is moved to a standard voice.
## Who can do this
**Admins only**, for cloning and for deleting. A cloned voice is somebody's
actual voice held under a consent record naming who agreed — the same weight
as the do-not-contact list or a KYC document, both of which are admin-only for
the same reason. Any member can still choose an already-cloned voice for an
agent, the same as choosing any other voice.
## What we keep, and for how long
The recording, the one stored preview, and the consent record stay for as long
as the voice exists. Deleting the voice deletes all three. There is nothing
else to configure — this follows the same erasure rule as [call
recordings](/channels/voice#recording-calls): once it is gone, it is gone, and
we cannot get it back.
Whether you had the right to clone a particular voice, and what you may say
in it, is yours to get right — the two promises above are what we ask you to
confirm, not a guarantee we can make on your behalf.
# Website widget
Source: https://docs.thinnest.ai/channels/website
The chat window on your own pages.
The widget is one script tag. It draws a launcher button, and the conversation
itself runs in an iframe on our origin.
## Install
```html theme={null}
```
Put it before the closing `