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 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.
  • 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.unassigned on GET /api/v1/me) and is never given to anyone else. It is not attached to a new business automatically: you attach it yourself with POST /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 as awaiting_number instead 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 as call.ended: who called, when, how long, the outcome and a short summary. With liveTranscript: true on a business, the call itself is streamed too (call.started, call.transcript) and call.ended carries 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>" }.

HTTPcodemeaning
400bad_jsonbody was not valid JSON
400validation_errorinvalid fields (see details[])
400invalid_voicevoiceId not in GET /voices
400invalid_domainsallowedDomains has entries that are not domain names (see Website call button)
401unauthorizedmissing / invalid / revoked key
402insufficient_poolnot enough minutes in your pool to allocate
403reseller_suspendedyour 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
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)
403quota_reachednumber cap reached — delete one, or buy capacity in the developer panel under Telefon numaraları (one-time 1.000 ₺ per number)
403plan_assistant_limityour Developers plan's assistant limit is reached (POST /businesses). Existing businesses keep working; upgrade the plan in the developer panel to add more
403plan_feature_unavailablethe 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
403key_scopea business-scoped key called an account-level endpoint (or set a locked field, see Business-scoped keys)
404not_foundbusiness / call / booking 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)
409slot_full · people_fullbooking write: the time already holds bookingSlotCapacity bookings, or the people limit (bookingMaxPartySize) would be exceeded (see Create a booking)
409cancelledreschedule of a booking that is already cancelled
422in_past · closed_day · outside_hours · blocked · party_too_large · party_size_requiredbooking write refused by the business's booking rules (see Create a booking)
422bookings_disabledbooking create or reschedule on a business with bookingsEnabled: false; turn it on first (cancelling still works)
404whatsapp_account_not_foundaccountId is not one of your organization's WhatsApp numbers (list them with GET /api/v1/whatsapp/accounts)
409whatsapp_account_not_connectedfollowUp / assistantReplies / answersNewChats (or account refresh) on a business that is not linked to a WhatsApp Business number; link it with accountId
429rate_limitedslow down (see Retry-After)
429reseller_concurrency_limityour 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_suspended and 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"
    }
  ]
}
FieldNotes
type"preset" for the built-in voices, "custom" for your clones.
idUse it as voiceId on POST /businesses and PATCH /businesses/{id}. Custom ids start with ozel_.
statusCustom voices only. Only ready voices are listed.
preview_urlPreset 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)

FieldRequiredNotes
audioyesMP3, WAV, M4A, WebM or OGG. At least 60 seconds of one person speaking, at most 20 MB. Only the first 3 minutes are used.
nameyes1 to 40 characters.
consentyesMust 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.
descriptionnoUp 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=true

The 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

StatuserrorWhen
400consent_requiredconsent is missing or not true.
400audio_required / name_requiredMissing field.
400audio_too_shortLess than 60 seconds of audio (durationSec tells you what we measured).
400invalid_audioThe file could not be read as audio.
400invalid_voicevoiceId on a business is neither a preset nor one of your ready custom voices.
403key_scopeA business-scoped key tried to clone or delete a voice. Use an account-wide key.
404not_foundThe voice does not exist or belongs to another account.
409voice_limitYour account has too many voices; delete one you no longer use or contact us.
413audio_too_largeOver 20 MB.
429rate_limitedToo many clones in an hour.
502training_failedThe 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_found for any other id (exactly like an id that doesn't exist). List endpoints return only that business; a businessId= 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.
EndpointBusiness-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):

fieldtypenotes
namestringrequired, 2–120 chars
categorystringe.g. "kuafor", "klinik", "oto" (drives safety guardrails)
hoursobjecte.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.
servicesarray[{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }]
faqarray[{ "q": "Otopark var mı?", "a": "Evet, ücretsiz." }]
freeTextstringfree-form extra knowledge
extraInstructionsstringtone/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
greetingstring \nullthe 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
outboundGreetingstring \nullthe 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
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
callRecordingbooleantrue (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
callerMemorybooleantrue (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
voiceIdstringfrom GET /voices. If omitted, the default voice is used and voiceId reads null until you set one
personaPresetstringthe assistant's tone: sicak (default), kurumsal, enerjik or sakin
voiceSpeednumbertalking 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
bookingsEnabledbooleanturn on appointment booking tools
bookingLeadMinutesintmin advance notice (default 60)
bookingMaxDaysAheadintmax days ahead (default 30)
bookingDefaultDurationMinutesintappointment length when the service has none, 1–1440
bookingSlotCapacityinthow 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
bookingMaxPartySizeint \nullthe 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
closedDaysarray \nullspecial 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
liveTranscriptbooleanstream the call to your webhook while it runs (default false), see Live transcript
enabledLanguagesstring[]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
maxCallMinutesintper-call cap in minutes, 0–600; 0 = unlimited. Default 5
toolsarraycustom 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, status stays provisioning and phoneNumber is null until 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 — 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.

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 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. 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):

  • limits are your own safety caps: at most dailyMaxCharges automatic charges per day and monthlyMaxTry TL 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.
  • card is null when no card is saved. autoChargeable: false means the card cannot be charged without the cardholder present (some debit cards); add another card in the panel.
  • disabledReason: null while 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 } }'
