Sesera Reseller API (v1)

Provision and manage AI phone-receptionist agents on Sesera's infrastructure programmatically. Each reseller (partner) authenticates with an API key, creates voice agents ("businesses"), gets a phone number for each, funds them with minutes from a prepaid pool, and reads back what the agents did — bookings, messages and the calls themselves (with transcripts and audio recordings) — via webhook or a read API.

  • Base URL: https://sesera.ai
  • Auth: Authorization: Bearer <API_KEY> on every request
  • Content type: application/json
  • All endpoints are scoped to the authenticating reseller — a key can only ever

see and touch its own businesses.


Concepts

  • Business = one AI voice agent + one phone number. You configure its

knowledge (hours, services, FAQ, free text) and voice; Sesera compiles a Turkish system prompt and provisions a live agent.

  • Number pool — numbers are drawn from Sesera's shared pool. Your API key

has a number cap (maxNumbers); creating past it returns 403 quota_reached. Creating up to your cap never fails on stock: if the pool is empty, the business is created and held in your stock with status **awaiting_number (no error). Its number is bound later via POST /api/v1/businesses/{id}/assign-number**, which is the only call that can return 409 no_numbers. Retry that call after Sesera restocks.

  • Minute pool — your account holds a prepaid minute balance (funded via

Sesera). You allocate minutes from the pool to a business; a business can only take calls while it has allocated minutes left.

  • Source of truth — bookings and messages captured by the agent live in

Sesera and are delivered to you by webhook (real-time) and/or the read API (polling). The calls behind them — including the transcript and the audio recording — are read-only over the API (GET /api/v1/calls, GET /api/v1/calls/{id}/recording); there is no call webhook.


Errors

All errors return { "error": "<code>", "message"?: "<human hint>" }.

