Sesera Reseller API (v1)
Provision and manage AI phone-receptionist agents on Sesera's infrastructure programmatically. Each reseller (partner) authenticates with an API key, creates voice agents ("businesses"), gets a phone number for each, funds them with minutes from a prepaid pool, and reads back what the agents did — bookings, messages and the calls themselves (with transcripts and audio recordings) — via webhook or a read API.
- Base URL:
https://sesera.ai - Auth:
Authorization: Bearer <API_KEY>on every request - Content type:
application/json - All endpoints are scoped to the authenticating reseller — a key can only ever
see and touch its own businesses.
Concepts
- Business = one AI voice agent + one phone number. You configure its
knowledge (hours, services, FAQ, free text) and voice; Sesera compiles a Turkish system prompt and provisions a live agent.
- Number pool — numbers are drawn from Sesera's shared pool. Your API key
has a number cap (maxNumbers); creating past it returns 403 quota_reached. Creating up to your cap never fails on stock: if the pool is empty, the business is created and held in your stock with status **awaiting_number (no error). Its number is bound later via POST /api/v1/businesses/{id}/assign-number**, which is the only call that can return 409 no_numbers. Retry that call after Sesera restocks.
- Minute pool — your account holds a prepaid minute balance (funded via
Sesera). You allocate minutes from the pool to a business; a business can only take calls while it has allocated minutes left.
- Source of truth — bookings and messages captured by the agent live in
Sesera and are delivered to you by webhook (real-time) and/or the read API (polling). The calls behind them — including the transcript and the audio recording — are read-only over the API (GET /api/v1/calls, GET /api/v1/calls/{id}/recording); there is no call webhook.
Errors
All errors return { "error": "<code>", "message"?: "<human hint>" }.
| HTTP | code | meaning |
|---|---|---|
| 400 | bad_json | body was not valid JSON |
| 400 | validation_error | invalid fields (see details[]) |
| 400 | invalid_voice | voiceId not in GET /voices |
| 401 | unauthorized | missing / invalid / revoked key |
| 402 | insufficient_pool | not enough minutes in your pool to allocate |
| 403 | reseller_suspended | your account is suspended |
| 403 | withheld_kvkk | call recording requested for a call with no KVKK disclosure record (see GET /api/v1/calls/{id}/recording) |
| 403 | feature_not_enabled | the field is only enabled for specific accounts (currently announcementName, see Announcement name, and callRecording while it rolls out, see Call recording) |
| 403 | quota_reached | number cap reached — delete one, or buy capacity in the dashboard (Sesera Dev → API → Bayilik, one-time 1.000 ₺ per number) |
| 404 | not_found | business / call does not exist (or isn't yours) |
| 404 | recording_not_found | the call is yours but has no audio recording (yet) |
| 409 | no_numbers | pool empty at assign-number time — retry after Sesera restocks (create never returns this) |
| 429 | rate_limited | slow down (see Retry-After) |
Account
GET /api/v1/me
Your account status, number quota, and minute pool.
curl https://sesera.ai/api/v1/me -H "Authorization: Bearer $KEY"{
"reseller": {
"id": "…", "name": "Akdeniz Telekom", "status": "active",
"numbers": { "used": 3, "max": 10 },
"minutes": { "balance": 250, "max": 0 }
}
}(max: 0 = no ceiling.)
GET /api/v1/voices
The voices you may set as voiceId. Each voice includes a **preview_url** — a public MP3 sample (the same clip our own wizard plays) you can drop straight into an <audio> tag so your customers can hear a voice before picking it. The URL is public and cacheable; it needs no Authorization header.
{ "voices": [ { "id": "IOx9E82IJLWAeUWBCdDz", "name": "Yağmur", "description": "Sıcak, samimi sohbet sesi", "gender": "female", "preview_url": "https://sesera.ai/_voicesamples/IOx9E82IJLWAeUWBCdDz.mp3" }, … ] }Businesses
POST /api/v1/businesses — create an agent (+ number when in stock)
If a number is in stock it's assigned immediately and the business is live. If the pool is empty, the business is still created (never fails on stock) and held with status: "awaiting_number" and phoneNumber: null — then call [assign-number](#post-apiv1businessesidassign-number--bind-a-number-from-stock) once Sesera restocks. Either way, this counts against your number cap.
Body (only name is required; everything else has sensible defaults):
| field | type | notes | |
|---|---|---|---|
name | string | required, 2–120 chars | |
category | string | e.g. "kuafor", "klinik", "oto" (drives safety guardrails) | |
hours | object | e.g. { "mon": { "closed": false, "intervals": [["09:00","18:00"]] }, … } | |
services | array | [{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }] | |
faq | array | [{ "q": "Otopark var mı?", "a": "Evet, ücretsiz." }] | |
freeText | string | free-form extra knowledge | |
extraInstructions | string | tone/behaviour instructions | |
greeting | string \ | null | the assistant's opening line — 30–250 chars and must contain the {businessName} placeholder. null = back to the built-in greeting; omit the field to keep the current one. See Greeting |
announcementName | string \ | null | enabled on request, per account. Only the NAME inside the fixed KVKK announcement that opens every call: *"Şu anda {announcementName} yapay zekası ile konuşuyorsunuz ve görüşmelerimiz kayıt altındadır."* null = the default announcement; omit to keep the current one. See Announcement name |
announcementPrefix | string \ | null | your own sentence read before the mandatory announcement, in the same announcer voice, e.g. *"Bu bir Alofy yapay zekasıdır."* 3 to 120 chars, available on every account. null or an empty string removes it; omit to keep the current one. See Announcement prefix |
callRecording | boolean | rolling out. false (default) = the call audio is never stored, only the transcript; the caller hears *"Görüşme yazıya dökülür."* true = the audio is recorded and the caller hears *"Bu görüşme kayıt altına alınmaktadır."* Omit to keep the current value. See Call recording | |
voiceId | string | from GET /voices (default applied if omitted) | |
voiceSpeed | number | 0.5–1.5 | |
number | "auto" \ | "none" | "auto" (default) takes a pool number and counts against your quota; "none" takes no number — the business will join one of your shared lines |
bookingsEnabled | boolean | turn on appointment booking tools | |
bookingLeadMinutes | int | min advance notice (default 60) | |
bookingMaxDaysAhead | int | max days ahead (default 30) | |
enabledLanguages | string[] | extra ISO-639-1 codes the agent may switch to | |
maxCallMinutes | int | per-call cap; 0 = unlimited | |
tools | array | custom tools the agent can call against your API — see Custom tools |
Strings that flow into the prompt may not contain control chars or {{.
curl -X POST https://sesera.ai/api/v1/businesses \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"name": "Yılmaz Kuaför",
"category": "kuafor",
"hours": { "mon": { "closed": false, "intervals": [["09:00","19:00"]] } },
"services": [{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }],
"bookingsEnabled": true
}'201 Created:
{
"business": {
"id": "…", "name": "Yılmaz Kuaför", "category": "kuafor",
"status": "live", "phoneNumber": "+90850…", "bookingsEnabled": true,
"tools": [],
"voiceId": "IOx9E82IJLWAeUWBCdDz",
"minutes": { "allocated": 0, "used": 0, "remaining": 0 },
"createdAt": "2026-07-03T…", "updatedAt": "2026-07-03T…"
}
}The number is live immediately, but the agent won't take calls until you allocate minutes (see below).status:live(routing) ·awaiting_number(created, held in your stock waiting for a DID — callassign-number) ·provisioning·suspended. When status isawaiting_number,phoneNumberisnull.
POST /api/v1/businesses/{id}/assign-number — bind a number from stock
Binds a pooled DID to a business that is awaiting_number (or whose number was previously reclaimed) and takes it live. This is the only endpoint that can return 409 no_numbers — call it, and if the pool is empty, retry after Sesera restocks. Idempotent: a business that already has a number returns it unchanged. No body.
curl -X POST https://sesera.ai/api/v1/businesses/$ID/assign-number \
-H "Authorization: Bearer $KEY"200 OK → { "business": { …, "status": "live", "phoneNumber": "+90850…" } } · 409 no_numbers (pool empty — retry after restock) · 403 quota_reached.
GET /api/v1/businesses — list yours
{ "businesses": [ { …business… }, … ] }GET /api/v1/businesses/{id} — fetch one
PATCH /api/v1/businesses/{id} — edit
Send only the fields you want to change (same fields as create); the agent is recompiled and re-synced. Omitted fields are preserved.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "freeText": "Pazar günleri kapalıyız." }'DELETE /api/v1/businesses/{id} — remove
Releases the number back to the pool and frees a slot in your number quota.
{ "deleted": true, "id": "…" }Shared line — one number, many businesses
Buying a number for every business is one way to go live. The other is a shared line: one of your numbers that all your customers forward their missed calls into. A forwarded call carries the *forwarding* phone number in its SIP Diversion header; Sesera matches that number to the business you registered it for and answers as that business. Nothing about Sesera is said on the call.
- The line stays assigned to the business you made it from. That business is
the default assistant: it answers when someone dials the line directly, when the caller hides their number, or when a forwarded call comes from a number you have not registered yet.
- A business joined to a shared line does not consume a number from your
quota and goes live as soon as its first forwarding number is registered. Create it with "number": "none" on POST /api/v1/businesses so no pool number is taken (status provisioning until it is joined).
- Capacity: 10 businesses per line (a number carries 10 concurrent calls).
Open a second shared line for more.
- Your customer sets conditional forwarding (no answer / busy / unreachable)
on their own phone to the shared line's number.
POST /api/v1/businesses/{id}/shared-line — make its number a shared line
No body. The business must already hold a number (assign-number). Idempotent.
{ "sharedLine": { "phoneNumber": "+90850…", "fallbackBusinessId": "…" } }Errors: 409 no_number (assign a number first), 409 not_line_owner.
DELETE /api/v1/businesses/{id}/shared-line — back to a private number
Refused with 409 line_in_use while other businesses still forward into it.
POST /api/v1/businesses/{id}/forwarding-sources — join a business to the line
{ "gsm": "05xx xxx xx xx", "sharedLine": "+90850…" }gsm is the phone number the customer will forward from (their own line). sharedLine is only needed when you own more than one shared line. Response: { "forwardingSource": { "gsm": "+90…", "sharedLine": "+90850…", "businessId": "…" } }.
Errors: 404 no_shared_line, 400 ambiguous_line (pass sharedLine), 409 gsm_taken (that number already forwards into a live business), 409 no_capacity (10 businesses on this line), 400 bad_gsm.
DELETE /api/v1/businesses/{id}/forwarding-sources
{ "gsm": "05xx xxx xx xx" }A business left with no number and no forwarding source returns to awaiting_number.
GET /api/v1/businesses/{id}/forwarding-sources
{ "forwardingSources": [ { "gsm", "sharedLine", "callCount", "lastCallAt", "createdAt" } ] }
GET /api/v1/shared-lines — everything at a glance
{
"sharedLines": [
{
"phoneNumber": "+90850…",
"fallbackBusinessId": "…",
"fallbackBusinessName": "OMM Dijital",
"maxBusinesses": 10,
"sources": [ { "businessId", "businessName", "gsm", "callCount", "lastCallAt" } ],
"unmatched": [ { "divertedFrom": "+90…", "from": "+90…", "at": "…" } ]
}
]
}unmatched lists the numbers that forwarded into the line in the last 30 days without a registration (the default assistant answered them) — register each one to the right business with forwarding-sources and it routes correctly from the next call.
Business objects (GET /api/v1/businesses) now also carry sharedLine (the line's number, or null) and forwardingNumbers (the registered GSMs).
The same controls live in the dashboard under Sesera Dev → API → Bayilik → Ortak hat.
Minutes
POST /api/v1/businesses/{id}/minutes — allocate from your pool
Moves minutes from your prepaid pool to this business. The pool is debited immediately; the business can now take calls until it runs out.
curl -X POST https://sesera.ai/api/v1/businesses/$ID/minutes \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "minutes": 50 }'{
"business": { …business with minutes.allocated updated… },
"pool": { "balance": 200 }
}402 insufficient_pool if your pool doesn't have that many minutes.
Topping up your pool. Fund the pool yourself from the Sesera dashboard: Ayarlar → Geliştirici → Dakika satın al (any Sesera account can switch the partner features on itself: Sesera Dev → API → Bayiliği etkinleştir). Minutes are sold in 100-minute steps at a volume-tiered rate (the more you buy, the lower the per-minute price); payment is by card via PayTR and the pool is credited automatically. Sesera can also top you up manually.
Greeting
Change the first sentence the assistant says, per business:
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"greeting":"Merhaba, {businessName} randevu hattına hoş geldiniz. Size nasıl yardımcı olabilirim?"}'{businessName}is replaced with the business name at call time, which is why
it is required. Don't hard-code the name: if the business is renamed, a hard-coded greeting freezes on the old one.
- 30–250 characters.
nullrestores the built-in greeting; omitting the field
leaves the current one untouched.
- Don't put the AI-identity or call-recording notice in here — a separate
announcement already plays both before the greeting, so the caller would hear the same notice twice.
greetingin aGETresponse is the text you set, ornullwhen one of the
built-in greetings is in use.
Call recording
Rolling out. Until it is enabled on your account the field is absent from responses and writing it returns 403 feature_not_enabled.Every call opens with a very short announcement, played before the greeting. Which one depends on callRecording:
callRecording | the caller hears | stored |
|---|---|---|
false (default) | *"Görüşme yazıya dökülür."* | transcript only, no audio |
true | *"Bu görüşme kayıt altına alınmaktadır."* | transcript + audio |
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"callRecording":true}'- Takes effect from the next call. Turning it on does not bring back audio for
calls taken while it was off: that audio was never stored.
- With it off,
GET /api/v1/calls/{id}/recordinganswers404for new calls
(recordingStatus: "none"); the transcript is available as usual.
- Also accepted on
POST /api/v1/businesses. The business owner can change the
same setting in the panel (Ayarlar → Görüşme kaydı).
- While the setting is active on a business the sentence is fixed and
announcementName is not used.
Announcement name
Enabled on request, per account. Other accounts get 403 feature_not_enabled and the field is not present in their responses.Every call opens with a short legal announcement, played before the greeting, telling the caller they are talking to an AI and that the call is recorded (KVKK). You can put your own brand in it. You set only the name; the sentence around it is fixed:
Şu anda {announcementName} yapay zekası ile konuşuyorsunuz ve görüşmelerimiz kayıt altındadır.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"announcementName":"Alofy"}'The next call on that business says: *"Şu anda Alofy yapay zekası ile konuşuyorsunuz ve görüşmelerimiz kayıt altındadır."*
- 2–30 characters: letters and digits, up to 4 words separated by single
spaces. No punctuation. Don't write "yapay zeka" or "kayıt" into the name: the fixed sentence already says both.
- The announcement cannot be removed or reworded — only the name changes.
null goes back to the default announcement (*"Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz."*); omitting the field leaves it untouched.
- Also accepted on
POST /api/v1/businesses. Set it per business: each of your
businesses can carry a different name.
announcementNamein aGETresponse is the name you set, ornull.- The name is not suffixed, so it reads correctly with any brand
("Alofy yapay zekası", "Mudanya Emlak yapay zekası").
- The
greetingfield is separate and unchanged: the announcement plays first,
then the assistant says its greeting.
Announcement prefix
Every call opens with a short legal announcement (AI + recording notice, KVKK), read by a fixed male announcer voice before the greeting. You cannot remove or reword it, but you can put your own sentence in front of it:
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"announcementPrefix":"Bu bir Alofy yapay zekasıdır."}'The next call on that business opens with: *"Bu bir Alofy yapay zekasıdır. Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz."*, then the assistant says its greeting.
- Free text, 3 to 120 characters: letters, digits, spaces and basic punctuation
(. , ! ? ' : ; & ( ) -). No web addresses. A final period is added if you leave it out; the stored value is exactly what is read.
- Read in the same announcer voice, right before the mandatory sentence. The
mandatory sentence always follows it.
nullor an empty string removes it (only the mandatory announcement plays); omitting the field
leaves it untouched.
- Available on every account. Also accepted on
POST /api/v1/businesses, and
set per business.
- Works together with
announcementNameandcallRecording: whichever
mandatory sentence applies, your prefix is read before it.
announcementPrefixin aGETresponse is the sentence you set, ornull.- The business owner can change the same sentence in the panel (Ayarlar →
Karşılama mesajı) and in Sesera Dev (İşletmeler → Anons).
Custom tools
Give each agent tools that call your own API during a call — look up an order, check stock, write to your CRM, anything. Declare them per business with a tools array on create or PATCH:
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"tools": [{
"name": "siparis_durumu",
"description": "Müşteri sipariş durumunu sorduğunda çağır. Sipariş numarasını al.",
"url": "https://api.senin-sistemin.com/siparis/durum",
"method": "POST",
"parameters": {
"type": "object",
"properties": {
"siparisNo": { "type": "string", "description": "Sipariş numarası" }
},
"required": ["siparisNo"]
},
"responseTimeoutSecs": 12,
"responsePath": "result"
}]
}'| field | type | notes | |
|---|---|---|---|
name | string | function name the AI calls; ^[a-z][a-z0-9_]{1,39}$, can't shadow a built-in | |
description | string | when to use it + what it returns — the AI reads this to decide | |
url | string | your endpoint; absolute https (internal/private hosts rejected) | |
method | POST\ | GET | default POST |
parameters | object | JSON-Schema (type:"object", properties, required) the AI fills | |
responseTimeoutSecs | int | 3–20, default 15 — it's on the live call, keep your endpoint fast | |
responsePath | string | JSON key in your response to hand back to the AI (e.g. result); omit = whole body | |
enabled | boolean | default true; false keeps the definition but turns it off |
- Request — Sesera
POSTs{ "name": "<tool name>", "arguments": { …the AI's args… }, "conversation_id": "<uuid>" }(or aGETwith the args as query params). Respond with JSON quickly. (This doc previously described aparameterskey and acallerfield; the live agent sends neither. Readarguments.) - Auth — every tool call carries
Authorization: Bearer <your webhook signing secret>(the same secret shown in Ayarlar → Geliştirici → Webhook). Verify it and reject anything else. - Replace semantics —
toolssets the whole list; send"tools": []to remove them all. Omit the field to leave them unchanged.customToolsis accepted as the same field. - Check it stuck — every business object returns the stored tool names in
tools(e.g."tools": ["siparis_durumu"]). Before 23 Sep 2026 a write that used thetoolsname answered 200 but stored nothing; if you set tools before then, send them again and confirm withGET /api/v1/businesses/{id}. - Max 10 tools per business, 20 parameters each. Editing tools re-syncs the agent automatically.
Reading calls, bookings & messages
All three are scoped to your businesses. Query params: since (ISO timestamp), businessId (filter to one), limit.
A booking/message is the outcome of a call; callId on it points at the call that produced it, so you can always fetch the conversation behind a result: GET /api/v1/calls/{callId}. (null when the row didn't come from a call.)
GET /api/v1/bookings
curl "https://sesera.ai/api/v1/bookings?since=2026-07-01T00:00:00Z" \
-H "Authorization: Bearer $KEY"{ "bookings": [ {
"id": "…", "businessId": "…", "startsAt": "…", "endsAt": "…",
"customerName": "Ahmet", "customerPhone": "+90…", "serviceName": "Saç kesimi",
"notes": null, "status": "confirmed", "source": "voice",
"callId": "…", "createdAt": "…"
} ] }limit max 200.
GET /api/v1/messages
{ "messages": [ {
"id": "…", "businessId": "…", "body": "Müşteri geri aranmak istiyor.",
"customerName": "Ayşe", "customerPhone": "+90…", "status": "new",
"callId": "…", "createdAt": "…"
} ] }limit max 200.
GET /api/v1/calls — calls with the full conversation
curl "https://sesera.ai/api/v1/calls?since=2026-08-01T00:00:00Z&limit=25" \
-H "Authorization: Bearer $KEY"{ "calls": [ {
"id": "…", "businessId": "…",
"fromPhone": "+905…", // null when the caller hid their number
"startedAt": "2026-08-05T12:16:58.052Z",
"endedAt": "2026-08-05T12:17:42.052Z",
"durationSeconds": 44,
"status": "ai_handled", // ringing | owner_answered | ai_handled | missed | failed
"outcome": null, // callback_scheduled | info_only | no_intent | hangup | spam | null
"transcriptStatus": "available",
"transcript": [
{ "role": "assistant", "text": "Merhaba, hoş geldiniz, burası …" },
{ "role": "customer", "text": "Web sitesi yaptırmak istiyorum." }
],
"transcriptText": "AI: Merhaba…\nMüşteri: Web sitesi yaptırmak istiyorum."
} ] }limitmax 100, default 25 (lower than the other lists — each row carries
a whole conversation). Page backwards with since; rows are newest-first by startedAt.
transcript=0returns metadata only (cheaper for frequent polling); the
transcript fields come back null with transcriptStatus: "not_requested".
transcriptStatusvalues:
| value | meaning |
|---|---|
available | transcript / transcriptText hold the conversation |
withheld_kvkk | the call carries no KVKK disclosure record, so the conversation body is withheld by law/policy — the metadata above is still accurate |
empty | nothing was transcribed (e.g. the caller hung up immediately) |
not_requested | you passed transcript=0 |
roleiscustomer(the caller) orassistant(the AI).
GET /api/v1/calls/{id}
Same object under { "call": … }, always with the transcript. Use it to fetch the conversation behind a booking/message callId. Unknown or foreign id → 404 not_found.
curl "https://sesera.ai/api/v1/calls/1dddffb1-2882-43be-a702-ac85001efebd" \
-H "Authorization: Bearer $KEY"This endpoint (not the list) also tells you whether the call has an audio recording, with two extra fields:
{ "call": {
"id": "1dddffb1-…", "…": "…same fields as the list…",
"recordingStatus": "available",
"recording": {
"url": "https://sesera.ai/api/v1/calls/1dddffb1-…/recording",
"contentType": "audio/wav",
"durationSeconds": 59,
"sizeBytes": 948844
}
} }recordingStatus | meaning |
|---|---|
available | recording describes the file; download it from recording.url |
withheld_kvkk | same rule as transcriptStatus: "withheld_kvkk" — no KVKK disclosure on the call, so the audio is withheld too; recording is null |
none | no recording (yet): recordings appear about 2 minutes after the call ends; very old calls may have none; recording is null |
GET /api/v1/calls/{id}/recording — the call's audio
Streams the recording of the whole conversation (caller and assistant mixed in one track) as **audio/wav**, 8 kHz, 16‑bit, mono (~1 MB per minute). Same Bearer key and same scope as the other call endpoints.
curl -o call.wav "https://sesera.ai/api/v1/calls/1dddffb1-2882-43be-a702-ac85001efebd/recording" \
-H "Authorization: Bearer $KEY"?download=1sendsContent-Disposition: attachment(default isinline).- HTTP
Rangerequests are supported (206 Partial Content), so an<audio>
player can seek. The URL needs your API key, so don't put it straight into a browser page — proxy it through your own backend and keep the key server-side.
- You don't need
GET /calls/{id}first: request the recording directly and
treat 404 recording_not_found as "no recording (yet)".
- Downloads count toward the normal rate limit.
| HTTP | error | meaning |
|---|---|---|
| 200 / 206 | — | the WAV file (or the requested byte range) |
| 403 | withheld_kvkk | the call has no KVKK disclosure record; the audio is withheld (the transcript is too) |
| 404 | not_found | unknown or foreign call id |
| 404 | recording_not_found | the call is yours but there is no recording; if the call ended in the last few minutes, retry shortly |
Webhooks (real-time)
If Sesera configures a webhook URL for your account, every new booking/message is POSTed to it as it happens. A business owner may additionally set their own endpoint for their business; that does not replace yours — the event is delivered to both.
Event body:
{
"type": "booking.created", // or "message.created"
"businessId": "…",
"createdAt": "2026-07-03T…",
"data": { … same fields as the read API rows … }
}Signature — header X-Sesera-Signature: t=<unixSeconds>,v0=<hex> where hex = HMAC_SHA256(secret, "<t>.<rawRequestBody>"). Verify it with the signing secret Sesera gives you, and reject timestamps older than ~5 minutes.
const crypto = require("crypto");
function verify(rawBody, header, secret) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (!parts.t || !parts.v0) return false;
// replay window
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
// timingSafeEqual THROWS on a length mismatch — compare lengths first
const a = Buffer.from(expected, "hex"), b = Buffer.from(parts.v0, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Respond 2xx quickly. Delivery retries a few times on failure; use the read API to reconcile anything missed.
Live transcript (real-time, during the call)
See what is being said while the call is still going: every sentence the caller says and every sentence the assistant says is POSTed to your webhook the moment it happens. Text only, no audio. Off by default, per business.
Turn it on — PATCH /api/v1/businesses/{id} with { "liveTranscript": true } (or true in the create body). The business owner can also flip it in the dashboard: Sesera Dev → API → *Canlı transkript*. GET /api/v1/businesses/{id} returns the current value as liveTranscript. Events go to the same webhook targets and carry the same X-Sesera-Signature as bookings and messages.
Three events per call, always in this order:
| Event | When | data |
|---|---|---|
call.started | the call is connected to the business | conversationId, direction (inbound / outbound), fromPhone (the other party), toPhone, startedAt |
call.transcript | on every sentence, repeatedly | conversationId, seq, turn, transcript |
call.ended | the call has been saved | conversationId, callId, durationSeconds, endedAt, transcriptStatus, transcript |
{
"type": "call.transcript",
"businessId": "…",
"createdAt": "2026-09-26T10:14:03.512Z",
"data": {
"conversationId": "7c1e…",
"seq": 4,
"turn": { "index": 3, "role": "assistant", "text": "Tabii, yarın saat on dörtte yerimiz var.", "final": false },
"transcript": [
{ "role": "assistant", "text": "Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz.", "final": true },
{ "role": "assistant", "text": "Merhaba, nasıl yardımcı olabilirim?", "final": true },
{ "role": "customer", "text": "Yarın öğleden sonra için randevu almak istiyorum.", "final": true },
{ "role": "assistant", "text": "Tabii, yarın saat on dörtte yerimiz var.", "final": false }
]
}
}**Render data.transcript, not just data.turn.** Every call.transcript carries the whole conversation so far. The live transcript is corrected as the call goes on: when the caller pauses and continues, the two parts become one sentence; when the caller interrupts the assistant, its sentence is shortened to what was actually heard. Redrawing the list from transcript on every event is always correct; appending turn is not. A line with "final": false is the sentence the assistant is speaking right now — the same line comes back with "final": true when it has finished.
- Roles are
customerandassistant, the same asGET /api/v1/calls/{id}. seqincreases within a call. Ignore an event whoseseqis lower than the
last one you applied.
- Events for one call are delivered one at a time, in order. If your endpoint is
slow, intermediate snapshots may be skipped, but the latest one is always sent. Answer 2xx within 3 seconds.
call.endedholds the final transcript and thecallIdfor
GET /api/v1/calls/{callId} and …/recording. It is retried like booking events; call.transcript is not (the next one supersedes it).
- KVKK: nothing is sent before the assistant has read the disclosure. A call
without a disclosure sends call.started and a call.ended with "transcriptStatus": "withheld_kvkk" and "transcript": null, exactly like the read API.
- Test without a call: Sesera Dev → API → *Örnek çağrı gönder* sends a full
sample sequence to the business webhook, each event marked "test": true.
Typical integration flow
GET /api/v1/me— confirm your quota + pool.POST /api/v1/businesses— create an agent. If stock is available you get a
live phoneNumber back; if not, status is awaiting_number (no error).
- If
awaiting_number:POST /api/v1/businesses/{id}/assign-numberto bind a
number (retry after Sesera restocks if it returns 409 no_numbers).
POST /api/v1/businesses/{id}/minutes— allocate minutes so it can answer.- Receive
booking.created/message.createdwebhooks (or poll
GET /api/v1/bookings + GET /api/v1/messages). Poll GET /api/v1/calls for the conversations themselves — webhooks fire on outcomes only, never on a call ending. The audio of any call is at GET /api/v1/calls/{id}/recording.
PATCHto edit,DELETEto release the number.
Rate limit: ~120 requests/minute per reseller account (all keys share it).
Outbound calls — SIP Connect
The assistant can also place calls, over a SIP trunk the business owns.
Sesera does not sell outbound minutes and never carries the telephony leg: the number, the carrier contract and the per-minute call cost stay with the customer. What we add is the assistant on that leg — and the AI minutes it burns are metered to the business exactly like an inbound call.
Set the trunk up first, either in the dashboard (Sesera Dev → SIP) or through the API below. A trunk needs the carrier's SIP host plus either a SIP username/password (mode register, what Turkish carriers use) or an IP allowlist (mode ip, for on-prem PBXes). Outbound additionally requires the one-time consent acknowledgement on that page — without it every call is refused with 403 consent_required, no matter what the API sends.
SIP trunks — add the carrier line through the API
A business provisioned through this API has no owner who can open the Sesera dashboard, so its trunk is managed here. Same validation, same switch, same limits as the dashboard.
POST /api/v1/businesses/{id}/sip-trunks — add a trunk and connect it
curl -X POST https://sesera.ai/api/v1/businesses/$BIZ/sip-trunks \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"label": "Verimor ana hat",
"mode": "register",
"direction": "both",
"host": "sip.verimor.com.tr",
"username": "908501234567",
"password": "…",
"did": "+908501234567",
"outboundConsent": true
}'| field | type | notes | ||
|---|---|---|---|---|
label | string | required, 2–60 chars | ||
mode | register\ | ip\ | relay | register = SIP username + password (Turkish carriers); ip = IP allowlist (on-prem PBX); relay = your own carrier number delivered to Sesera by IP routing, see below |
direction | inbound\ | outbound\ | both | default both |
host | string | required, the carrier's SIP host or IP | ||
port | int | default 5060 | ||
transport | udp\ | tcp | default udp | |
username / password | string | required in register mode. The password is write-only and never returned | ||
allowedIps | string[] | required in ip mode; IPs or CIDRs | ||
did | string | the customer's own number on this trunk; routes inbound calls to this business | ||
outboundCallerId | string | number shown on outbound calls; the carrier may override it | ||
maxConcurrent | int | simultaneous calls, 1–30, default 2 | ||
dailyOutboundCap | int | 0–5000, default 200 | ||
callWindow | object | { "start": "09:00", "end": "20:00" } Europe/Istanbul, the default | ||
outboundConsent | boolean | the İYS / consent acknowledgement outbound calls require (see Compliance below). You give it on your customer's behalf | ||
connect | boolean | default true; false saves the trunk without touching the switch |
201 Created → { "trunk": { … } }. If the trunk was saved but the switch refused it, the response is still 201 with the trunk in status: "error" and a connectError object; fix it with PATCH rather than creating a second one.
GET /api/v1/businesses/{id}/sip-trunks — list. GET …/sip-trunks/{trunkId} — one; add ?live=1 to ask the switch directly: "live": { "endpointLoaded": true, "registration": "Registered" }.
PATCH …/sip-trunks/{trunkId} — send only what changes. An edit to a connected trunk is pushed to the switch at once. { "enabled": false } disconnects it, { "enabled": true } connects it again. outboundConsent can be set or cleared.
DELETE …/sip-trunks/{trunkId} — disconnect and remove.
Your own number — relay mode
Point a number you already own at one of your businesses, with no 0850 and no forwarding. Your carrier delivers the call straight to Sesera; the number never enters Sesera's pool, is never given to anyone else, and does not use your number quota.
- Create the trunk. Only
labelanddidare needed:
curl -X POST https://sesera.ai/api/v1/businesses/$BIZ/sip-trunks \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "label": "Verimor 0224", "mode": "relay", "did": "+902243220410" }' The response carries "relayAddress": "…:5070".
- In your carrier panel, route incoming calls for that number to
relayAddress with the port. On Verimor: Ses Hizmeti → Gelen Çağrı Yönetimi → trunk destination. No SIP registration is needed.
To move the number to another business, DELETE the trunk and create it on the other business (the carrier setting stays the same). direction defaults to inbound; host, port and allowedIps are ignored.
Outbound calls on your own number (Verimor). Set direction to both (or outbound) and add the number's SIP password from the carrier panel; username defaults to the number without +:
curl -X PATCH https://sesera.ai/api/v1/businesses/$BIZ/sip-trunks/$TRUNK \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "direction": "both", "password": "…", "outboundConsent": true }'Calls then go out through POST /api/v1/businesses/{id}/calls exactly as on any other trunk (consent, call window, daily cap and minutes all apply), and the carrier bills them to your account. No SIP registration is made, so your incoming routing stays as set. Outbound in relay mode is Verimor only.
Up to 5 trunks per business. Every change reloads the switch, so changes are limited to 12 per business per hour and 40 per reseller per hour (429 rate_limited with Retry-After). Writes are refused with 403 read_only on your own Sesera subscription; manage that one from the dashboard.
| HTTP | code | meaning |
|---|---|---|
| 400 | bad_label / bad_mode / bad_host / bad_username / password_required / allowed_ips_required / bad_did / bad_call_window / did_required … | invalid field, see message |
| 404 | not_found | trunk not on this business |
| 409 | too_many_trunks / did_taken | 5 trunks already, or that number is on another trunk |
| 429 | rate_limited | too many switch changes |
| 502 | media_host_failed / endpoint_not_loaded | the switch did not take it; retry, or check the values |
Call transfer — let the assistant put the caller through
When a caller asks for a person ("salona bağlanmak istiyorum"), the assistant can transfer the call to a line you choose instead of taking a message. The second leg is dialled through the business's own SIP trunk, so your carrier bills it (a transfer from a Sesera 0850 number is not possible: those numbers carry no outbound minutes). You need:
- an active trunk on the business that can place calls:
directionbothoroutbound(inrelaymode also the SIPpassword, see above); - calls arriving on your own number (a
relayor SIP Connect trunk).
curl -X PUT https://sesera.ai/api/v1/businesses/$BIZ/call-transfer \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"targets": [
{ "name": "Salon", "phone": "0532 000 00 00",
"description": "Arayan kuaförle ya da salondaki biriyle doğrudan konuşmak isterse" }
]
}'| field | ||
|---|---|---|
targets | required, 1 to 5 | name (1-40 letters/digits, what the assistant calls the line), phone (a Turkish number, premium-rate refused), description (optional, up to 300 characters: when to use this line) |
enabled | default true | false keeps the lines but stops offering transfer |
trunkId | optional | trunk the transfer goes out on; default: the trunk the call came in on, else the first trunk that can place calls |
ringSeconds | 10-60, default 25 | how long the line rings before the caller goes back to the assistant |
maxMinutes | 1-240, default 60 | hard end for the transferred conversation |
message | optional, up to 200 characters | what the assistant says before transferring; default "Sizi hemen bağlıyorum, lütfen hatta kalın." |
fallbackMessage | optional, up to 200 characters | what the assistant says if the line does not answer; default "Maalesef şu an ulaşamadım. İsterseniz mesajınızı alayım, size en kısa sürede dönüş yapılsın." |
GET returns the settings plus ready (and notReadyReason when a trunk that can place calls is missing) and dialsThroughTrunkId. DELETE switches transfer off. PUT replaces the whole setting.
How it behaves on a call:
- The assistant transfers only when the caller asks for the line (or accepts its offer), and only to the lines you listed; it never dials a number the caller says.
- It says
message, leaves the call, and the line rings with your number as caller ID. Assistant minutes stop at that moment; the transferred conversation is not recorded and uses no Sesera minutes. - If the line is busy or does not answer within
ringSeconds, the caller is back with the assistant, which saysfallbackMessageand takes a message. That second part counts as a new call inGET /api/v1/calls.
| HTTP | code | meaning |
|---|---|---|
| 400 | targets_required / too_many_targets / bad_target_name / duplicate_target_name / bad_target_phone / bad_target_description / bad_ring_seconds / bad_max_minutes / bad_message / bad_trunk_id | invalid field, see message |
| 409 | transfer_trunk_required | the business has no active trunk that can place calls |
| 409 | transfer_trunk_cannot_dial | trunkId is not an active outbound trunk of this business |
| 409 | target_loops_back | the line is a Sesera number or a number on a SIP trunk, so it would ring an assistant again |
| 403 | read_only | your own Sesera subscription; not available through the API |
POST /api/v1/businesses/{id}/calls — ring a number
curl -X POST https://sesera.ai/api/v1/businesses/$BIZ/calls \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"to": "+905321234567",
"firstMessage": "Merhaba, ben {{business_name}} asistanıyım. Kısa bir bilgi vermek için aradım.",
"extraInstructions": "Amaç: yeni kampanyayı anlat, ilgilenirse randevu ver. İlgilenmiyorsa kibarca kapat.",
"metadata": { "campaignId": "eylul-kampanya", "leadId": "42" }
}'| field | type | notes |
|---|---|---|
to | string | required. E.164 (+90…) or a Turkish local form |
trunkId | string | optional while the business has exactly one outbound trunk; required with several |
firstMessage | string | the assistant's opening line. {{business_name}} is substituted. Omit to use the normal greeting — which is usually wrong on an outbound call |
extraInstructions | string | why this call exists. Injected ahead of the stored knowledge; tone, language policy and brevity rules still apply |
metadata | object | echoed back on reads; your campaign/lead ids |
{ "call": { "id": "…", "status": "dialing", "to": "+905321234567", "trunkId": "…" } }202 means the call file reached the switch — not that anybody answered.
GET /api/v1/businesses/{id}/calls — what happened
{ "calls": [ { "id": "…", "to": "+90…", "status": "completed", "callId": "…",
"durationSeconds": 74, "createdAt": "…", "endedAt": "…" } ] }status: queued → dialing → completed / no_answer / failed. callId points at the GET /api/v1/calls/{callId} record with the transcript.
Errors specific to outbound
| HTTP | code | meaning |
|---|---|---|
| 402 | no_plan | the business has no minute budget; SIP Connect refuses unmetered accounts |
| 402 | usage_cap_reached | minutes exhausted |
| 403 | consent_required | the İYS/consent acknowledgement is not ticked on the trunk |
| 403 | blocked_contact | the number is on the business's own block list |
| 409 | no_trunk | no active outbound trunk configured |
| 409 | outside_calling_window | outside the trunk's local calling hours (default 09:00–20:00 Europe/Istanbul) |
| 409 | trunk_inactive / direction_not_allowed | the trunk is off, or set to inbound only |
| 429 | daily_cap_reached / trunk_busy | per-day or simultaneous-call ceiling hit |
| 502 | dial_failed | the switch could not be reached — retry |
An unanswered call costs zero AI minutes: the assistant is only bridged in after the callee picks up.
Compliance. Outbound marketing in Turkey is subject to İYS. The consent, the İYS registration and the KVKK notice belong to the business placing the calls — which is what the acknowledgement records. Sesera enforces the mechanics (calling window, block list, daily cap, per-call audit trail); it does not obtain consent for you.
Performance analysis — read what Sesera measured for a business
Sesera scans every live business twice a day and writes a performance report: how many calls came in over a 28-day window, how many turned into a booking or a message, how that conversion compares with similar Sesera businesses, and what specifically looks wrong (a greeting that is too long, a call limit that cuts people off, a volume drop against the line's own baseline).
For a business you provisioned there is no Sesera dashboard and no owner to show a card to, so this endpoint is how the analysis reaches you. What you do with it is yours to decide: show it in your own panel, mail it, act on it through the API, or ignore it.
GET /api/v1/businesses/{businessId}/health{
"health": {
"businessId": "…",
"computedAt": "2026-09-10T15:40:12.004Z",
"windowDays": 28,
"metrics": {
"calls": 134,
"recovered": 58,
"bookings": 41,
"messages": 17,
"recoveryRate": 0.4328,
"callsPerDay": 3.7,
"baselineCallsPerDay": 5.2
},
"cohort": { "size": 21, "p25": 0.21, "median": 0.34, "p75": 0.52 },
"verdict": "ortalama_ustu",
"severity": "izle",
"findings": [
{
"code": "uzun_karsilama",
"severity": "izle",
"detail": "Karşılama cümleniz 228 karakter; arayan konuşmaya başlamadan önce uzun bir anons dinliyor.",
"suggestedChange": {
"setting": "set_greeting",
"args": { "presetId": "kisa" },
"label": "Karşılamayı kısaltalım"
}
}
],
"summaryTr": "DURUM: …"
},
"reviewed": false
}| Field | Meaning |
|---|---|
metrics | The raw counts the verdict was computed from. recoveryRate is recovered / calls, null when there were no calls. callsPerDay is the last 7 days, baselineCallsPerDay the trailing period it is compared against. |
cohort | Where the line sits among comparable Sesera businesses: the group size and its three quartiles. Anonymous by construction, never another tenant's row. |
verdict | ustun, ortalama_ustu, ortalama, dusuk or yetersiz_veri. Computed from the conversion rate only. |
severity | ok, izle (worth looking at) or acil (something is actively costing calls). |
findings[] | One entry per detected problem, each with a plain-Turkish detail. suggestedChange names a field you can already PATCH on the business; nothing is applied for you. |
summaryTr | The whole report as Turkish prose, safe to show or read out loud as is. |
reviewed | Whether a Sesera reviewer has cleared this report for a Sesera customer. Always false for businesses you provisioned, and that is normal: the review queue exists for businesses whose owner sees a Sesera dashboard, and yours have no such owner. |
404 no_report means the scan has not produced a report for this business yet. It needs a live line with some call history; a business created an hour ago will answer 404 until the next scan. The scan runs at 09:40 and 18:40 Europe/Istanbul.
Reading is free of side effects: it never marks anything as delivered and never changes what the next scan does.
Your own Sesera subscription is readable through this endpoint too, with one difference: it IS a Sesera customer with a Sesera dashboard, so the advice half of the report only appears once a Sesera reviewer has cleared it (reviewed true). The counts are always there.