fieldtypenotes
enabledbooleanrequired
belowMinutesinteger 1–100000required when enabled is true
amountMinutesinteger 1–1000000required 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. 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.

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, else outboundGreeting, else greeting.
  • {{name}} placeholders are filled from the call's variables (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. GET returns the text you set, or null.

Call recording

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

callRecordingthe caller hearsstored
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}/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ı).
  • The sentence is fixed. announcementName, announcementPrefix, announcementSuffix and announcementAiNotice work with both values (with announcementName and false: *"Ş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. 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.

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.
  • 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).

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.

FieldTypeWhat it does
announcementSuffixstring \nullyour 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.
announcementAiNoticebooleantrue (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, announcementName is not used (the name only appears in the AI part).
  • Calls whose announcement played remain listenable through GET /api/v1/calls/{id}/recording with the AI part switched off as well.
  • Both fields are accepted on POST /api/v1/businesses and PATCH /api/v1/businesses/{id}, set per business, and returned by every GET (announcementSuffix: the sentence or null; 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"
    }]
  }'
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; a name that resolves to a private network or to Sesera's own servers is not called)
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 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 — 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.

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.com matches example.com and www.example.com.
  • *.example.com matches example.com and 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 plain http for local testing; every other site must be https.
  • 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"}}'
fieldtype
enabledbooleanfalse hides the button on every site
allowedDomainsstring[]replaces the whole list (max 20)
button.textstring1 to 40 characters
button.colorstringhex, #rrggbb
button.positionright · left · nonenone = no corner button, use data-sesera-ara
button.showBadgebooleandefault true. false hides the "Sesera ile" line; Creator plan and above, see below
dailyLimitinteger1 to 5000 website calls per day
rotateKeybooleantrue 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.