HTTPcodemeaning
400bad_jsonbody was not valid JSON
400validation_errorinvalid fields (see details[])
400invalid_voicevoiceId not in GET /voices
401unauthorizedmissing / invalid / revoked key
402insufficient_poolnot enough minutes in your pool to allocate
403reseller_suspendedyour account is suspended
403withheld_kvkkcall recording requested for a call with no KVKK disclosure record (see GET /api/v1/calls/{id}/recording)
403feature_not_enabledthe field is only enabled for specific accounts (currently announcementName, see Announcement name, and callRecording while it rolls out, see Call recording)
403quota_reachednumber cap reached — delete one, or buy capacity in the dashboard (Sesera Dev → API → Bayilik, one-time 1.000 ₺ per number)
404not_foundbusiness / call does not exist (or isn't yours)
404recording_not_foundthe call is yours but has no audio recording (yet)
409no_numberspool empty at assign-number time — retry after Sesera restocks (create never returns this)
429rate_limitedslow down (see Retry-After)

Account

GET /api/v1/me

Your account status, number quota, and minute pool.

curl https://sesera.ai/api/v1/me -H "Authorization: Bearer $KEY"
{
  "reseller": {
    "id": "…", "name": "Akdeniz Telekom", "status": "active",
    "numbers": { "used": 3, "max": 10 },
    "minutes": { "balance": 250, "max": 0 }
  }
}

(max: 0 = no ceiling.)

GET /api/v1/voices

The voices you may set as voiceId. Each voice includes a **preview_url** — a public MP3 sample (the same clip our own wizard plays) you can drop straight into an <audio> tag so your customers can hear a voice before picking it. The URL is public and cacheable; it needs no Authorization header.

{ "voices": [ { "id": "IOx9E82IJLWAeUWBCdDz", "name": "Yağmur", "description": "Sıcak, samimi sohbet sesi", "gender": "female", "preview_url": "https://sesera.ai/_voicesamples/IOx9E82IJLWAeUWBCdDz.mp3" }, … ] }

Businesses

POST /api/v1/businesses — create an agent (+ number when in stock)

If a number is in stock it's assigned immediately and the business is live. If the pool is empty, the business is still created (never fails on stock) and held with status: "awaiting_number" and phoneNumber: null — then call [assign-number](#post-apiv1businessesidassign-number--bind-a-number-from-stock) once Sesera restocks. Either way, this counts against your number cap.

Body (only name is required; everything else has sensible defaults):

fieldtypenotes
namestringrequired, 2–120 chars
categorystringe.g. "kuafor", "klinik", "oto" (drives safety guardrails)
hoursobjecte.g. { "mon": { "closed": false, "intervals": [["09:00","18:00"]] }, … }
servicesarray[{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }]
faqarray[{ "q": "Otopark var mı?", "a": "Evet, ücretsiz." }]
freeTextstringfree-form extra knowledge
extraInstructionsstringtone/behaviour instructions
greetingstring \nullthe assistant's opening line — 30–250 chars and must contain the {businessName} placeholder. null = back to the built-in greeting; omit the field to keep the current one. See Greeting
announcementNamestring \nullenabled 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
announcementPrefixstring \nullyour 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
callRecordingbooleanrolling out. false (default) = the call audio is never stored, only the transcript; the caller hears *"Görüşme yazıya dökülür."* true = the audio is recorded and the caller hears *"Bu görüşme kayıt altına alınmaktadır."* Omit to keep the current value. See Call recording
voiceIdstringfrom GET /voices (default applied if omitted)
voiceSpeednumber0.5–1.5
number"auto" \"none""auto" (default) takes a pool number and counts against your quota; "none" takes no number — the business will join one of your shared lines
bookingsEnabledbooleanturn on appointment booking tools
bookingLeadMinutesintmin advance notice (default 60)
bookingMaxDaysAheadintmax days ahead (default 30)
enabledLanguagesstring[]extra ISO-639-1 codes the agent may switch to
maxCallMinutesintper-call cap; 0 = unlimited
toolsarraycustom tools the agent can call against your API — see Custom tools

Strings that flow into the prompt may not contain control chars or {{.

curl -X POST https://sesera.ai/api/v1/businesses \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Yılmaz Kuaför",
    "category": "kuafor",
    "hours": { "mon": { "closed": false, "intervals": [["09:00","19:00"]] } },
    "services": [{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }],
    "bookingsEnabled": true
  }'

201 Created:

{
  "business": {
    "id": "…", "name": "Yılmaz Kuaför", "category": "kuafor",
    "status": "live", "phoneNumber": "+90850…", "bookingsEnabled": true,
    "tools": [],
    "voiceId": "IOx9E82IJLWAeUWBCdDz",
    "minutes": { "allocated": 0, "used": 0, "remaining": 0 },
    "createdAt": "2026-07-03T…", "updatedAt": "2026-07-03T…"
  }
}
The number is live immediately, but the agent won't take calls until you allocate minutes (see below). status: live (routing) · awaiting_number (created, held in your stock waiting for a DID — call assign-number) · provisioning · suspended. When status is awaiting_number, phoneNumber is null.

POST /api/v1/businesses/{id}/assign-number — bind a number from stock

Binds a pooled DID to a business that is awaiting_number (or whose number was previously reclaimed) and takes it live. This is the only endpoint that can return 409 no_numbers — call it, and if the pool is empty, retry after Sesera restocks. Idempotent: a business that already has a number returns it unchanged. No body.

curl -X POST https://sesera.ai/api/v1/businesses/$ID/assign-number \
  -H "Authorization: Bearer $KEY"

200 OK → { "business": { …, "status": "live", "phoneNumber": "+90850…" } } · 409 no_numbers (pool empty — retry after restock) · 403 quota_reached.

GET /api/v1/businesses — list yours

{ "businesses": [ { …business… }, … ] }

GET /api/v1/businesses/{id} — fetch one

PATCH /api/v1/businesses/{id} — edit

Send only the fields you want to change (same fields as create); the agent is recompiled and re-synced. Omitted fields are preserved.

curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "freeText": "Pazar günleri kapalıyız." }'

DELETE /api/v1/businesses/{id} — remove

Releases the number back to the pool and frees a slot in your number quota.

{ "deleted": true, "id": "…" }

Shared line — one number, many businesses

Buying a number for every business is one way to go live. The other is a shared line: one of your numbers that all your customers forward their missed calls into. A forwarded call carries the *forwarding* phone number in its SIP Diversion header; Sesera matches that number to the business you registered it for and answers as that business. Nothing about Sesera is said on the call.

  • The line stays assigned to the business you made it from. That business is

the default assistant: it answers when someone dials the line directly, when the caller hides their number, or when a forwarded call comes from a number you have not registered yet.

  • A business joined to a shared line does not consume a number from your

quota and goes live as soon as its first forwarding number is registered. Create it with "number": "none" on POST /api/v1/businesses so no pool number is taken (status provisioning until it is joined).

  • Capacity: 10 businesses per line (a number carries 10 concurrent calls).

Open a second shared line for more.

  • Your customer sets conditional forwarding (no answer / busy / unreachable)

on their own phone to the shared line's number.

POST /api/v1/businesses/{id}/shared-line — make its number a shared line

No body. The business must already hold a number (assign-number). Idempotent.

{ "sharedLine": { "phoneNumber": "+90850…", "fallbackBusinessId": "…" } }

Errors: 409 no_number (assign a number first), 409 not_line_owner.

DELETE /api/v1/businesses/{id}/shared-line — back to a private number

Refused with 409 line_in_use while other businesses still forward into it.

POST /api/v1/businesses/{id}/forwarding-sources — join a business to the line

{ "gsm": "05xx xxx xx xx", "sharedLine": "+90850…" }

gsm is the phone number the customer will forward from (their own line). sharedLine is only needed when you own more than one shared line. Response: { "forwardingSource": { "gsm": "+90…", "sharedLine": "+90850…", "businessId": "…" } }.

Errors: 404 no_shared_line, 400 ambiguous_line (pass sharedLine), 409 gsm_taken (that number already forwards into a live business), 409 no_capacity (10 businesses on this line), 400 bad_gsm.

DELETE /api/v1/businesses/{id}/forwarding-sources

{ "gsm": "05xx xxx xx xx" }

A business left with no number and no forwarding source returns to awaiting_number.

GET /api/v1/businesses/{id}/forwarding-sources

{ "forwardingSources": [ { "gsm", "sharedLine", "callCount", "lastCallAt", "createdAt" } ] }

GET /api/v1/shared-lines — everything at a glance

{
  "sharedLines": [
    {
      "phoneNumber": "+90850…",
      "fallbackBusinessId": "…",
      "fallbackBusinessName": "OMM Dijital",
      "maxBusinesses": 10,
      "sources": [ { "businessId", "businessName", "gsm", "callCount", "lastCallAt" } ],
      "unmatched": [ { "divertedFrom": "+90…", "from": "+90…", "at": "…" } ]
    }
  ]
}

unmatched lists the numbers that forwarded into the line in the last 30 days without a registration (the default assistant answered them) — register each one to the right business with forwarding-sources and it routes correctly from the next call.

Business objects (GET /api/v1/businesses) now also carry sharedLine (the line's number, or null) and forwardingNumbers (the registered GSMs).

The same controls live in the dashboard under Sesera Dev → API → Bayilik → Ortak hat.

Minutes

POST /api/v1/businesses/{id}/minutes — allocate from your pool

Moves minutes from your prepaid pool to this business. The pool is debited immediately; the business can now take calls until it runs out.

curl -X POST https://sesera.ai/api/v1/businesses/$ID/minutes \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "minutes": 50 }'
{
  "business": { …business with minutes.allocated updated… },
  "pool": { "balance": 200 }
}

402 insufficient_pool if your pool doesn't have that many minutes.

Topping up your pool. Fund the pool yourself from the Sesera dashboard: Ayarlar → Geliştirici → Dakika satın al (any Sesera account can switch the partner features on itself: Sesera Dev → API → Bayiliği etkinleştir). Minutes are sold in 100-minute steps at a volume-tiered rate (the more you buy, the lower the per-minute price); payment is by card via PayTR and the pool is credited automatically. Sesera can also top you up manually.


Greeting

Change the first sentence the assistant says, per business:

curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"greeting":"Merhaba, {businessName} randevu hattına hoş geldiniz. Size nasıl yardımcı olabilirim?"}'
  • {businessName} is replaced with the business name at call time, which is why

it is required. Don't hard-code the name: if the business is renamed, a hard-coded greeting freezes on the old one.

  • 30–250 characters. null restores 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.

  • greeting in a GET response is the text you set, or null when one of the

built-in greetings is in use.


Call recording

Rolling out. Until it is enabled on your account the field is absent from responses and writing it returns 403 feature_not_enabled.

Every call opens with a very short announcement, played before the greeting. Which one depends on callRecording:

callRecordingthe caller hearsstored
false (default)*"Görüşme yazıya dökülür."*transcript only, no audio
true*"Bu görüşme kayıt altına alınmaktadır."*transcript + audio
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"callRecording":true}'
  • Takes effect from the next call. Turning it on does not bring back audio for

