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.
An agent can also be reached without a phone number: from a call button on a website, or from your own app or bot over a WebSocket (see Speech to speech 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. A business-scoped key sees exactly one business (see Business-scoped keys).
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 returns403 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 statusawaiting_number(no error). Its number is bound later viaPOST /api/v1/businesses/{id}/assign-number, which is the only call that can return409 no_numbers. Retry that call after Sesera restocks. - Your numbers stay yours — when you delete a business, its number does not go back to Sesera's pool. It stays on your account as an unassigned number (listed in
numbers.unassignedonGET /api/v1/me) and is never given to anyone else. It is not attached to a new business automatically: you attach it yourself withPOST /api/v1/businesses/{id}/assign-number(or in the developer panel under Telefon numaraları). An unassigned number still holds one slot of your cap, so while you hold one and have no other free slot, a new business is created asawaiting_numberinstead of taking a fresh pool number. - 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). The end of every call reaches your webhook ascall.ended: who called, when, how long, the outcome and a short summary. WithliveTranscript: trueon a business, the call itself is streamed too (call.started,call.transcript) andcall.endedcarries the transcript, see Webhooks. - Business-scoped key — an API key minted for ONE business (developer panel → Asistanlar → the assistant → Gelişmiş tab → Bu işletmeyi devredin). Hand it to your customer or their developer: it manages that business and nothing else — not your pool, your other businesses, your purchases or your account settings. Your own keys stay account-wide.
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 |
| 400 | invalid_domains | allowedDomains has entries that are not domain names (see Website call button) |
| 401 | unauthorized | missing / invalid / revoked key |
| 402 | insufficient_pool | not enough minutes in your pool to allocate |
| 403 | reseller_suspended | your account is suspended: every API request is refused (reads included) until Sesera reopens it. Your businesses keep answering calls on their allocated minutes, unless Sesera has also stopped calls on the account. See Account suspension |
| 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) |
| 403 | quota_reached | number cap reached — delete one, or buy capacity in the developer panel under Telefon numaraları (one-time 1.000 ₺ per number) |
| 403 | plan_assistant_limit | your Developers plan's assistant limit is reached (POST /businesses). Existing businesses keep working; upgrade the plan in the developer panel to add more |
| 403 | plan_feature_unavailable | the feature is not in your Developers plan: call analysis (GET /businesses/{id}/health, Pro and above), SIP Connect (sip-trunks create, edit, verify; Pro and above) or hiding the "Sesera ile" line on the website call button (button.showBadge: false, Creator and above). Listing and deleting trunks stay open |
| 403 | key_scope | a business-scoped key called an account-level endpoint (or set a locked field, see Business-scoped keys) |
| 404 | not_found | business / call / booking 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) |
| 409 | slot_full · people_full | booking write: the time already holds bookingSlotCapacity bookings, or the people limit (bookingMaxPartySize) would be exceeded (see Create a booking) |
| 409 | cancelled | reschedule of a booking that is already cancelled |
| 422 | in_past · closed_day · outside_hours · blocked · party_too_large · party_size_required | booking write refused by the business's booking rules (see Create a booking) |
| 422 | bookings_disabled | booking create or reschedule on a business with bookingsEnabled: false; turn it on first (cancelling still works) |
| 404 | whatsapp_account_not_found | accountId is not one of your organization's WhatsApp numbers (list them with GET /api/v1/whatsapp/accounts) |
| 409 | whatsapp_account_not_connected | followUp / assistantReplies / answersNewChats (or account refresh) on a business that is not linked to a WhatsApp Business number; link it with accountId |
| 429 | rate_limited | slow down (see Retry-After) |
| 429 | reseller_concurrency_limit | your Developers plan's concurrent call limit is reached (voice sessions, outbound calls) |
Account suspension
Sesera can put a partner account on hold in two steps, and tells your team by email (the account's contact address and every verified team member) each time the state changes:
- Suspended — the API answers
403 reseller_suspendedand the developer panel is read-only. Calls continue: your businesses keep answering on the minutes already allocated to them, and an automatic top-up you configured (see Automatic top-up for a business) keeps moving minutes from your pool. Webhooks keep arriving, so bookings, messages and low-minute alerts from those calls still reach your system. - Calls stopped — everything above, and every inbound and outbound call of every one of your businesses is refused (callers hear busy, no minutes are used).
To reopen an account, write to [email protected].
Account
GET /api/v1/me
Your account status, number quota, and minute pool. Not available to business-scoped keys (403 key_scope).
curl https://sesera.ai/api/v1/me -H "Authorization: Bearer $KEY"{
"reseller": {
"id": "…", "name": "Akdeniz Telekom", "status": "active",
"numbers": { "used": 3, "max": 10, "unassigned": ["+908501234567"] },
"minutes": {
"balance": 250, "max": 0,
"autoTopUp": {
"enabled": true,
"thresholdMinutes": 200,
"topupMinutes": 1000,
"priceTry": 3249,
"limits": { "dailyMaxCharges": 10, "monthlyMaxTry": 100000 },
"usage": { "chargesToday": 1, "chargedThisMonthTry": 3249 },
"card": { "last4": "4242", "scheme": "VISA", "expMonth": "11", "expYear": "31", "autoChargeable": true },
"disabledReason": null,
"lastSuccessAt": "2026-10-09T19:43:48.930Z",
"lastFailureAt": null
}
}
}
}(max: 0 = no ceiling.) numbers.used counts numbers on your businesses and does not include unassigned; the slots left for a new pool number are max − used − unassigned.length. minutes.autoTopUp is your pool's card top-up, read-only; the fields are explained under Automatic top-up for your pool.
GET /api/v1/voices
The voices you may set as voiceId: the preset Turkish voices ("type": "preset") and your own cloned voices ("type": "custom", see Custom voices). A preset voice's preview_url is 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; it is public, cacheable and needs no Authorization header. A custom voice's preview_url needs your API key.
{ "voices": [ { "id": "IOx9E82IJLWAeUWBCdDz", "type": "preset", "name": "Yağmur", "description": "Sıcak, samimi sohbet sesi", "gender": "female", "preview_url": "https://sesera.ai/_voicesamples/IOx9E82IJLWAeUWBCdDz.mp3" }, … ] }Custom voices (voice cloning)
Clone a voice from a one minute recording and use it on any of your businesses. A cloned voice belongs to your account, not to a single business: every business you manage can use it. Voice cloning is included on every plan.
You can also clone voices in the dashboard: Sesler → Seslerim → Yeni ses klonla.
List voices
GET /api/v1/voices
Returns the preset Turkish voices and your own cloned voices. Business-scoped keys can call this endpoint (they need it to pick a voiceId).
{
"voices": [
{
"id": "…",
"type": "preset",
"name": "Yağmur",
"description": "Sıcak, samimi sohbet sesi",
"gender": "female",
"preview_url": "https://sesera.ai/_voicesamples/….mp3"
},
{
"id": "ozel_k7m2q9abcd12",
"type": "custom",
"name": "Selin Hanım",
"description": "Warm, friendly",
"gender": null,
"status": "ready",
"durationSec": 72,
"createdAt": "2026-10-22T11:05:00.000Z",
"preview_url": "https://sesera.ai/api/v1/voices/ozel_k7m2q9abcd12/preview"
}
]
}| Field | Notes |
|---|---|
type | "preset" for the built-in voices, "custom" for your clones. |
id | Use it as voiceId on POST /businesses and PATCH /businesses/{id}. Custom ids start with ozel_. |
status | Custom voices only. Only ready voices are listed. |
preview_url | Preset previews are public MP3 files. Custom previews need your API key (Authorization: Bearer …) and return audio/mpeg. |
Clone a voice
POST /api/v1/voices (multipart/form-data, account-wide key)
| Field | Required | Notes |
|---|---|---|
audio | yes | MP3, WAV, M4A, WebM or OGG. At least 60 seconds of one person speaking, at most 20 MB. Only the first 3 minutes are used. |
name | yes | 1 to 40 characters. |
consent | yes | Must be true. You confirm the voice is yours or that you have its owner's explicit permission. The time and the API key are stored with the voice. |
description | no | Up to 80 characters, shown next to the name. |
curl -X POST https://sesera.ai/api/v1/voices \
-H "Authorization: Bearer rsk_live_…" \
-F [email protected] \
-F name="Selin Hanım" \
-F description="Warm, friendly" \
-F consent=trueThe voice is usually ready in under a minute and the request waits for it. Response 201:
{ "voice": { "id": "ozel_k7m2q9abcd12", "type": "custom", "name": "Selin Hanım", "status": "ready", "…": "…" } }Tips for a natural clone: a quiet room, one speaker, no music or TV, normal speaking pace (the clone speaks the way it hears you).
Use it on a business
curl -X PATCH https://sesera.ai/api/v1/businesses/{id} \
-H "Authorization: Bearer rsk_live_…" \
-H "Content-Type: application/json" \
-d '{ "voiceId": "ozel_k7m2q9abcd12" }'The next call is answered in that voice. GET /businesses returns the custom id in voiceId. Setting a preset voiceId switches the business back to that preset. A custom voice from another account is rejected with 400 invalid_voice.
Get or delete a voice
GET /api/v1/voices/{id} returns one custom voice.
DELETE /api/v1/voices/{id} (account-wide key) deletes the voice and its recording. Businesses that used it fall back to their preset voice on the next call.
{ "deleted": true, "id": "ozel_k7m2q9abcd12", "businessesReset": 2 }Preview
GET /api/v1/voices/{id}/preview returns a short audio/mpeg sample in the cloned voice.
Errors
| Status | error | When |
|---|---|---|
| 400 | consent_required | consent is missing or not true. |
| 400 | audio_required / name_required | Missing field. |
| 400 | audio_too_short | Less than 60 seconds of audio (durationSec tells you what we measured). |
| 400 | invalid_audio | The file could not be read as audio. |
| 400 | invalid_voice | voiceId on a business is neither a preset nor one of your ready custom voices. |
| 403 | key_scope | A business-scoped key tried to clone or delete a voice. Use an account-wide key. |
| 404 | not_found | The voice does not exist or belongs to another account. |
| 409 | voice_limit | Your account has too many voices; delete one you no longer use or contact us. |
| 413 | audio_too_large | Over 20 MB. |
| 429 | rate_limited | Too many clones in an hour. |
| 502 | training_failed | The voice could not be created; try again shortly. |
Business-scoped keys
Give a business to someone else — your customer, or their developer — without giving them your account. In the developer panel, open the assistant under Asistanlar, go to the Gelişmiş tab and use Bu işletmeyi devredin to mint a key for that business only. The full key is shown once; hand it over together with the business id.
A business-scoped key authenticates exactly like your own key (Authorization: Bearer <key>), but:
- it sees one business. Single-business endpoints answer
404 not_foundfor any other id (exactly like an id that doesn't exist). List endpoints return only that business; abusinessId=filter pointing elsewhere returns an empty list. - account-level endpoints answer
403 key_scope, whatever id you pass. - it stops working (
401 unauthorized) when you revoke it or delete the business. - it has its own rate-limit bucket (per business), so your customer's traffic never eats into your account's limit.
| Endpoint | Business-scoped key |
|---|---|
GET /api/v1/voices | ✅ |
GET /api/v1/voices/{id}, GET /api/v1/voices/{id}/preview | ✅ |
GET /api/v1/businesses | ✅ only that business |
GET /api/v1/businesses/{id} | ✅ |
PATCH /api/v1/businesses/{id} | ✅ except tools / customTools and autoTopUp → 403 key_scope |
GET · PUT · DELETE /api/v1/businesses/{id}/call-transfer | ✅ |
GET · PUT · DELETE /api/v1/businesses/{id}/keypad | ✅ |
GET · POST · DELETE /api/v1/businesses/{id}/forwarding-sources | ✅ |
GET /api/v1/businesses/{id}/health | ✅ |
GET · POST /api/v1/businesses/{id}/sip-trunks, GET · PATCH · DELETE …/sip-trunks/{trunkId}, POST …/verify | ✅ |
GET · POST /api/v1/businesses/{id}/calls (outbound) | ✅ |
GET · PATCH /api/v1/businesses/{id}/web-widget | ✅ |
GET · PATCH /api/v1/businesses/{id}/whatsapp, POST …/whatsapp/account/refresh | ✅ except accountId and answersNewChats → 403 key_scope |
GET /api/v1/whatsapp/accounts | ❌ 403 key_scope |
POST /api/v1/businesses/{id}/voice-sessions | ✅ |
GET /api/v1/calls, /bookings, /messages | ✅ only that business |
GET /api/v1/webhook-deliveries | ❌ 403 key_scope |
POST /api/v1/bookings, POST /api/v1/bookings/{id}/cancel, POST /api/v1/bookings/{id}/reschedule | ✅ only that business |
GET /api/v1/calls/{id}, GET /api/v1/calls/{id}/recording | ✅ that business's calls |
GET /api/v1/me | ❌ 403 key_scope |
POST /api/v1/voices, DELETE /api/v1/voices/{id} | ❌ 403 key_scope |
POST /api/v1/businesses | ❌ 403 key_scope |
DELETE /api/v1/businesses/{id} | ❌ 403 key_scope |
POST /api/v1/businesses/{id}/assign-number | ❌ 403 key_scope |
POST /api/v1/businesses/{id}/minutes | ❌ 403 key_scope |
POST · DELETE /api/v1/businesses/{id}/shared-line | ❌ 403 key_scope |
GET /api/v1/shared-lines | ❌ 403 key_scope |
Why tools and autoTopUp are locked: custom tool calls on your businesses carry your account's webhook signing secret, so only an account-wide key may point them at a URL; autoTopUp moves minutes out of your pool (see Automatic top-up for a business). Set both with your own key.
Webhooks keep going to your account webhook; a business-scoped key has no webhook of its own. Forward the events your customer needs.
Businesses
POST /api/v1/businesses — create an agent (+ number when in stock)
Start without a number. A new Developers account has a number cap of 0, so a create that takes a pool number (the default, "number": "auto") answers 403 quota_reached. Send "number": "none" as in the example below: the cap is not touched, and the assistant works right away on your website call button, the speech to speech API and your shared lines. A number is bought separately, one time 1.000 ₺ per number (developer panel → Telefon numaraları), and bound later with assign-number.
With "number": "auto" and a free slot in your cap: 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 the assign-number call 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": [{ "open": "09:00", "close": "18:00" }] }, … }. Once you send hours, the assistant tells callers your opening hours and whether you are open now; a day you leave out counts as closed. | |
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. Can also give the assistant a persona: a name, age, personality or backstory (e.g. *"Asistanın adı Elif, 28 yaşında, esprili biri"*). It then introduces itself with that name; asked seriously whether it is human, it still says it is an AI assistant | |
greeting | string \ | null | the assistant's opening line — 30–250 chars; the {businessName} placeholder is optional. null = back to the built-in greeting; omit the field to keep the current one. See Greeting |
outboundGreeting | string \ | null | the opening line when the assistant places a call — 10–300 chars, may contain {{variables}} filled per call. null = outbound calls without their own firstMessage use greeting. See Outbound 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 | true (default) = the audio is recorded and the announcement says *"Bu görüşme kayıt altındadır …"*. false = the call audio is never stored, only the transcript; the announcement says *"Görüşme yazıya dökülür …"*. Omit to keep the current value. See Call recording | |
callerMemory | boolean | true (default) = after each call the assistant notes what is worth remembering about the caller (name, address, what they asked for) under that phone number, and greets a repeat caller by name next time. false = nothing is noted and earlier notes are not used. Omit to keep the current value. See Caller memory | |
voiceId | string | from GET /voices. If omitted, the default voice is used and voiceId reads null until you set one | |
personaPreset | string | the assistant's tone: sicak (default), kurumsal, enerjik or sakin | |
voiceSpeed | number | talking pace, 1.0 = natural. Accepted 0.5–1.5; on calls it is applied within 0.85–1.20 (values outside are clamped, steps of 0.05). The voice keeps its pitch. Omit to keep the current value | |
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) | |
bookingDefaultDurationMinutes | int | appointment length when the service has none, 1–1440 | |
bookingSlotCapacity | int | how many bookings may run at the same time, 1–100 (default 1 = one booking per slot, a second caller is offered another time). See Bookings: capacity, party size, cancel | |
bookingMaxPartySize | int \ | null | the most people that may be booked into the same time, 1–2000, for restaurants and the like. When set, the assistant asks the caller how many people before booking. null (default) = no people limit and party size is not asked. Omit to keep the current value |
closedDays | array \ | null | special closed days on top of the weekly hours (holidays, annual leave): [{ "from": "2026-12-31", "to": "2027-01-01", "note": "Yılbaşı" }]. to defaults to from (one day), note is optional (longer notes are cut to 60 characters), up to 50 entries, a range is at most one year. Replaces the whole list; null or [] clears it; omit to keep it. See Closed days |
liveTranscript | boolean | stream the call to your webhook while it runs (default false), see Live transcript | |
enabledLanguages | string[] | extra ISO-639-1 codes the agent may switch to (Turkish is always on). Accepted: af ar az be bg bn bs ca cs cy da de el en es et eu fa fi fil fr gl gu he hi hr hu id it ja kk kn ko lt lv mk ml mr ms nl no pa pl pt ro ru sk sl sq sr sv sw ta te th uk ur vi zh. Up to 59 entries; other codes are stored but ignored on calls. On calls the first 5 accepted codes are used, and the assistant answers callers in every accepted language | |
maxCallMinutes | int | per-call cap in minutes, 0–600; 0 = unlimited. Default 5 | |
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 {{ (outboundGreeting is the exception: it takes {{variables}}). Unknown fields are ignored without an error, so check the returned object after a write.
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": [{ "open": "09:00", "close": "19:00" }] } },
"services": [{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }],
"bookingsEnabled": true,
"number": "none"
}'201 Created:
{
"business": {
"id": "…", "name": "Yılmaz Kuaför", "category": "kuafor",
"status": "provisioning", "phoneNumber": null, "bookingsEnabled": true,
"bookingSlotCapacity": 1, "bookingMaxPartySize": null, "closedDays": [],
"tools": [],
"voiceId": null,
"minutes": { "allocated": 0, "used": 0, "remaining": 0 },
"autoTopUp": { "enabled": false, "belowMinutes": null, "amountMinutes": null },
"createdAt": "2026-07-03T…", "updatedAt": "2026-07-03T…"
}
}Without a number,statusstaysprovisioningandphoneNumberisnulluntil you bind a number or join a shared line; the website call button and the speech to speech API work regardless. A pool number is live immediately. Either way 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.
Body is optional. Without a body, your own unassigned number (from a deleted business, see numbers.unassigned on GET /api/v1/me) is used first, then Sesera's pool. To pick one of your unassigned numbers, send it: { "number": "+908501234567" }.
curl -X POST https://sesera.ai/api/v1/businesses/$ID/assign-number \
-H "Authorization: Bearer $KEY"
curl -X POST https://sesera.ai/api/v1/businesses/$ID/assign-number \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "number": "+908501234567" }'200 OK → { "business": { …, "status": "live", "phoneNumber": "+90850…" } } · 409 no_numbers (pool empty — retry after restock) · 403 quota_reached · 404 number_not_found (number is not one of your unassigned numbers).
GET /api/v1/businesses — list yours
{ "businesses": [ { …business… }, … ] }With a business-scoped key the list holds only that business (and never your own Sesera subscription).
GET /api/v1/businesses/{id} — fetch one
Same business object as create. It reports status, number, minutes and the settings listed in it (greeting, outboundGreeting, voiceId, tools names, announcement fields, liveTranscript, callRecording, callerMemory, autoTopUp, bookingsEnabled, bookingSlotCapacity, bookingMaxPartySize, closedDays); the knowledge you sent (hours, services, faq, freeText, extraInstructions) and the settings personaPreset, voiceSpeed, maxCallMinutes, enabledLanguages and the other booking* fields are not echoed back, so keep your own copy. managed: false marks your own Sesera subscription: it is read-only here (403 read_only on writes).
PATCH /api/v1/businesses/{id} — edit
Send only the fields you want to change (same fields as create, plus autoTopUp, see Automatic top-up for a business); 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
Frees the business's assistant slot. Its number stays on your account as an unassigned number (numbers.unassigned on GET /api/v1/me), still holds one slot of your number cap, is never given to anyone else, and is attached to another business only when you call assign-number for it. Minutes you allocated to the business and it has not used go back to your pool (a + entry in your pool history). Its SIP trunks are removed, and its business-scoped keys stop working (401). Through the API this is also the only way to take allocated minutes back from a business; the developer panel can move unused minutes back without deleting (Havuza geri al).
{ "deleted": true, "id": "…" }409 line_in_use while the business carries a shared line that other businesses still forward into (detach them first).
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
liveas soon as its first forwarding number is registered. Create it with"number": "none"onPOST /api/v1/businessesso no pool number is taken (statusprovisioninguntil 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. Response: { "ok": true, "phoneNumber": "+90850…" }.
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 (not a Turkish number), 400 validation_error (gsm missing or shorter than 10 characters).
DELETE /api/v1/businesses/{id}/forwarding-sources
{ "gsm": "05xx xxx xx xx" }Response: { "ok": true }.
A business left with no number and no forwarding source stops taking calls (status: "provisioning"). It does not hold a number slot; attach it to a line again or call assign-number to give it its own 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 developer panel under Telefon numaraları, next to where you buy number capacity.
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. minutes is rounded to 2 decimals (400 bad_amount if that leaves 0); the pool is debited exactly, while the business's minutes count whole minutes (rounded down), so allocate whole minutes to keep both sides equal. To take minutes back from a business through the API, delete it (unused minutes return to the pool); in the developer panel, Havuza geri al moves them back without deleting.
Topping up your pool. Fund the pool yourself in the developer panel under Dakika ve ödeme (every Sesera account is a corporate account there, opened on first sign-in, no application needed). The panel also moves minutes to a business, same as this endpoint. Accounts opened on the developer site get their minutes from a monthly plan: the plan adds its included minutes to your pool every 30 days (unused plan minutes carry over for up to 2 months, then expire). Extra minutes are one flat per-minute price on every plan and need an active plan. Older accounts without a plan buy 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.
Automatic top-up for your pool
In the developer panel under Dakika ve ödeme you can save a company card and turn on auto top-up: when your pool drops below thresholdMinutes, Sesera charges the saved card for topupMinutes at your current minute price (priceTry: the flat extra-minute price on a plan, the volume rate without one) and adds them to your pool. Each top-up is a normal purchase: it appears in your pool history and you receive the usual receipt. It is checked whenever you allocate minutes to a business, so your integration needs no extra call.
The API shows it read-only, as minutes.autoTopUp in GET /api/v1/me (see the example there):
limitsare your own safety caps: at mostdailyMaxChargesautomatic charges per day andmonthlyMaxTryTL per calendar month (Turkey time). They default to 10 charges and 100,000 TL and can be changed under "Advanced settings" in the panel. When a cap is reached, auto top-up waits until the next day or month; you can still buy minutes by hand at any time.cardisnullwhen no card is saved.autoChargeable: falsemeans the card cannot be charged without the cardholder present (some debit cards); add another card in the panel.disabledReason:nullwhile on, or after you switched it off yourself;"payment_failed"after two declined charges in a row (we also email you);"card_removed"after the card was removed or replaced. Turn it back on in the panel.- Cards and auto top-up settings can only be managed in the panel, never through the API.
This refills your pool from your card. To refill a business from your pool, see Automatic top-up for a business.
Automatic top-up for a business
Let a business refill itself from your pool so its line never goes quiet: when its remaining minutes drop below belowMinutes, Sesera allocates amountMinutes from your pool to it — the same allocation as POST /businesses/{id}/minutes, recorded the same way. Set it with PATCH:
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "autoTopUp": { "enabled": true, "belowMinutes": 30, "amountMinutes": 200 } }'| field | type | notes |
|---|---|---|
enabled | boolean | required |
belowMinutes | integer 1–100000 | required when enabled is true |
amountMinutes | integer 1–1000000 | required when enabled is true; must be at least belowMinutes, so a single top-up always lifts the business above the threshold |
{ "autoTopUp": { "enabled": false } } or { "autoTopUp": null } switches it off; the last belowMinutes / amountMinutes are kept and returned. A PATCH that carries only autoTopUp does not touch the agent (no recompile, no re-sync). If the business is already below the threshold when you switch it on, the first top-up happens right away.
The check runs after every call and never delays one. If your pool does not hold amountMinutes, nothing is moved (if your pool itself refills from your card, see Automatic top-up for your pool, that refill is started right then) and the low-minutes alert takes over. A business that has run dry is topped up on its next incoming call once your pool has the minutes; that first call may still hear busy. Two calls ending at the same moment never trigger two top-ups.
Low-minutes alert
Sesera watches the highest balance each business has ever had (its remaining minutes right after an allocation). When the remaining minutes fall to 2 % of that peak or less, you get one alert: a signed business.minutes_low webhook (see Webhooks) and a short email to your team with a link to allocate minutes. It fires once; it re-arms when the business is back above 2 % of its peak (usually after you allocate). Example: a business that peaked at 1 000 minutes alerts at 20 minutes left.
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. It is optional: a greeting can open with the assistant's own name instead (*"Merhaba, ben Elif. Size nasıl yardımcı olabilirim?"*). If you do mention the business, use the placeholder rather than hard-coding 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.
Outbound greeting
greeting is what the assistant says when someone calls in. When the assistant places a call (Outbound calls), the person it rings did not ask to be called, so it can open with a different line:
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"outboundGreeting":"Merhaba {{ad}}, {businessName} adına arıyorum. Yarın saat {{saat}} randevunuzu hatırlatmak istedim."}'- Order on every outbound call: the call's own
firstMessage, elseoutboundGreeting, elsegreeting. {{name}}placeholders are filled from the call'svariables(POST /calls). A placeholder with no value refuses the call (missing_variables) rather than reading the braces out loud.{businessName}and{{business_name}}need no value.- 10–300 characters.
null(or an empty string) removes it; omitting the field leaves it untouched.GETreturns the text you set, ornull.
Call recording
Every call opens with a very short announcement, played before the greeting. Which one depends on callRecording:
callRecording | the caller hears | stored |
|---|---|---|
true (default) | *"Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz."* | transcript + audio |
false | *"Görüşme yazıya dökülür ve yapay zekayla konuşuyorsunuz."* | transcript only, no 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ı). - The sentence is fixed.
announcementName,announcementPrefix,announcementSuffixandannouncementAiNoticework with both values (withannouncementNameandfalse: *"Şu anda {announcementName} yapay zekası ile konuşuyorsunuz ve görüşme yazıya dökülür."*).
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.
nullgoes 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.
Caller memory
When a call ends, the assistant writes short notes about the caller under their phone number, for this business only: the name they gave, their address, what they asked for or booked, and anything left open. When the same number calls the same business again, the assistant already knows these, greets the caller by name and does not ask for it again.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"callerMemory":false}'- On by default for every business. Takes effect from the next call.
false: no notes are written and existing notes are not used. Turning it back on brings the earlier notes back.- Notes are kept per business and per number; another business never sees them. Card numbers, identity numbers and passwords are never noted. A caller who asks to be forgotten is forgotten.
- From a landline or switchboard number the assistant does not greet by name, since several people may call from the same line.
- Notes of a number that has not called for 12 months are not used.
- Also accepted on
POST /api/v1/businesses. The setting is also in Sesera Dev and in the developer panel (Asistan).
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).
Announcement suffix & AI notice
The same announcement controls the business owner has in the panel (Ayarlar → Karşılama mesajı) are available through the API: a sentence after the mandatory announcement, and switching off its "you are talking to an AI" part. Together with Announcement prefix you can shape the whole opening; the recording notice itself always plays.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"announcementPrefix":"Merhaba, Burak Bey'\''in sanal asistanıyım.",
"announcementAiNotice":false,
"announcementSuffix":"Size nasıl yardımcı olabileceğimizi dinliyoruz."}'The next call on that business opens with: *"Merhaba, Burak Bey'in sanal asistanıyım. Bu görüşme kayıt altındadır. Size nasıl yardımcı olabileceğimizi dinliyoruz."*, then the assistant says its greeting.
| Field | Type | What it does | |
|---|---|---|---|
announcementSuffix | string \ | null | your own sentence read after the mandatory announcement, in the same announcer voice. Same rules as announcementPrefix: 3 to 120 characters, letters, digits, spaces and basic punctuation (. , ! ? ' : ; & ( ) -), no web addresses, a final period is added if missing. null or an empty string removes it; omit to keep the current one. |
announcementAiNotice | boolean | true (default): the announcement says the caller is talking to an AI. false: only the recording notice is read (*"Bu görüşme kayıt altındadır."*, or *"Görüşme yazıya dökülür."* when callRecording is off). Omit to keep the current setting. |
- What the caller hears, in order:
announcementPrefix, the mandatory announcement,announcementSuffix, then the greeting. - The recording notice cannot be switched off or reworded by any field; only the AI part is optional.
- With
announcementAiNotice: false,announcementNameis not used (the name only appears in the AI part). - Calls whose announcement played remain listenable through
GET /api/v1/calls/{id}/recordingwith the AI part switched off as well. - Both fields are accepted on
POST /api/v1/businessesandPATCH /api/v1/businesses/{id}, set per business, and returned by everyGET(announcementSuffix: the sentence ornull;announcementAiNotice:true/false). - Takes effect from the next call. The business owner can change the same settings in the panel.
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; a name that resolves to a private network or to Sesera's own servers is not called) | |
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 the developer panel under Webhook). Verify it and reject anything else. The secret is created when you first save a webhook URL: until then tool calls go out without this header, so set your webhook first. - 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.
Website call button
Put a call button on your customer's website. A visitor presses it and talks to the business's assistant in the browser, exactly like a phone call: same assistant, same knowledge, same booking tools. No phone number is needed.
Paste one line into every page of the site (right before </body>):
<script src="https://sesera.ai/widget.js" data-key="wk_…" async></script>Get the line (embedCode) from GET /api/v1/businesses/{id}/web-widget or from the developer panel under Web sitesi. A round button appears in the bottom-right corner.
Your own button instead. Add data-konum="yok" to the script tag (or set button.position to none) and put data-sesera-ara on any element: pressing it starts the call. From your own code: window.Sesera.ara().
<button data-sesera-ara>Asistanla konuş</button>Allowed domains. The key is not a secret: it sits in the page's HTML. What protects it is the domain list: the button only works on sites whose domain is listed (the browser's Origin is checked on every call).
example.commatchesexample.comandwww.example.com.*.example.commatchesexample.comand every subdomain.- A full URL is accepted and reduced to its host (
https://www.Example.com/x→www.example.com). A port, if written, must match too.localhost(with a port) works over plainhttpfor local testing; every other site must behttps. - A domain with Turkish letters (
çiçekçi.com) is stored in its punycode form (xn--…), the form browsers send. A public suffix on its own (com.tr,*.com.tr,*.co.uk) is refused. - Up to 20 domains. An empty list means the button works on no site yet.
Billing and records. A website call uses the business's minutes, exactly like a phone call, and is refused when the business has no minutes left. It appears in GET /api/v1/calls and in the panel's call list like any other call. Before the call starts, the visitor is shown the KVKK announcement. dailyLimit caps how many website calls the business takes per day (default 100), a safety net on top of the minutes. At most 3 website calls per business run at the same time, and they also count against your plan's concurrent call limit; above either limit the visitor is told the assistant is busy.
GET /api/v1/businesses/{id}/web-widget
Created on first read (switched on, no domains yet).
{
"webWidget": {
"enabled": true,
"publicKey": "wk_3f9a0c1e5b7d4a2f8e6c0b1d9a7f5e3c",
"allowedDomains": ["ornekkuafor.com", "*.ornekkuafor.com"],
"button": { "text": "Bizi arayın", "color": "#111827", "position": "right", "showBadge": true },
"badgeRemovable": true,
"dailyLimit": 100,
"embedCode": "<script src=\"https://sesera.ai/widget.js\" data-key=\"wk_3f9a0c1e5b7d4a2f8e6c0b1d9a7f5e3c\" async></script>",
"updatedAt": "2026-10-21T19:40:00.000Z"
}
}On your own Sesera subscription (read-only) webWidget is null until one exists.
PATCH /api/v1/businesses/{id}/web-widget
Send only the fields you change.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID/web-widget \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"allowedDomains":["ornekkuafor.com"],"button":{"text":"Hemen arayın","color":"#0f766e","position":"left"}}'| field | type | |
|---|---|---|
enabled | boolean | false hides the button on every site |
allowedDomains | string[] | replaces the whole list (max 20) |
button.text | string | 1 to 40 characters |
button.color | string | hex, #rrggbb |
button.position | right · left · none | none = no corner button, use data-sesera-ara |
button.showBadge | boolean | default true. false hides the "Sesera ile" line; Creator plan and above, see below |
dailyLimit | integer | 1 to 5000 website calls per day |
rotateKey | boolean | true issues a new publicKey: the old embed line stops working at once |
"Sesera ile" line. The call panel shows a small "Sesera ile" link under the call button. On every plan above Starter (Creator, Pro, Scale, Business, Max) you can hide it with button.showBadge: false. The read-only badgeRemovable tells you whether your current plan allows it. Hiding it on a plan that does not allow it returns 403 plan_feature_unavailable; showing it again is always allowed. If your plan later drops to Starter or lapses, your choice is kept but the line shows again (within about a minute) until you upgrade. Sesera decides this on its side for every page view, not the script on the site.
Returns the same object as GET. Errors: 400 validation_error for a bad field, 403 plan_feature_unavailable for showBadge: false below Creator, 400 invalid_domains when an entry is not a domain (nothing is saved; the bad entries are listed):
{ "error": "invalid_domains", "message": "…", "invalidDomains": ["ornek kuafor"] }Business-scoped keys may use both methods.
The business's own WhatsApp Business number, added on the Sesera Developers panel's WhatsApp page. Through the API you read its state and switch it.
When a caller asks for something in writing (address, price, a link) or agrees to continue on WhatsApp, the business's own number writes to them right after the call: one message about what was discussed. If the customer writes back, the assistant answers in that chat with the same knowledge it uses on the phone. A business already using the WhatsApp Business app keeps using it on the phone (coexistence): the owner sees these chats there and can step in.
Adding a WhatsApp Business number is done in the developer panel, not through the API. It needs Meta's sign-in window, where the owner logs in to Meta and picks the number. A number is added once on the panel's WhatsApp page (Hesap ekle) and then linked to as many assistants as you like, in the panel or through the API (accountId, see accounts); each assistant uses at most one number. The account object below is the number this business is linked to, so assistants sharing a number show the same number, templateStatus and reconnectBy, while followUp and assistantReplies are per assistant. A customer who writes to the number without an earlier chat is answered by the one assistant chosen on the account as Yeni sohbetleri cevaplayan asistan (answersNewChats); if none is chosen, those messages get no reply. Through the API you link and unlink assistants, choose the one that answers new chats and switch the two settings. The connection from Meta lasts 60 days; reconnectBy tells you when the owner has to press Yeniden bağla in the panel.
Night hours (Turkey time). No WhatsApp message is sent between 23:00 and 08:00:
- An after-call message for a call that ends at night is queued and sent at 08:00. The assistant tells the caller on the phone that the message will come at eight in the morning.
- A customer who writes to the business at night still gets the assistant's reply, if they wrote within the last few minutes.
Where the assistant answers. Only in chats that started with an after-call message in the last 7 days. It does not answer everyone who writes to the business's number. If the owner has written in that chat from the phone in the last 12 hours, the assistant stays silent: a person has taken over.
Meta's charges. The after-call message is a Meta-approved template sent from the business's own WhatsApp Business account, so Meta's per-message fee (if any) is billed to the business's own Meta account, not to Sesera or to your pool. The template is submitted to Meta automatically when the account is connected; messages go out only once Meta approves it (templateReady), which usually takes a few minutes.
GET /api/v1/businesses/{id}/whatsapp
{
"whatsapp": {
"account": {
"connected": true,
"id": "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
"number": "+905321112233",
"displayName": "Örnek Kuaför",
"coexistence": true,
"templateStatus": "APPROVED",
"templateReady": true,
"followUp": true,
"assistantReplies": true,
"answersNewChats": false,
"lastError": null,
"connectedAt": "2026-10-27T10:00:00.000Z",
"reconnectBy": "2026-12-26T10:00:00.000Z"
}
}
}account is null until the business is linked to a number in the panel.
| field | |
|---|---|
id | the number's account id; the same for every assistant linked to it (see accounts) |
coexistence | the number is also used in the WhatsApp Business app on the owner's phone |
templateStatus | Meta's status of the after-call template: PENDING, APPROVED, REJECTED, PAUSED, DISABLED, … (null until submitted) |
templateReady | after-call messages can be sent (APPROVED, or FLAGGED which Meta still delivers) |
followUp | after-call message on or off |
assistantReplies | the assistant answers customers who write back |
answersNewChats | this assistant is the one that answers customers who write to the number first |
lastError | the last problem with the connection or the template, as text for a person (Turkish), else null |
reconnectBy | when the Meta connection runs out; the owner reconnects in the panel before then |
On your own Sesera subscription the endpoint is read-only.
PATCH /api/v1/businesses/{id}/whatsapp
Send only the fields you change; an empty object changes nothing and returns the current state. Unknown fields are refused.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID/whatsapp \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"followUp":true,"assistantReplies":false}'| field | type | |
|---|---|---|
followUp | boolean | after-call message from the business's own number on or off. Needs the account |
assistantReplies | boolean | the assistant answers customers who write back. Needs the account |
accountId | string or null | link this business to one of your organization's numbers (an id from accounts); a business on another number moves here, its followUp and assistantReplies move with it. null unlinks it; the number and the other assistants stay. Account-wide keys only |
answersNewChats | boolean | true: this business answers customers who write to its number first, replacing the number's previous choice. false: nobody does, if this business was the one. Needs the account (it can be linked in the same request). Account-wide keys only |
Returns the same object as GET. A request is applied in full or not at all. Errors: 400 validation_error; 403 key_scope for accountId / answersNewChats with a business-scoped key; 404 whatsapp_account_not_found for an accountId that is not yours; 409 whatsapp_account_not_connected for followUp / assistantReplies / answersNewChats when the business is not linked to a number (and is not being linked in the same request).
POST /api/v1/businesses/{id}/whatsapp/account/refresh
Asks Meta again for the after-call template's status (and submits the template if it is missing). Meta also reports its decision by itself, so you only need this right after connecting, to see templateReady sooner. The account is shared, so this refreshes it for every assistant linked to the same number. Returns { "whatsapp": { "account": { … } } }, the same account object as GET. 409 whatsapp_account_not_connected when there is no account. At most 10 calls a minute.
GET /api/v1/whatsapp/accounts
Your organization's WhatsApp Business numbers, each with the businesses linked to it. Add a number in the developer panel; link a business to it with PATCH /api/v1/businesses/{id}/whatsapp and { "accountId": "…" }. Account-wide keys only (403 key_scope with a business-scoped key).
{
"accounts": [
{
"id": "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
"number": "+905321112233",
"displayName": "Örnek Kuaför",
"coexistence": true,
"templateStatus": "APPROVED",
"templateReady": true,
"lastError": null,
"connectedAt": "2026-10-27T10:00:00.000Z",
"reconnectBy": "2026-12-26T10:00:00.000Z",
"businessIds": ["…", "…"],
"newChatsBusinessId": null
}
]
}newChatsBusinessId is the business that answers customers who write to the number first, null if none.
Business-scoped keys may use every WhatsApp endpoint of their own business, except setting accountId / answersNewChats and listing accounts.
Speech to speech API (voice sessions)
Connect the business's assistant to anything that carries audio: a Telegram or WhatsApp call, your own mobile app, a call center, a kiosk. Your server opens a WebSocket, streams the caller's voice in as raw PCM and plays the assistant's voice that comes back. It is the same assistant as on the phone line: same voice, knowledge, booking tools, KVKK announcement, and the caller can interrupt it.
No phone number and no SIP setup is needed. Every Developers plan can use it, on the businesses you provisioned (on your own Sesera subscription it answers 403 read_only).
POST /api/v1/businesses/{id}/voice-sessions
Ask for one session from your server, then open its url within 90 seconds. The URL works once. Never send your API key to a browser or phone app: ask for the session on your server and hand only the url to the client if needed.
curl -X POST https://sesera.ai/api/v1/businesses/$ID/voice-sessions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"sampleRate":16000}'| field | type | |
|---|---|---|
sampleRate | integer | optional, 8000 to 48000, default 24000. Both directions use this rate |
201 Created:
{
"voiceSession": {
"url": "wss://rt.sesera.ai/web/?ticket=…&rate=16000",
"sampleRate": 16000,
"encoding": "pcm_s16le",
"channels": 1,
"expiresInSeconds": 90,
"maxMinutes": 5,
"announcement": "Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz."
}
}maxMinutes is the business's call length limit (maxCallMinutes; 0 means the business has no per-call limit); the assistant ends the session there at the latest, earlier if the business's minutes run out. announcement is the sentence the assistant reads first.
The WebSocket
Open the socket with WebSocket compression (permessage-deflate) turned off. PCM barely compresses, and with compression on the stream was seen to stall after a few seconds. In Node.js ws: new WebSocket(url, { perMessageDeflate: false }).
You send
- Binary frames: the caller's audio, 16 bit signed little endian PCM, mono, at
sampleRate. Send it as it is captured, in small chunks (20 to 100 ms). Keep sending silence too: the assistant uses it to hear that the caller has finished speaking. - Text
{"type":"hangup"}to end the session. - Text
{"type":"tool_result","id":"…","result":{…}}only as an answer to atoolevent (below).
You receive
- Binary frames: the assistant's voice, same format and rate. Play them in order as they arrive, with a small buffer (100 to 200 ms). Audio is sent in real time, so when the caller interrupts, the assistant simply stops sending.
{"type":"ready","rate":16000}once the assistant is connected.{"type":"say","who":"ai"|"me"|"canli","text":"…"}live captions: what the assistant is saying (ai), what it heard from the caller (me), and the caller's words while they are still speaking (canli, replaced by the next one). Informational only; do not depend on receiving them.{"type":"tool","id":"…","name":"…","arguments":{…}}rarely; answer with{"type":"tool_result","id":"…","result":{"ok":false}}if you do not handle it.{"type":"ended","reason":"agent"}when the assistant hangs up (goodbye, time limit, minutes used up). The socket then closes.{"type":"error","reason":"bad_ticket"}(URL expired or already used, close code 4401) or{"type":"error","reason":"agent_down"}(close code 4503): ask for a new session.
Minimal Node.js client (npm i ws):
import WebSocket from "ws";
const r = await fetch(`https://sesera.ai/api/v1/businesses/${ID}/voice-sessions`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ sampleRate: 16000 }),
});
const { voiceSession } = await r.json();
const ws = new WebSocket(voiceSession.url, { perMessageDeflate: false });
ws.binaryType = "nodebuffer";
ws.on("message", (data, isBinary) => {
if (isBinary) return speaker.write(data); // assistant's voice, PCM16 16 kHz
const ev = JSON.parse(data.toString());
if (ev.type === "ended" || ev.type === "error") ws.close();
});
microphone.on("data", (pcm) => ws.readyState === 1 && ws.send(pcm)); // caller, PCM16 16 kHzBilling and records. A voice session uses the business's minutes like a phone call and appears in GET /api/v1/calls and in the panel's call list. It is refused with 402 no_minutes when the business has no minutes left. How many run at the same time is limited by your plan's concurrent call limit, the same limit phone calls count against; above it the request is refused with 429 reseller_concurrency_limit; try again when a call ends.
| status | error | |
|---|---|---|
| 403 | read_only | the business is your own Sesera subscription, not one you provisioned |
| 402 | no_minutes | the business has no minutes left: give it minutes (POST …/minutes) |
| 409 | assistant_not_ready | the assistant has no prompt yet |
| 409 | calls_stopped | calls are stopped on this business |
| 429 | rate_limited | more than 30 sessions requested for one business in a minute |
| 429 | reseller_concurrency_limit | your plan's concurrent call limit is reached |
| 503 | not_accepting_calls · unavailable | your account is not taking calls, or the service is down |
Reading calls, bookings & messages
All three are scoped to your businesses. Query params: since (ISO timestamp), businessId (filter to one), limit. With a business-scoped key all three (and GET /api/v1/calls/{id}, …/recording) cover only that business.
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",
"partySize": null, "notes": null, "status": "confirmed", "source": "voice",
"callId": "…", "createdAt": "…"
} ] }source is voice (the assistant took it on a call), chat (the assistant took it in a written chat, e.g. WhatsApp), manual (added in the Sesera dashboard) or api (created with POST /api/v1/bookings). limit max 200 (also the default), newest-first by createdAt. Page back with before = the last row's createdAt and beforeId = its id (a beforeId that is not an id answers 400 bad_before_id).
partySize is how many people the booking is for; null when it was not asked (the business has no bookingMaxPartySize). A cancelled booking reads status: "cancelled"; a rescheduled one keeps its id and shows the new startsAt / endsAt.
Bookings: capacity, party size, cancel
- Several bookings in one slot. With
bookingSlotCapacity: 3up to three bookings may overlap; the fourth caller is offered the nearest free times. A slot that already holds bookings counts the most bookings running at the same moment, so a 10:00–10:30 and a 10:30–11:00 booking leave room for a 10:00–11:00 one at capacity 2. - People limit. With
bookingMaxPartySize: 40the assistant asks how many people, and a slot is free while the people already booked there plus the new party stay within 40. A party larger than the limit is not booked; the assistant takes a message for the business instead. There is no waiting list. - Google Calendar. Your business's own Sesera bookings mirrored into its connected calendar are not counted twice; any other event in that calendar closes the slot completely.
- Cancel and reschedule by phone. A caller can cancel or move a booking that was made with the number they are calling from. The assistant reads the booking's day and time back and needs a clear "yes" before it changes anything. A caller with a hidden number is asked to call again from their own number or leave a message. A rescheduled booking keeps its party size and follows the same capacity rules. The business owner is notified, the mirrored Google Calendar event is removed or moved, and you receive
booking.cancelled/booking.rescheduled(see Webhooks).
Closed days
closedDays on the business (PATCH /api/v1/businesses/{id}) marks whole days as closed without touching the weekly hours: holidays, a bayram, annual leave. On those days:
- the assistant tells callers the business is closed. It knows the closed days of the next 60 days; past entries stay stored (and are returned by
GET) but are ignored; - no booking is made by any path: the assistant does not offer or book those days, a reschedule cannot move a booking onto them, and
POST /api/v1/bookingsanswers422 closed_day. A booking that runs past midnight into a closed day is refused too.
Existing bookings on a day you later close are not cancelled; cancel them yourself if needed.
POST /api/v1/bookings — create a booking
Puts a booking into a business's calendar from your system, with the same rules the assistant follows on the phone.
curl -X POST https://sesera.ai/api/v1/bookings \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "businessId": "…", "startsAt": "2026-11-14T19:30:00+03:00",
"customerName": "Ahmet Yılmaz", "customerPhone": "05321234567",
"partySize": 4, "serviceName": "Akşam yemeği" }'| field | type | notes |
|---|---|---|
businessId | uuid | required with an account key; a business-scoped key may omit it (its own business) |
startsAt | string | ISO 8601 with offset, must be in the future |
durationMinutes | int | 5–1440. Default: the matching service's duration when serviceName matches one of the business's services, otherwise bookingDefaultDurationMinutes |
serviceName | string | optional, max 120 |
customerName | string | required, max 120 |
customerPhone | string | optional, a Turkish number (05…, +905…) |
partySize | int | number of people, 1–2000. Required when the business has bookingMaxPartySize |
notes | string | optional, max 1000 |
Checked, in the same order and under the same lock as a phone booking: opening hours, the booking blocks set in the dashboard, closedDays, bookingSlotCapacity and bookingMaxPartySize (see capacity). Not applied: the assistant's own bookingLeadMinutes and bookingMaxDaysAhead, which only limit what the assistant offers callers.
201 Created → { "booking": { … } }, the same object as GET /api/v1/bookings with source: "api". The owner is notified, the booking is mirrored to the business's connected Google Calendar and you receive booking.created.
Errors: 400 validation_error · 404 not_found (business not yours) · 403 read_only (your own subscription) · 409 slot_full / 409 people_full · 422 in_past · 422 closed_day · 422 outside_hours · 422 blocked (a time the business takes no bookings) · 422 party_too_large (more people than bookingMaxPartySize, no time can hold them) · 422 party_size_required · 422 bookings_disabled (the business has bookingsEnabled: false).
POST /api/v1/bookings/{id}/cancel
No body. 200 OK → { "booking": { …, "status": "cancelled" } }. The mirrored Google Calendar event is removed, the owner is notified and you receive booking.cancelled with cancelledBy: "api". Cancelling a booking that is already cancelled answers 200 with the booking and sends no new webhook. A booking that has already started answers 422 in_past. A booking id that is not one of your businesses' is 404 not_found, one of your own Sesera subscription is 403 read_only. Cancelling works even when the business has bookingsEnabled: false.
POST /api/v1/bookings/{id}/reschedule
{ "startsAt": "2026-11-15T20:00:00+03:00", "durationMinutes": 90 }durationMinutes is optional and defaults to the booking's current length. The new time goes through the same checks as a new booking; the booking itself does not count against its new slot, and its partySize is kept. 200 OK → { "booking": { … } } with the new startsAt / endsAt (same id). The mirrored calendar event is moved, the owner is notified and you receive booking.rescheduled with rescheduledBy: "api". A cancelled booking answers 409 cancelled; one that has already started answers 422 in_past. A business with bookingsEnabled: false answers 422 bookings_disabled. A new time that breaks the rules answers the same errors as create; 404 not_found and 403 read_only as for cancel.
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 (also the default), newest-first by createdAt. Page back with before = the last row's createdAt and beforeId = its id.
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.",
"metadata": null // outbound calls: the metadata you sent, e.g. { "campaignId": "eylul-kampanya" }
} ] }limitmax 100, default 25 (lower than the other lists — each row carries a whole conversation). Rows are newest-first bystartedAt. To page back, pass the last row'sstartedAtasbeforeand itsidasbeforeId; the next page starts right after that row, so nothing is skipped even when several calls share a timestamp.sinceis the lower bound and works together with them.transcript=0returns metadata only (cheaper for frequent polling); the transcript fields come backnullwithtranscriptStatus: "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).metadatais the object you passed to [POST /api/v1/businesses/{id}/calls](#post-apiv1businessesidcalls--ring-a-number) for an outbound call, returned exactly as stored;nullfor inbound calls.
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 treat404 recording_not_foundas "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)
Set your webhook URL in the developer panel under Webhook (or ask Sesera to set it). Every new booking/message, every booking cancelled or moved and the end of every call are POSTed to it as they happen. The signing secret is created when you first save a URL and is shown on the same page, where you can also renew it. 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:
{
"id": "evt_2b1f…", // the event's id: same on every target and on every retry
"type": "booking.created", // or "booking.cancelled", "booking.rescheduled", "message.created", or a call.* event below
"businessId": "…",
"createdAt": "2026-07-03T…",
"data": { … same fields as the read API rows … }
}id lets you drop an event you already processed. A call's call.ended id is derived from the call, so the end of one call always has the same id.
Signature — header X-Sesera-Signature: t=<unixSeconds>,v0=<hex> where hex = HMAC_SHA256(secret, "<t>.<rawRequestBody>"). Verify it with the signing secret from the panel (Webhook), and reject timestamps older than ~5 minutes. The URL must be a public https address; local and private addresses (localhost, 10.x, 192.168.x) are refused, and names that resolve to private networks or to Sesera's own servers are rejected, so test with a public endpoint.
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, and GET /api/v1/webhook-deliveries to see what was sent and what your endpoint answered.
Call ended — every call, on every business, same signature, same targets. Sent right after the call is saved:
| field | |
|---|---|
callId | the call; read it with GET /api/v1/calls/{id} |
conversationId | the session id (the one call.started carried, when live transcript is on) |
direction | inbound or outbound |
channel | phone or web (website call button) |
fromPhone | the caller, E.164; null when the number was hidden (and on web calls). On an outbound call the person called is toPhone |
toPhone | the number that was called; null on web calls |
startedAt, endedAt, durationSeconds | when and how long |
outcome | booked (a booking was made), transfer (handed to one of your transfer lines and the line answered), message (the caller left a message), transfer_failed (a transfer was tried but the line was busy, did not answer or could not be dialled; if the caller went back to the assistant, that part is a separate call with its own call.ended), info (talked, left nothing), missed (hung up without speaking). Strongest first: a call that booked and left a message is booked. When a transfer was tried, call.ended waits for the line's result (at most about a minute) before it is sent |
summary | one Turkish sentence, e.g. "Randevu alındı: 3 Kasım Salı 14:00, Saç kesimi.". On a call without a KVKK disclosure record it never quotes the caller |
bookingIds, messageIds | the bookings and messages this call created (the same ids booking.created / message.created carried) |
metadata | outbound calls: the metadata you sent; otherwise null |
transcriptStatus, transcript | not_requested and null, unless the business has liveTranscript: true (then as below) |
{
"id": "evt_call_ended_1dddffb1288243bea702ac85001efebd",
"type": "call.ended",
"businessId": "…",
"createdAt": "2026-11-02T10:01:15.120Z",
"data": {
"callId": "1dddffb1-2882-43be-a702-ac85001efebd",
"conversationId": "…",
"direction": "inbound",
"channel": "phone",
"fromPhone": "+905321112233",
"toPhone": "+908501234567",
"startedAt": "2026-11-02T10:00:00.000Z",
"endedAt": "2026-11-02T10:01:14.000Z",
"durationSeconds": 74,
"outcome": "booked",
"summary": "Randevu alındı: 3 Kasım Salı 14:00, Saç kesimi.",
"bookingIds": ["…"],
"messageIds": [],
"metadata": null,
"transcriptStatus": "not_requested",
"transcript": null
}
}A missed-lead note that Sesera adds after the call (message.created with source: "missed_lead") may arrive after call.ended and is not counted in outcome or messageIds.
Booking events — same signature, same targets:
type | data |
|---|---|
booking.created | id, startsAt, endsAt, customerName, customerPhone, serviceName, partySize (null when not asked), status, source (voice, chat, manual or api) |
booking.cancelled | the same fields with status: "cancelled", plus cancelledBy: "caller" (the caller cancelled it on the phone) or "api" (POST /api/v1/bookings/{id}/cancel) |
booking.rescheduled | the same fields with the new startsAt / endsAt, plus previousStartsAt, previousEndsAt and rescheduledBy ("caller" or "api") |
Live call events — only for a business with liveTranscript: true (PATCH /api/v1/businesses/{id}), same signature, same targets. On such a business call.ended is sent once, after the last call.transcript, with all the fields above plus the final transcript:
type | data |
|---|---|
call.started | conversationId, direction (inbound/outbound), fromPhone, toPhone, startedAt |
call.transcript | conversationId, seq (grows; drop anything older than what you have), turn (the line that just changed, with its index), transcript (every line so far) |
call.ended | everything in Call ended above; transcriptStatus is available, withheld_kvkk or empty, and transcript is null unless available |
Lines are withheld until the KVKK announcement has been played, so the first call.transcript can come a few seconds after call.started.
Account events — delivered to your account webhook (and to a business's own webhook, if it has one), same signature:
type | data |
|---|---|
business.minutes_low | businessId, remainingMinutes (what is left, 2 decimals), peakMinutes (the business's highest balance so far), threshold (2 % of peakMinutes; the alert fired because remainingMinutes ≤ threshold) |
Example:
{
"id": "evt_5c0e…",
"type": "business.minutes_low",
"businessId": "3ba05a2a-…",
"createdAt": "2026-10-09T19:49:26.738Z",
"data": {
"businessId": "3ba05a2a-…",
"remainingMinutes": 2,
"peakMinutes": 100,
"threshold": 2
}
}GET /api/v1/webhook-deliveries — what was sent
One row per event per target, newest first, for the last 30 days: your account webhook, and a business's own webhook when it has one. The event body is not stored, so it is not returned; live transcript lines (call.transcript) are not listed. Account-wide keys only (403 key_scope for a business-scoped key).
curl "https://sesera.ai/api/v1/webhook-deliveries?status=failed&limit=20" \
-H "Authorization: Bearer $KEY"{ "deliveries": [ {
"id": "…", "eventId": "evt_call_ended_…", "type": "call.ended",
"businessId": "…", "businessName": "Yılmaz Kuaför",
"target": "reseller", // reseller = your account webhook · business = the business's own webhook
"url": "https://api.example.com/sesera/webhook",
"status": "failed", // delivered | failed | refused | pending
"httpStatus": 500, "attempts": 3, "durationMs": 3412,
"error": "alıcı HTTP 500 döndü",
"createdAt": "2026-11-02T10:01:15.120Z"
} ] }- Filters:
businessId,type(e.g.call.ended),status,since.limitmax 200, default 50. Page back withbefore= the last row'screatedAtandbeforeId= itsid. A bad filter answers400 validation_error(400 bad_before_idforbeforeId). erroris a short Turkish note of what went wrong (no answer in time, the HTTP status your endpoint returned, a refused address);nullwhen delivered.refusedmeans Sesera did not send it: the URL no longer resolves to a public address, or the target has no signing secret.pendingis a delivery still being retried.- The same list is in the developer panel under Webhook → Gönderilen olaylar.
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 the whole transcript, not just the turn: use data.transcript, not only 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
2xxwithin 3 seconds. call.endedholds the final transcript and thecallIdforGET /api/v1/calls/{callId}and…/recording. It is retried like booking events;call.transcriptis not (the next one supersedes it).- KVKK: nothing is sent before the assistant has read the disclosure. A call without a disclosure sends
call.startedand acall.endedwith"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/businesseswith"number": "none"— create an agent without a number. This works on a new account (number cap 0) and the agent answers on your website call button and the speech to speech API right away. A number is bought separately (one time 1.000 ₺ per number, developer panel → Telefon numaraları). Once you have a free slot, create with"number": "auto"(the default) to get a livephoneNumberback, orawaiting_number(no error) when the pool is empty.- To give a numberless or
awaiting_numberagent a number:POST /api/v1/businesses/{id}/assign-number(retry after Sesera restocks if it returns409 no_numbers). POST /api/v1/businesses/{id}/minutes— allocate minutes so it can answer.- Receive
booking.created/message.createdandcall.endedwebhooks (or pollGET /api/v1/bookings+GET /api/v1/messages).call.endedarrives for every call with the caller, duration, outcome and a short summary; the conversation itself is atGET /api/v1/calls/{id}(or streamed withliveTranscript). The audio of any call is atGET /api/v1/calls/{id}/recording. PATCHto edit,DELETEto remove a business (its number stays on your account, see Concepts).
Rate limit: ~120 requests/minute per reseller account (all your account-wide keys share it; business-scoped keys have their own).
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 developer panel (Telefon numaraları, "SIP ile numara ekle") 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 public SIP host or IP. Private, local and Sesera addresses, and names that resolve to them, are refused with bad_host (checked again on connect) | ||
port | int | default 5060 | ||
transport | udp\ | tcp | default udp | |
username / password | string | required in register mode. The password is write-only and never returned; it may not contain a line break or ; | ||
allowedIps | string[] | required in ip mode; IPs or CIDRs. An address already on another business's ip trunk is refused with 409 ip_taken | ||
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. Ignored in relay mode: that trunk connects once its number is verified |
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 (within about a minute, see the limits below). { "enabled": false } disconnects it, { "enabled": true } connects it again. outboundConsent can be set or cleared.
DELETE …/sip-trunks/{trunkId} — disconnect and remove. The trunk is removed from the business even when the switch cannot be reached; the answer is then 502 media_host_failed and a retry answers 404 (nothing left to do on your side).
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" and a verification object. The trunk stays in status: "draft" until the number is verified.
- Verify the number. Within 15 minutes, call
verification.callTo(Sesera's demo line, 0850 242 79 46) from the number itself (verification.callFrom). When the call comes in, the number is verified and the trunk connects on its own (status: "active"); you can hang up right away. A forwarded call or a hidden caller ID does not count.
No outgoing minutes on the number? Do step 3 first, then within the same 15 minutes call the number itself from any phone. The call can only reach Sesera through the relay if the number's carrier panel routes it to us, which only the owner can set, so it verifies the number the same way. That call is answered by the assistant.
"verification": {
"status": "pending",
"callFrom": "+902243220410",
"callTo": "+908502427946",
"expiresAt": "2026-10-09T17:15:00.000Z",
"verifiedAt": null
} status is pending, verified or expired; poll GET …/sip-trunks/{trunkId} to watch it change. Missed the window? POST …/sip-trunks/{trunkId}/verify opens a new 15-minute one. Until the number is verified, {"enabled": true} returns 409 not_verified. This is what keeps anyone else from pointing your number at their business.
- In your carrier panel, route incoming calls for that number to
relayAddresswith 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). A number your account has already verified keeps its verification: the new trunk is created verified and connects without a new verification. Changing did, or switching a trunk to relay, needs a new verification unless your account verified that number before; the trunk goes back to draft until then. 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. Reloads are batched across the server, at most one per minute, so a write can take up to about 60 seconds to answer: use a client timeout of at least 90 seconds and do not retry early. 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 |
| 400 | not_relay | POST …/verify on a trunk that is not in relay mode |
| 404 | not_found | trunk not on this business |
| 409 | too_many_trunks / did_taken | 5 trunks already, or that number is on another trunk |
| 409 | ip_taken | an address in allowedIps is already on another business's ip trunk |
| 409 | not_verified | relay trunk whose number is not verified yet, see verification |
| 409 | already_verified | POST …/verify on a trunk whose number is already verified |
| 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 |
Keypad menu (DTMF) — "press 2 to reach the salon"
Let callers use the phone keypad. A key you define does exactly what you set, with no AI decision in between (put the caller through, read a sentence, repeat the menu, hang up), or hands a request to the assistant. Keys you did not define still reach the assistant as text, so a caller can type a customer or order number. No server of your own is needed. Works on every call the business answers; only the transfer action needs call transfer (above), and therefore the business's own trunk.
curl -X PUT https://sesera.ai/api/v1/businesses/$BIZ/keypad \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"enabled": true,
"menuPrompt": "İşletmeye bağlanmak için 2'\''ye, randevu için 1'\''e basabilirsiniz.",
"promptAt": "after_greeting",
"keys": {
"1": { "action": "assistant", "instruction": "Arayan randevu almak istiyor." },
"2": { "action": "transfer", "target": "Salon" },
"9": { "action": "say", "text": "Adresimiz Mudanya sahil yolu no 12." },
"*": { "action": "repeat_menu" },
"0": { "action": "hangup", "text": "Aradığınız için teşekkürler, iyi günler." },
"5": { "action": "tool", "tool": "siparis_durumu" }
},
"otherKeys": "assistant"
}'| field | ||
|---|---|---|
keys | object, up to 12 keys | key 0-9, * or # → an action (table below) |
menuPrompt | 3-200 characters | the menu sentence. Required with promptAt: "after_greeting", optional with "on_request". No web addresses |
promptAt | after_greeting (default) / on_request | when the menu sentence is read, see below |
otherKeys | assistant (default) / ignore | what happens to keys that are not in keys |
enabled | default true | false keeps the menu but switches it off |
action | extra field | what happens | AI involved |
|---|---|---|---|
transfer | target (required): the name of one of the business's call-transfer lines | the call is put through to that line | no |
assistant | instruction (required, up to 300 characters) | the assistant takes instruction as the caller's request and answers it | yes |
say | text (required, up to 200 characters) | text is read in the assistant's voice, then the assistant listens again | no |
repeat_menu | menuPrompt is read again (needs a menuPrompt) | no | |
hangup | text (optional, up to 200 characters) | text is read if given, then the call ends | no |
tool | tool (required): the name of one of the business's custom tools | your tool is called, the assistant tells the caller the result | yes |
Texts are read out on the call, so no web addresses; control characters are removed and whitespace is collapsed.
When the menu sentence is spoken
promptAt: "after_greeting": the caller hears the mandatory announcement, then the greeting, thenmenuPrompt, as one opening (it is cached with the greeting, so it adds no delay).promptAt: "on_request": nothing is read up front. The assistant says the menu when the caller asks for it, or when it clearly helps.- In both cases the assistant knows the menu: a caller can say "iki" or "salona bağla" instead of pressing, or ask "hangi tuş neydi?", and gets the same result.
Keys that are not in the keys list
With otherKeys: "assistant" they are collected until the caller pauses for 2 seconds or presses # (up to 20 digits) and sent to the assistant as *"Arayan tuşlarla şunu girdi: 12345"*: a customer number, an order number. With "ignore" they do nothing. A menu with no keys at all is valid with "assistant" (digit entry only).
How it behaves on a call
- The mandatory announcement is never cut. A key pressed during it is handled right after it ends.
- A key pressed while the greeting, the menu sentence or any assistant reply is playing interrupts it instantly.
- Every press appears in the conversation as *"(2 tuşuna basıldı)"*: in
GET /api/v1/calls/{id}, in the panel, and in the live transcript events (call.transcript,call.ended) when live transcript is on. - Presses arrive as telephone events (RFC 4733), separate from the caller's voice, so a press is not mistaken for words.
- A key in
keysacts the moment it is pressed. The exception is a caller who is already typing other digits: then it counts as one more digit of that number (a customer number containing a 2 does not trigger the transfer key).
Transfer keys
Call transfer must be set up first (PUT /api/v1/businesses/{id}/call-transfer) and target must be one of its line names (case does not matter); otherwise the menu is refused with 409 transfer_target_unknown. If a transfer cannot run on a particular call (the call came in on a Sesera number, the trunk cannot place calls, the line was removed later), the key falls back to the assistant, which says it cannot connect right now and offers to take a message.
Tool keys
tool must name one of the business's custom tools (declared with tools on PATCH /api/v1/businesses/{id}), otherwise 409 tool_unknown. Your endpoint gets the same request as any custom tool call, with the pressed key as the only argument:
{ "name": "siparis_durumu", "arguments": { "key": "5" }, "conversation_id": "7c1e…" }The usual Authorization: Bearer <webhook signing secret> header, responseTimeoutSecs and responsePath apply. Parameters you declared on the tool are not filled for a key press; make them optional or read key. A disabled tool is skipped on calls.
Reading and removing it
GET returns the stored menu plus keyStatus: for every transfer and tool key, whether it would run right now.
{ "keypad": {
"enabled": true,
"menuPrompt": "İşletmeye bağlanmak için 2'ye, randevu için 1'e basabilirsiniz.",
"promptAt": "after_greeting",
"keys": { "1": { "action": "assistant", "instruction": "Arayan randevu almak istiyor." },
"2": { "action": "transfer", "target": "Salon" } },
"otherKeys": "assistant",
"keyStatus": { "2": { "ready": true } }
} }keyStatus reasons when ready is false: transfer_not_configured, transfer_target_unknown (the line was removed after the menu was saved), transfer_disabled, transfer_trunk_required, transfer_trunk_cannot_dial, tool_unknown, tool_disabled. Such a key still answers on a call, as described above. PUT replaces the whole menu and returns the same object. DELETE removes the menu (enabled: false, no keys). Changes apply from the next call.
| HTTP | code | meaning |
|---|---|---|
| 400 | bad_enabled / bad_prompt_at / bad_other_keys / bad_menu_prompt / bad_keys / too_many_keys / bad_key / bad_action / bad_target / bad_instruction / bad_text / bad_tool | invalid field, see message |
| 400 | menu_prompt_required | promptAt is after_greeting (or a key uses repeat_menu) but there is no menuPrompt |
| 400 | keys_required | no keys and otherKeys: "ignore": the menu would do nothing |
| 409 | transfer_target_unknown | call transfer is not set up, or target is not one of its lines |
| 409 | tool_unknown | tool is not one of the business's custom tools |
| 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 {{ad}}, ben {{business_name}} asistanıyım. Kısa bir bilgi vermek için aradım.",
"variables": { "ad": "Ayşe Hanı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 for this call. {{business_name}} is substituted. Omit to use the business's outbound greeting; if that is not set either, the normal (inbound) greeting is used, which is usually wrong on an outbound call |
variables | object | values for the {{name}} placeholders in firstMessage, the outbound greeting and extraInstructions, e.g. { "ad": "Ayşe Hanım", "saat": "15:00" }. Up to 20; a name is letters, digits and _, starts with a letter or _, at most 40 characters (business_name is reserved); values up to 200 chars. A placeholder without a value refuses the call with missing_variables |
extraInstructions | string | why this call exists. Added after the stored knowledge as the call's purpose; 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": "…",
"metadata": { "campaignId": "eylul-kampanya", "leadId": "42" } } ] }status: queued → dialing → completed / no_answer / failed. callId points at the GET /api/v1/calls/{callId} record with the transcript. metadata is what you sent on POST; the same object comes back on GET /api/v1/calls and in the call's call.ended webhook.
Errors specific to outbound
| HTTP | code | meaning |
|---|---|---|
| 400 | missing_variables | a {{name}} in the opener or instructions has no value in variables; message lists the names |
| 400 | first_message_too_long | the opener is longer than 400 characters once the variables are filled in |
| 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 |
| 429 | reseller_concurrency_limit | your plan's concurrent call limit is reached; an unanswered call frees its line when ringing ends |
| 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.