WhatsApp

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
idthe number's account id; the same for every assistant linked to it (see accounts)
coexistencethe number is also used in the WhatsApp Business app on the owner's phone
templateStatusMeta's status of the after-call template: PENDING, APPROVED, REJECTED, PAUSED, DISABLED, … (null until submitted)
templateReadyafter-call messages can be sent (APPROVED, or FLAGGED which Meta still delivers)
followUpafter-call message on or off
assistantRepliesthe assistant answers customers who write back
answersNewChatsthis assistant is the one that answers customers who write to the number first
lastErrorthe last problem with the connection or the template, as text for a person (Turkish), else null
reconnectBywhen 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}'
fieldtype
followUpbooleanafter-call message from the business's own number on or off. Needs the account
assistantRepliesbooleanthe assistant answers customers who write back. Needs the account
accountIdstring or nulllink 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
answersNewChatsbooleantrue: 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}'
fieldtype
sampleRateintegeroptional, 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 a tool event (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 kHz

Billing 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.

statuserror
403read_onlythe business is your own Sesera subscription, not one you provisioned
402no_minutesthe business has no minutes left: give it minutes (POST …/minutes)
409assistant_not_readythe assistant has no prompt yet
409calls_stoppedcalls are stopped on this business
429rate_limitedmore than 30 sessions requested for one business in a minute
429reseller_concurrency_limityour plan's concurrent call limit is reached
503not_accepting_calls · unavailableyour 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: 3 up 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: 40 the 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/bookings answers 422 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" }'
fieldtypenotes
businessIduuidrequired with an account key; a business-scoped key may omit it (its own business)
startsAtstringISO 8601 with offset, must be in the future
durationMinutesint5–1440. Default: the matching service's duration when serviceName matches one of the business's services, otherwise bookingDefaultDurationMinutes
serviceNamestringoptional, max 120
customerNamestringrequired, max 120
customerPhonestringoptional, a Turkish number (05…, +905…)
partySizeintnumber of people, 1–2000. Required when the business has bookingMaxPartySize
notesstringoptional, 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" }
} ] }
  • limit max 100, default 25 (lower than the other lists — each row carries a whole conversation). Rows are newest-first by startedAt. To page back, pass the last row's startedAt as before and its id as beforeId; the next page starts right after that row, so nothing is skipped even when several calls share a timestamp. since is the lower bound and works together with them.
  • 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).
  • metadata is the object you passed to [POST /api/v1/businesses/{id}/calls](#post-apiv1businessesidcalls--ring-a-number) for an outbound call, returned exactly as stored; null for 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
  }
} }
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)

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
callIdthe call; read it with GET /api/v1/calls/{id}
conversationIdthe session id (the one call.started carried, when live transcript is on)
directioninbound or outbound
channelphone or web (website call button)
fromPhonethe caller, E.164; null when the number was hidden (and on web calls). On an outbound call the person called is toPhone
toPhonethe number that was called; null on web calls
startedAt, endedAt, durationSecondswhen and how long
outcomebooked (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
summaryone 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, messageIdsthe bookings and messages this call created (the same ids booking.created / message.created carried)
metadataoutbound calls: the metadata you sent; otherwise null
transcriptStatus, transcriptnot_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:

typedata
booking.createdid, startsAt, endsAt, customerName, customerPhone, serviceName, partySize (null when not asked), status, source (voice, chat, manual or api)
booking.cancelledthe same fields with status: "cancelled", plus cancelledBy: "caller" (the caller cancelled it on the phone) or "api" (POST /api/v1/bookings/{id}/cancel)
booking.rescheduledthe 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:

typedata
call.startedconversationId, direction (inbound/outbound), fromPhone, toPhone, startedAt
call.transcriptconversationId, seq (grows; drop anything older than what you have), turn (the line that just changed, with its index), transcript (every line so far)
call.endedeverything 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:

typedata
business.minutes_lowbusinessId, 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. limit max 200, default 50. Page back with before = the last row's createdAt and beforeId = its id. A bad filter answers 400 validation_error (400 bad_before_id for beforeId).
  • error is a short Turkish note of what went wrong (no answer in time, the HTTP status your endpoint returned, a refused address); null when delivered.
  • refused means Sesera did not send it: the URL no longer resolves to a public address, or the target has no signing secret. pending is 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:

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 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 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 with "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 live phoneNumber back, or awaiting_number (no error) when the pool is empty.
  3. To give a numberless or awaiting_number agent a number: POST /api/v1/businesses/{id}/assign-number (retry after Sesera restocks if it returns 409 no_numbers).
  4. POST /api/v1/businesses/{id}/minutes — allocate minutes so it can answer.
  5. Receive booking.created / message.created and call.ended webhooks (or poll GET /api/v1/bookings + GET /api/v1/messages). call.ended arrives for every call with the caller, duration, outcome and a short summary; the conversation itself is at GET /api/v1/calls/{id} (or streamed with liveTranscript). The audio of any call is at GET /api/v1/calls/{id}/recording.
  6. PATCH to edit, DELETE to 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
  }'
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 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)
portintdefault 5060
transportudp\tcpdefault udp
username / passwordstringrequired in register mode. The password is write-only and never returned; it may not contain a line break or ;
allowedIpsstring[]required in ip mode; IPs or CIDRs. An address already on another business's ip trunk is refused with 409 ip_taken
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. 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.

  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" and a verification object. The trunk stays in status: "draft" until the number is verified.

  1. 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.

  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). 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.

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
400not_relayPOST …/verify on a trunk that is not in relay mode
404not_foundtrunk not on this business
409too_many_trunks / did_taken5 trunks already, or that number is on another trunk
409ip_takenan address in allowedIps is already on another business's ip trunk
409not_verifiedrelay trunk whose number is not verified yet, see verification
409already_verifiedPOST …/verify on a trunk whose number is already verified
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

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
keysobject, up to 12 keyskey 0-9, * or # → an action (table below)
menuPrompt3-200 charactersthe menu sentence. Required with promptAt: "after_greeting", optional with "on_request". No web addresses
promptAtafter_greeting (default) / on_requestwhen the menu sentence is read, see below
otherKeysassistant (default) / ignorewhat happens to keys that are not in keys
enableddefault truefalse keeps the menu but switches it off
actionextra fieldwhat happensAI involved
transfertarget (required): the name of one of the business's call-transfer linesthe call is put through to that lineno
assistantinstruction (required, up to 300 characters)the assistant takes instruction as the caller's request and answers ityes
saytext (required, up to 200 characters)text is read in the assistant's voice, then the assistant listens againno
repeat_menumenuPrompt is read again (needs a menuPrompt)no
hanguptext (optional, up to 200 characters)text is read if given, then the call endsno
tooltool (required): the name of one of the business's custom toolsyour tool is called, the assistant tells the caller the resultyes

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, then menuPrompt, 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 keys acts 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.

HTTPcodemeaning
400bad_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_toolinvalid field, see message
400menu_prompt_requiredpromptAt is after_greeting (or a key uses repeat_menu) but there is no menuPrompt
400keys_requiredno keys and otherKeys: "ignore": the menu would do nothing
409transfer_target_unknowncall transfer is not set up, or target is not one of its lines
409tool_unknowntool is not one of the business's custom tools
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 {{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" }
  }'
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 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
variablesobjectvalues 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
extraInstructionsstringwhy this call exists. Added after the stored knowledge as the call's purpose; 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": "…",
               "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

HTTPcodemeaning
400missing_variablesa {{name}} in the opener or instructions has no value in variables; message lists the names
400first_message_too_longthe opener is longer than 400 characters once the variables are filled in
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
429reseller_concurrency_limityour plan's concurrent call limit is reached; an unanswered call frees its line when ringing ends
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.