calls taken while it was off: that audio was never stored.

  • With it off, GET /api/v1/calls/{id}/recording answers 404 for new calls

(recordingStatus: "none"); the transcript is available as usual.

  • Also accepted on POST /api/v1/businesses. The business owner can change the

same setting in the panel (Ayarlar → Görüşme kaydı).

  • While the setting is active on a business the sentence is fixed and

announcementName is not used.

Announcement name

Enabled on request, per account. Other accounts get 403 feature_not_enabled and the field is not present in their responses.

Every call opens with a short legal announcement, played before the greeting, telling the caller they are talking to an AI and that the call is recorded (KVKK). You can put your own brand in it. You set only the name; the sentence around it is fixed:

Şu anda {announcementName} yapay zekası ile konuşuyorsunuz ve görüşmelerimiz kayıt altındadır.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"announcementName":"Alofy"}'

The next call on that business says: *"Şu anda Alofy yapay zekası ile konuşuyorsunuz ve görüşmelerimiz kayıt altındadır."*

  • 2–30 characters: letters and digits, up to 4 words separated by single

spaces. No punctuation. Don't write "yapay zeka" or "kayıt" into the name: the fixed sentence already says both.

  • The announcement cannot be removed or reworded — only the name changes.

null goes back to the default announcement (*"Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz."*); omitting the field leaves it untouched.

  • Also accepted on POST /api/v1/businesses. Set it per business: each of your

businesses can carry a different name.

  • announcementName in a GET response is the name you set, or null.
  • The name is not suffixed, so it reads correctly with any brand

("Alofy yapay zekası", "Mudanya Emlak yapay zekası").

  • The greeting field is separate and unchanged: the announcement plays first,

then the assistant says its greeting.


Announcement prefix

Every call opens with a short legal announcement (AI + recording notice, KVKK), read by a fixed male announcer voice before the greeting. You cannot remove or reword it, but you can put your own sentence in front of it:

curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"announcementPrefix":"Bu bir Alofy yapay zekasıdır."}'

The next call on that business opens with: *"Bu bir Alofy yapay zekasıdır. Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz."*, then the assistant says its greeting.

  • Free text, 3 to 120 characters: letters, digits, spaces and basic punctuation

(. , ! ? ' : ; & ( ) -). No web addresses. A final period is added if you leave it out; the stored value is exactly what is read.

  • Read in the same announcer voice, right before the mandatory sentence. The

mandatory sentence always follows it.

  • null or 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 announcementName and callRecording: whichever

mandatory sentence applies, your prefix is read before it.

  • announcementPrefix in a GET response is the sentence you set, or null.
  • The business owner can change the same sentence in the panel (Ayarlar →

Karşılama mesajı) and in Sesera Dev (İşletmeler → Anons).


Custom tools

Give each agent tools that call your own API during a call — look up an order, check stock, write to your CRM, anything. Declare them per business with a tools array on create or PATCH:

curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "tools": [{
      "name": "siparis_durumu",
      "description": "Müşteri sipariş durumunu sorduğunda çağır. Sipariş numarasını al.",
      "url": "https://api.senin-sistemin.com/siparis/durum",
      "method": "POST",
      "parameters": {
        "type": "object",
        "properties": {
          "siparisNo": { "type": "string", "description": "Sipariş numarası" }
        },
        "required": ["siparisNo"]
      },
      "responseTimeoutSecs": 12,
      "responsePath": "result"
    }]
  }'
fieldtypenotes
namestringfunction name the AI calls; ^[a-z][a-z0-9_]{1,39}$, can't shadow a built-in
descriptionstringwhen to use it + what it returns — the AI reads this to decide
urlstringyour endpoint; absolute https (internal/private hosts rejected)
methodPOST\GETdefault POST
parametersobjectJSON-Schema (type:"object", properties, required) the AI fills
responseTimeoutSecsint3–20, default 15 — it's on the live call, keep your endpoint fast
responsePathstringJSON key in your response to hand back to the AI (e.g. result); omit = whole body
enabledbooleandefault true; false keeps the definition but turns it off
  • Request — Sesera POSTs { "name": "<tool name>", "arguments": { …the AI's args… }, "conversation_id": "<uuid>" } (or a GET with the args as query params). Respond with JSON quickly. (This doc previously described a parameters key and a caller field; the live agent sends neither. Read arguments.)
  • Auth — every tool call carries Authorization: Bearer <your webhook signing secret> (the same secret shown in Ayarlar → Geliştirici → Webhook). Verify it and reject anything else.
  • Replace semantics — tools sets the whole list; send "tools": [] to remove them all. Omit the field to leave them unchanged. customTools is 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 the tools name answered 200 but stored nothing; if you set tools before then, send them again and confirm with GET /api/v1/businesses/{id}.
  • Max 10 tools per business, 20 parameters each. Editing tools re-syncs the agent automatically.

Reading calls, bookings & messages

All three are scoped to your businesses. Query params: since (ISO timestamp), businessId (filter to one), limit.

A booking/message is the outcome of a call; callId on it points at the call that produced it, so you can always fetch the conversation behind a result: GET /api/v1/calls/{callId}. (null when the row didn't come from a call.)

GET /api/v1/bookings

curl "https://sesera.ai/api/v1/bookings?since=2026-07-01T00:00:00Z" \
  -H "Authorization: Bearer $KEY"
{ "bookings": [ {
  "id": "…", "businessId": "…", "startsAt": "…", "endsAt": "…",
  "customerName": "Ahmet", "customerPhone": "+90…", "serviceName": "Saç kesimi",
  "notes": null, "status": "confirmed", "source": "voice",
  "callId": "…", "createdAt": "…"
} ] }

limit max 200.

GET /api/v1/messages

{ "messages": [ {
  "id": "…", "businessId": "…", "body": "Müşteri geri aranmak istiyor.",
  "customerName": "Ayşe", "customerPhone": "+90…", "status": "new",
  "callId": "…", "createdAt": "…"
} ] }

limit max 200.

GET /api/v1/calls — calls with the full conversation

curl "https://sesera.ai/api/v1/calls?since=2026-08-01T00:00:00Z&limit=25" \
  -H "Authorization: Bearer $KEY"
{ "calls": [ {
  "id": "…", "businessId": "…",
  "fromPhone": "+905…",                 // null when the caller hid their number
  "startedAt": "2026-08-05T12:16:58.052Z",
  "endedAt": "2026-08-05T12:17:42.052Z",
  "durationSeconds": 44,
  "status": "ai_handled",               // ringing | owner_answered | ai_handled | missed | failed
  "outcome": null,                      // callback_scheduled | info_only | no_intent | hangup | spam | null
  "transcriptStatus": "available",
  "transcript": [
    { "role": "assistant", "text": "Merhaba, hoş geldiniz, burası …" },
    { "role": "customer",  "text": "Web sitesi yaptırmak istiyorum." }
  ],
  "transcriptText": "AI: Merhaba…\nMüşteri: Web sitesi yaptırmak istiyorum."
} ] }
  • limit max 100, default 25 (lower than the other lists — each row carries

a whole conversation). Page backwards with since; rows are newest-first by startedAt.

  • transcript=0 returns metadata only (cheaper for frequent polling); the

transcript fields come back null with transcriptStatus: "not_requested".

  • transcriptStatus values:
valuemeaning
availabletranscript / transcriptText hold the conversation
withheld_kvkkthe call carries no KVKK disclosure record, so the conversation body is withheld by law/policy — the metadata above is still accurate
emptynothing was transcribed (e.g. the caller hung up immediately)
not_requestedyou passed transcript=0
  • role is customer (the caller) or assistant (the AI).

GET /api/v1/calls/{id}

Same object under { "call": … }, always with the transcript. Use it to fetch the conversation behind a booking/message callId. Unknown or foreign id → 404 not_found.

curl "https://sesera.ai/api/v1/calls/1dddffb1-2882-43be-a702-ac85001efebd" \
  -H "Authorization: Bearer $KEY"

This endpoint (not the list) also tells you whether the call has an audio recording, with two extra fields:

{ "call": {
  "id": "1dddffb1-…", "…": "…same fields as the list…",
  "recordingStatus": "available",
  "recording": {
    "url": "https://sesera.ai/api/v1/calls/1dddffb1-…/recording",
    "contentType": "audio/wav",
    "durationSeconds": 59,
    "sizeBytes": 948844
  }
} }
recordingStatusmeaning
availablerecording describes the file; download it from recording.url
withheld_kvkksame rule as transcriptStatus: "withheld_kvkk" — no KVKK disclosure on the call, so the audio is withheld too; recording is null
noneno 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=1 sends Content-Disposition: attachment (default is inline).
  • HTTP Range requests are supported (206 Partial Content), so an <audio>

player can seek. The URL needs your API key, so don't put it straight into a browser page — proxy it through your own backend and keep the key server-side.

  • You don't need GET /calls/{id} first: request the recording directly and

treat 404 recording_not_found as "no recording (yet)".

  • Downloads count toward the normal rate limit.
HTTPerrormeaning
200 / 206—the WAV file (or the requested byte range)
403withheld_kvkkthe call has no KVKK disclosure record; the audio is withheld (the transcript is too)
404not_foundunknown or foreign call id
404recording_not_foundthe call is yours but there is no recording; if the call ended in the last few minutes, retry shortly

Webhooks (real-time)

If Sesera configures a webhook URL for your account, every new booking/message is POSTed to it as it happens. A business owner may additionally set their own endpoint for their business; that does not replace yours — the event is delivered to both.

Event body:

{
  "type": "booking.created",   // or "message.created"
  "businessId": "…",
  "createdAt": "2026-07-03T…",
  "data": { … same fields as the read API rows … }
}

Signature — header X-Sesera-Signature: t=<unixSeconds>,v0=<hex> where hex = HMAC_SHA256(secret, "<t>.<rawRequestBody>"). Verify it with the signing secret Sesera gives you, and reject timestamps older than ~5 minutes.

const crypto = require("crypto");
function verify(rawBody, header, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (!parts.t || !parts.v0) return false;
  // replay window
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`).digest("hex");
  // timingSafeEqual THROWS on a length mismatch — compare lengths first
  const a = Buffer.from(expected, "hex"), b = Buffer.from(parts.v0, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Respond 2xx quickly. Delivery retries a few times on failure; use the read API to reconcile anything missed.


Live transcript (real-time, during the call)

See what is being said while the call is still going: every sentence the caller says and every sentence the assistant says is POSTed to your webhook the moment it happens. Text only, no audio. Off by default, per business.

Turn it on — PATCH /api/v1/businesses/{id} with { "liveTranscript": true } (or true in the create body). The business owner can also flip it in the dashboard: Sesera Dev → API → *Canlı transkript*. GET /api/v1/businesses/{id} returns the current value as liveTranscript. Events go to the same webhook targets and carry the same X-Sesera-Signature as bookings and messages.

Three events per call, always in this order:

EventWhendata
call.startedthe call is connected to the businessconversationId, direction (inbound / outbound), fromPhone (the other party), toPhone, startedAt
call.transcripton every sentence, repeatedlyconversationId, seq, turn, transcript
call.endedthe call has been savedconversationId, callId, durationSeconds, endedAt, transcriptStatus, transcript
{
  "type": "call.transcript",
  "businessId": "…",
  "createdAt": "2026-09-26T10:14:03.512Z",
  "data": {
    "conversationId": "7c1e…",
    "seq": 4,
    "turn": { "index": 3, "role": "assistant", "text": "Tabii, yarın saat on dörtte yerimiz var.", "final": false },
    "transcript": [
      { "role": "assistant", "text": "Bu görüşme kayıt altındadır ve yapay zekayla konuşuyorsunuz.", "final": true },
      { "role": "assistant", "text": "Merhaba, nasıl yardımcı olabilirim?", "final": true },
      { "role": "customer",  "text": "Yarın öğleden sonra için randevu almak istiyorum.", "final": true },
      { "role": "assistant", "text": "Tabii, yarın saat on dörtte yerimiz var.", "final": false }
    ]
  }
}

**Render data.transcript, not just data.turn.** Every call.transcript carries the whole conversation so far. The live transcript is corrected as the call goes on: when the caller pauses and continues, the two parts become one sentence; when the caller interrupts the assistant, its sentence is shortened to what was actually heard. Redrawing the list from transcript on every event is always correct; appending turn is not. A line with "final": false is the sentence the assistant is speaking right now — the same line comes back with "final": true when it has finished.

  • Roles are customer and assistant, the same as GET /api/v1/calls/{id}.
  • seq increases within a call. Ignore an event whose seq is lower than the

last one you applied.

  • Events for one call are delivered one at a time, in order. If your endpoint is

slow, intermediate snapshots may be skipped, but the latest one is always sent. Answer 2xx within 3 seconds.

  • call.ended holds the final transcript and the callId for

GET /api/v1/calls/{callId} and …/recording. It is retried like booking events; call.transcript is not (the next one supersedes it).

  • KVKK: nothing is sent before the assistant has read the disclosure. A call

without a disclosure sends call.started and a call.ended with "transcriptStatus": "withheld_kvkk" and "transcript": null, exactly like the read API.

  • Test without a call: Sesera Dev → API → *Örnek çağrı gönder* sends a full

sample sequence to the business webhook, each event marked "test": true.


Typical integration flow

  1. GET /api/v1/me — confirm your quota + pool.
  2. POST /api/v1/businesses — create an agent. If stock is available you get a

live phoneNumber back; if not, status is awaiting_number (no error).

  1. If awaiting_number: POST /api/v1/businesses/{id}/assign-number to bind a

number (retry after Sesera restocks if it returns 409 no_numbers).

  1. POST /api/v1/businesses/{id}/minutes — allocate minutes so it can answer.
  2. Receive booking.created / message.created webhooks (or poll

GET /api/v1/bookings + GET /api/v1/messages). Poll GET /api/v1/calls for the conversations themselves — webhooks fire on outcomes only, never on a call ending. The audio of any call is at GET /api/v1/calls/{id}/recording.

  1. PATCH to edit, DELETE to release the number.

Rate limit: ~120 requests/minute per reseller account (all keys share it).


Outbound calls — SIP Connect

The assistant can also place calls, over a SIP trunk the business owns.

Sesera does not sell outbound minutes and never carries the telephony leg: the number, the carrier contract and the per-minute call cost stay with the customer. What we add is the assistant on that leg — and the AI minutes it burns are metered to the business exactly like an inbound call.

Set the trunk up first, either in the dashboard (Sesera Dev → SIP) or through the API below. A trunk needs the carrier's SIP host plus either a SIP username/password (mode register, what Turkish carriers use) or an IP allowlist (mode ip, for on-prem PBXes). Outbound additionally requires the one-time consent acknowledgement on that page — without it every call is refused with 403 consent_required, no matter what the API sends.

SIP trunks — add the carrier line through the API

A business provisioned through this API has no owner who can open the Sesera dashboard, so its trunk is managed here. Same validation, same switch, same limits as the dashboard.

POST /api/v1/businesses/{id}/sip-trunks — add a trunk and connect it

curl -X POST https://sesera.ai/api/v1/businesses/$BIZ/sip-trunks \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "label": "Verimor ana hat",
    "mode": "register",
    "direction": "both",
    "host": "sip.verimor.com.tr",
    "username": "908501234567",
    "password": "…",
    "did": "+908501234567",
    "outboundConsent": true
  }'
fieldtypenotes
labelstringrequired, 2–60 chars
moderegister\ip\relayregister = SIP username + password (Turkish carriers); ip = IP allowlist (on-prem PBX); relay = your own carrier number delivered to Sesera by IP routing, see below
directioninbound\outbound\bothdefault both
hoststringrequired, the carrier's SIP host or IP
portintdefault 5060
transportudp\tcpdefault udp
username / passwordstringrequired in register mode. The password is write-only and never returned
allowedIpsstring[]required in ip mode; IPs or CIDRs
didstringthe customer's own number on this trunk; routes inbound calls to this business
outboundCallerIdstringnumber shown on outbound calls; the carrier may override it
maxConcurrentintsimultaneous calls, 1–30, default 2
dailyOutboundCapint0–5000, default 200
callWindowobject{ "start": "09:00", "end": "20:00" } Europe/Istanbul, the default
outboundConsentbooleanthe İYS / consent acknowledgement outbound calls require (see Compliance below). You give it on your customer's behalf
connectbooleandefault true; false saves the trunk without touching the switch

201 Created → { "trunk": { … } }. If the trunk was saved but the switch refused it, the response is still 201 with the trunk in status: "error" and a connectError object; fix it with PATCH rather than creating a second one.

GET /api/v1/businesses/{id}/sip-trunks — list. GET …/sip-trunks/{trunkId} — one; add ?live=1 to ask the switch directly: "live": { "endpointLoaded": true, "registration": "Registered" }.

PATCH …/sip-trunks/{trunkId} — send only what changes. An edit to a connected trunk is pushed to the switch at once. { "enabled": false } disconnects it, { "enabled": true } connects it again. outboundConsent can be set or cleared.

DELETE …/sip-trunks/{trunkId} — disconnect and remove.

Your own number — relay mode

Point a number you already own at one of your businesses, with no 0850 and no forwarding. Your carrier delivers the call straight to Sesera; the number never enters Sesera's pool, is never given to anyone else, and does not use your number quota.

  1. Create the trunk. Only label and did are 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".

  1. In your carrier panel, route incoming calls for that number to

relayAddress with the port. On Verimor: Ses Hizmeti → Gelen Çağrı Yönetimi → trunk destination. No SIP registration is needed.

To move the number to another business, DELETE the trunk and create it on the other business (the carrier setting stays the same). direction defaults to inbound; host, port and allowedIps are ignored.

Outbound calls on your own number (Verimor). Set direction to both (or outbound) and add the number's SIP password from the carrier panel; username defaults to the number without +:

curl -X PATCH https://sesera.ai/api/v1/businesses/$BIZ/sip-trunks/$TRUNK \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "direction": "both", "password": "…", "outboundConsent": true }'

Calls then go out through POST /api/v1/businesses/{id}/calls exactly as on any other trunk (consent, call window, daily cap and minutes all apply), and the carrier bills them to your account. No SIP registration is made, so your incoming routing stays as set. Outbound in relay mode is Verimor only.

Up to 5 trunks per business. Every change reloads the switch, so changes are limited to 12 per business per hour and 40 per reseller per hour (429 rate_limited with Retry-After). Writes are refused with 403 read_only on your own Sesera subscription; manage that one from the dashboard.

HTTPcodemeaning
400bad_label / bad_mode / bad_host / bad_username / password_required / allowed_ips_required / bad_did / bad_call_window / did_required …invalid field, see message
404not_foundtrunk not on this business
409too_many_trunks / did_taken5 trunks already, or that number is on another trunk
429rate_limitedtoo many switch changes
502media_host_failed / endpoint_not_loadedthe 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: direction both or outbound (in relay mode also the SIP password, see above);
  • calls arriving on your own number (a relay or 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
targetsrequired, 1 to 5name (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)
enableddefault truefalse keeps the lines but stops offering transfer
trunkIdoptionaltrunk the transfer goes out on; default: the trunk the call came in on, else the first trunk that can place calls
ringSeconds10-60, default 25how long the line rings before the caller goes back to the assistant
maxMinutes1-240, default 60hard end for the transferred conversation
messageoptional, up to 200 characterswhat the assistant says before transferring; default "Sizi hemen bağlıyorum, lütfen hatta kalın."
fallbackMessageoptional, up to 200 characterswhat 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 says fallbackMessage and takes a message. That second part counts as a new call in GET /api/v1/calls.
HTTPcodemeaning
400targets_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_idinvalid field, see message
409transfer_trunk_requiredthe business has no active trunk that can place calls
409transfer_trunk_cannot_dialtrunkId is not an active outbound trunk of this business
409target_loops_backthe line is a Sesera number or a number on a SIP trunk, so it would ring an assistant again
403read_onlyyour own Sesera subscription; not available through the API

POST /api/v1/businesses/{id}/calls — ring a number

curl -X POST https://sesera.ai/api/v1/businesses/$BIZ/calls \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "to": "+905321234567",
    "firstMessage": "Merhaba, ben {{business_name}} asistanıyım. Kısa bir bilgi vermek için aradım.",
    "extraInstructions": "Amaç: yeni kampanyayı anlat, ilgilenirse randevu ver. İlgilenmiyorsa kibarca kapat.",
    "metadata": { "campaignId": "eylul-kampanya", "leadId": "42" }
  }'
fieldtypenotes
tostringrequired. E.164 (+90…) or a Turkish local form
trunkIdstringoptional while the business has exactly one outbound trunk; required with several
firstMessagestringthe assistant's opening line. {{business_name}} is substituted. Omit to use the normal greeting — which is usually wrong on an outbound call
extraInstructionsstringwhy this call exists. Injected ahead of the stored knowledge; tone, language policy and brevity rules still apply
metadataobjectechoed back on reads; your campaign/lead ids
{ "call": { "id": "…", "status": "dialing", "to": "+905321234567", "trunkId": "…" } }

202 means the call file reached the switch — not that anybody answered.

GET /api/v1/businesses/{id}/calls — what happened

{ "calls": [ { "id": "…", "to": "+90…", "status": "completed", "callId": "…",
               "durationSeconds": 74, "createdAt": "…", "endedAt": "…" } ] }

status: queued → dialing → completed / no_answer / failed. callId points at the GET /api/v1/calls/{callId} record with the transcript.

Errors specific to outbound

HTTPcodemeaning
402no_planthe business has no minute budget; SIP Connect refuses unmetered accounts
402usage_cap_reachedminutes exhausted
403consent_requiredthe İYS/consent acknowledgement is not ticked on the trunk
403blocked_contactthe number is on the business's own block list
409no_trunkno active outbound trunk configured
409outside_calling_windowoutside the trunk's local calling hours (default 09:00–20:00 Europe/Istanbul)
409trunk_inactive / direction_not_allowedthe trunk is off, or set to inbound only
429daily_cap_reached / trunk_busyper-day or simultaneous-call ceiling hit
502dial_failedthe 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
}
FieldMeaning
metricsThe 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.
cohortWhere the line sits among comparable Sesera businesses: the group size and its three quartiles. Anonymous by construction, never another tenant's row.
verdictustun, ortalama_ustu, ortalama, dusuk or yetersiz_veri. Computed from the conversion rate only.
severityok, 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.
summaryTrThe whole report as Turkish prose, safe to show or read out loud as is.
reviewedWhether 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.