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) — via webhook or a read API.
- Base URL:
https://sesera.ai - Auth:
Authorization: Bearer <API_KEY>on every request - Content type:
application/json - All endpoints are scoped to the authenticating reseller — a key can only ever
see and touch its own businesses.
Concepts
- Business = one AI voice agent + one phone number. You configure its
knowledge (hours, services, FAQ, free text) and voice; Sesera compiles a Turkish system prompt and provisions a live agent.
- Number pool — numbers are drawn from Sesera's shared pool. Your API key
has a number cap (maxNumbers); creating past it returns 403 quota_reached. Creating up to your cap never fails on stock: if the pool is empty, the business is created and held in your stock with status **awaiting_number (no error). Its number is bound later via POST /api/v1/businesses/{id}/assign-number**, which is the only call that can return 409 no_numbers. Retry that call after Sesera restocks.
- Minute pool — your account holds a prepaid minute balance (funded via
Sesera). You allocate minutes from the pool to a business; a business can only take calls while it has allocated minutes left.
- Source of truth — bookings and messages captured by the agent live in
Sesera and are delivered to you by webhook (real-time) and/or the read API (polling). The calls behind them — including the transcript — are read-only over the API (GET /api/v1/calls); there is no call webhook.
Errors
All errors return { "error": "<code>", "message"?: "<human hint>" }.
| HTTP | code | meaning |
|---|---|---|
| 400 | bad_json | body was not valid JSON |
| 400 | validation_error | invalid fields (see details[]) |
| 400 | invalid_voice | voiceId not in GET /voices |
| 401 | unauthorized | missing / invalid / revoked key |
| 402 | insufficient_pool | not enough minutes in your pool to allocate |
| 403 | reseller_suspended | your account is suspended |
| 403 | quota_reached | number cap reached — delete one or ask Sesera to raise it |
| 404 | not_found | business does not exist (or isn't yours) |
| 409 | no_numbers | pool empty at assign-number time — retry after Sesera restocks (create never returns this) |
| 429 | rate_limited | slow down (see Retry-After) |
| 502 | agent_sync_failed | upstream voice provisioning hiccup — retry |
Account
GET /api/v1/me
Your account status, number quota, and minute pool.
curl https://sesera.ai/api/v1/me -H "Authorization: Bearer $KEY"{
"reseller": {
"id": "…", "name": "Akdeniz Telekom", "status": "active",
"numbers": { "used": 3, "max": 10 },
"minutes": { "balance": 250, "max": 0 }
}
}(max: 0 = no ceiling.)
GET /api/v1/voices
The voices you may set as voiceId. Each voice includes a **preview_url** — a public MP3 sample (the same clip our own wizard plays) you can drop straight into an <audio> tag so your customers can hear a voice before picking it. The URL is public and cacheable; it needs no Authorization header.
{ "voices": [ { "id": "IOx9E82IJLWAeUWBCdDz", "name": "Yağmur", "description": "Sıcak, samimi sohbet sesi", "gender": "female", "preview_url": "https://sesera.ai/_voicesamples/IOx9E82IJLWAeUWBCdDz.mp3" }, … ] }Businesses
POST /api/v1/businesses — create an agent (+ number when in stock)
If a number is in stock it's assigned immediately and the business is live. If the pool is empty, the business is still created (never fails on stock) and held with status: "awaiting_number" and phoneNumber: null — then call [assign-number](#post-apiv1businessesidassign-number--bind-a-number-from-stock) once Sesera restocks. Either way, this counts against your number cap.
Body (only name is required; everything else has sensible defaults):
| field | type | notes |
|---|---|---|
name | string | required, 2–120 chars |
category | string | e.g. "kuafor", "klinik", "oto" (drives safety guardrails) |
hours | object | e.g. { "mon": { "closed": false, "intervals": [["09:00","18:00"]] }, … } |
services | array | [{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }] |
faq | array | [{ "q": "Otopark var mı?", "a": "Evet, ücretsiz." }] |
freeText | string | free-form extra knowledge |
extraInstructions | string | tone/behaviour instructions |
voiceId | string | from GET /voices (default applied if omitted) |
voiceSpeed | number | 0.5–1.5 |
bookingsEnabled | boolean | turn on appointment booking tools |
bookingLeadMinutes | int | min advance notice (default 60) |
bookingMaxDaysAhead | int | max days ahead (default 30) |
enabledLanguages | string[] | extra ISO-639-1 codes the agent may switch to |
maxCallMinutes | int | per-call cap; 0 = unlimited |
tools | array | custom tools the agent can call against your API — see Custom tools |
Strings that flow into the prompt may not contain control chars or {{.
curl -X POST https://sesera.ai/api/v1/businesses \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"name": "Yılmaz Kuaför",
"category": "kuafor",
"hours": { "mon": { "closed": false, "intervals": [["09:00","19:00"]] } },
"services": [{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }],
"bookingsEnabled": true
}'201 Created:
{
"business": {
"id": "…", "name": "Yılmaz Kuaför", "category": "kuafor",
"status": "live", "phoneNumber": "+90850…", "bookingsEnabled": true,
"voiceId": "IOx9E82IJLWAeUWBCdDz",
"minutes": { "allocated": 0, "used": 0, "remaining": 0 },
"createdAt": "2026-07-03T…", "updatedAt": "2026-07-03T…"
}
}The number is live immediately, but the agent won't take calls until you allocate minutes (see below).status:live(routing) ·awaiting_number(created, held in your stock waiting for a DID — callassign-number) ·provisioning·suspended. When status isawaiting_number,phoneNumberisnull.
POST /api/v1/businesses/{id}/assign-number — bind a number from stock
Binds a pooled DID to a business that is awaiting_number (or whose number was previously reclaimed) and takes it live. This is the only endpoint that can return 409 no_numbers — call it, and if the pool is empty, retry after Sesera restocks. Idempotent: a business that already has a number returns it unchanged. No body.
curl -X POST https://sesera.ai/api/v1/businesses/$ID/assign-number \
-H "Authorization: Bearer $KEY"200 OK → { "business": { …, "status": "live", "phoneNumber": "+90850…" } } · 409 no_numbers (pool empty — retry after restock) · 403 quota_reached.
GET /api/v1/businesses — list yours
{ "businesses": [ { …business… }, … ] }GET /api/v1/businesses/{id} — fetch one
PATCH /api/v1/businesses/{id} — edit
Send only the fields you want to change (same fields as create); the agent is recompiled and re-synced. Omitted fields are preserved.
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "freeText": "Pazar günleri kapalıyız." }'DELETE /api/v1/businesses/{id} — remove
Releases the number back to the pool and frees a slot in your number quota.
{ "deleted": true, "id": "…" }Minutes
POST /api/v1/businesses/{id}/minutes — allocate from your pool
Moves minutes from your prepaid pool to this business. The pool is debited immediately; the business can now take calls until it runs out.
curl -X POST https://sesera.ai/api/v1/businesses/$ID/minutes \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "minutes": 50 }'{
"business": { …business with minutes.allocated updated… },
"pool": { "balance": 200 }
}402 insufficient_pool if your pool doesn't have that many minutes.
Topping up your pool. Fund the pool yourself from the Sesera dashboard: Ayarlar → Geliştirici → Dakika satın al (the "Geliştirici" section appears once Sesera links your account to your reseller). Minutes are sold in 100-minute steps at a volume-tiered rate (the more you buy, the lower the per-minute price); payment is via Whop and the pool is credited automatically. Sesera can also top you up manually.
Custom tools
Give each agent tools that call your own API during a call — look up an order, check stock, write to your CRM, anything. Declare them per business with a tools array on create or PATCH:
curl -X PATCH https://sesera.ai/api/v1/businesses/$ID \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"tools": [{
"name": "siparis_durumu",
"description": "Müşteri sipariş durumunu sorduğunda çağır. Sipariş numarasını al.",
"url": "https://api.senin-sistemin.com/siparis/durum",
"method": "POST",
"parameters": {
"type": "object",
"properties": {
"siparisNo": { "type": "string", "description": "Sipariş numarası" }
},
"required": ["siparisNo"]
},
"responseTimeoutSecs": 12,
"responsePath": "result"
}]
}'| field | type | notes | |
|---|---|---|---|
name | string | function name the AI calls; ^[a-z][a-z0-9_]{1,39}$, can't shadow a built-in | |
description | string | when to use it + what it returns — the AI reads this to decide | |
url | string | your endpoint; absolute https (internal/private hosts rejected) | |
method | POST\ | GET | default POST |
parameters | object | JSON-Schema (type:"object", properties, required) the AI fills | |
responseTimeoutSecs | int | 3–20, default 15 — it's on the live call, keep your endpoint fast | |
responsePath | string | JSON key in your response to hand back to the AI (e.g. result); omit = whole body | |
enabled | boolean | default true; false keeps the definition but turns it off |
- Request — Sesera
POSTs{ "name": "<tool name>", "arguments": { …the AI's args… }, "conversation_id": "<uuid>" }(or aGETwith the args as query params). Respond with JSON quickly. (This doc previously described aparameterskey and acallerfield; the live agent sends neither. Readarguments.) - Auth — every tool call carries
Authorization: Bearer <your webhook signing secret>(the same secret shown in Ayarlar → Geliştirici → Webhook). Verify it and reject anything else. - Replace semantics —
toolssets the whole list; send"tools": []to remove them all. Omit the field to leave them unchanged. - Max 10 tools per business, 20 parameters each. Editing tools re-syncs the agent automatically.
Reading calls, bookings & messages
All three are scoped to your businesses. Query params: since (ISO timestamp), businessId (filter to one), limit.
A booking/message is the outcome of a call; callId on it points at the call that produced it, so you can always fetch the conversation behind a result: GET /api/v1/calls/{callId}. (null when the row didn't come from a call.)
GET /api/v1/bookings
curl "https://sesera.ai/api/v1/bookings?since=2026-07-01T00:00:00Z" \
-H "Authorization: Bearer $KEY"{ "bookings": [ {
"id": "…", "businessId": "…", "startsAt": "…", "endsAt": "…",
"customerName": "Ahmet", "customerPhone": "+90…", "serviceName": "Saç kesimi",
"notes": null, "status": "confirmed", "source": "voice",
"callId": "…", "createdAt": "…"
} ] }limit max 200.
GET /api/v1/messages
{ "messages": [ {
"id": "…", "businessId": "…", "body": "Müşteri geri aranmak istiyor.",
"customerName": "Ayşe", "customerPhone": "+90…", "status": "new",
"callId": "…", "createdAt": "…"
} ] }limit max 200.
GET /api/v1/calls — calls with the full conversation
curl "https://sesera.ai/api/v1/calls?since=2026-08-01T00:00:00Z&limit=25" \
-H "Authorization: Bearer $KEY"{ "calls": [ {
"id": "…", "businessId": "…",
"fromPhone": "+905…", // null when the caller hid their number
"startedAt": "2026-08-05T12:16:58.052Z",
"endedAt": "2026-08-05T12:17:42.052Z",
"durationSeconds": 44,
"status": "ai_handled", // ringing | owner_answered | ai_handled | missed | failed
"outcome": null, // callback_scheduled | info_only | no_intent | hangup | spam | null
"transcriptStatus": "available",
"transcript": [
{ "role": "assistant", "text": "Merhaba, hoş geldiniz, burası …" },
{ "role": "customer", "text": "Web sitesi yaptırmak istiyorum." }
],
"transcriptText": "AI: Merhaba…\nMüşteri: Web sitesi yaptırmak istiyorum."
} ] }limitmax 100, default 25 (lower than the other lists — each row carries
a whole conversation). Page backwards with since; rows are newest-first by startedAt.
transcript=0returns metadata only (cheaper for frequent polling); the
transcript fields come back null with transcriptStatus: "not_requested".
transcriptStatusvalues:
| value | meaning |
|---|---|
available | transcript / transcriptText hold the conversation |
withheld_kvkk | the call carries no KVKK disclosure record, so the conversation body is withheld by law/policy — the metadata above is still accurate |
empty | nothing was transcribed (e.g. the caller hung up immediately) |
not_requested | you passed transcript=0 |
roleiscustomer(the caller) orassistant(the AI).
GET /api/v1/calls/{id}
Same object under { "call": … }, always with the transcript. Use it to fetch the conversation behind a booking/message callId. Unknown or foreign id → 404 not_found.
curl "https://sesera.ai/api/v1/calls/1dddffb1-2882-43be-a702-ac85001efebd" \
-H "Authorization: Bearer $KEY"Webhooks (real-time)
If Sesera configures a webhook URL for your account, every new booking/message is POSTed to it as it happens. A business owner may additionally set their own endpoint for their business; that does not replace yours — the event is delivered to both.
Event body:
{
"type": "booking.created", // or "message.created"
"businessId": "…",
"createdAt": "2026-07-03T…",
"data": { … same fields as the read API rows … }
}Signature — header X-Sesera-Signature: t=<unixSeconds>,v0=<hex> where hex = HMAC_SHA256(secret, "<t>.<rawRequestBody>"). Verify it with the signing secret Sesera gives you, and reject timestamps older than ~5 minutes.
const crypto = require("crypto");
function verify(rawBody, header, secret) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (!parts.t || !parts.v0) return false;
// replay window
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
// timingSafeEqual THROWS on a length mismatch — compare lengths first
const a = Buffer.from(expected, "hex"), b = Buffer.from(parts.v0, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Respond 2xx quickly. Delivery retries a few times on failure; use the read API to reconcile anything missed.
Typical integration flow
GET /api/v1/me— confirm your quota + pool.POST /api/v1/businesses— create an agent. If stock is available you get a
live phoneNumber back; if not, status is awaiting_number (no error).
- If
awaiting_number:POST /api/v1/businesses/{id}/assign-numberto bind a
number (retry after Sesera restocks if it returns 409 no_numbers).
POST /api/v1/businesses/{id}/minutes— allocate minutes so it can answer.- Receive
booking.created/message.createdwebhooks (or poll
GET /api/v1/bookings + GET /api/v1/messages). Poll GET /api/v1/calls for the conversations themselves — webhooks fire on outcomes only, never on a call ending.
PATCHto edit,DELETEto release the number.
Rate limit: ~120 requests/minute per key.
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 in the dashboard: Sesera Dev → Numaralar → SIP bağlantısı ayarla. 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.
POST /api/v1/businesses/{id}/calls — ring a number
curl -X POST https://sesera.ai/api/v1/businesses/$BIZ/calls \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"to": "+905321234567",
"firstMessage": "Merhaba, ben {{business_name}} asistanıyım. Kısa bir bilgi vermek için aradım.",
"extraInstructions": "Amaç: yeni kampanyayı anlat, ilgilenirse randevu ver. İlgilenmiyorsa kibarca kapat.",
"metadata": { "campaignId": "eylul-kampanya", "leadId": "42" }
}'| field | type | notes |
|---|---|---|
to | string | required. E.164 (+90…) or a Turkish local form |
trunkId | string | optional while the business has exactly one outbound trunk; required with several |
firstMessage | string | the assistant's opening line. {{business_name}} is substituted. Omit to use the normal greeting — which is usually wrong on an outbound call |
extraInstructions | string | why this call exists. Injected ahead of the stored knowledge; tone, language policy and brevity rules still apply |
metadata | object | echoed back on reads; your campaign/lead ids |
{ "call": { "id": "…", "status": "dialing", "to": "+905321234567", "trunkId": "…" } }202 means the call file reached the switch — not that anybody answered.
GET /api/v1/businesses/{id}/calls — what happened
{ "calls": [ { "id": "…", "to": "+90…", "status": "completed", "callId": "…",
"durationSeconds": 74, "createdAt": "…", "endedAt": "…" } ] }status: queued → dialing → completed / no_answer / failed. callId points at the GET /api/v1/calls/{callId} record with the transcript.
Errors specific to outbound
| HTTP | code | meaning |
|---|---|---|
| 402 | no_plan | the business has no minute budget; SIP Connect refuses unmetered accounts |
| 402 | usage_cap_reached | minutes exhausted |
| 403 | consent_required | the İYS/consent acknowledgement is not ticked on the trunk |
| 403 | blocked_contact | the number is on the business's own block list |
| 409 | no_trunk | no active outbound trunk configured |
| 409 | outside_calling_window | outside the trunk's local calling hours (default 09:00–20:00 Europe/Istanbul) |
| 409 | trunk_inactive / direction_not_allowed | the trunk is off, or set to inbound only |
| 429 | daily_cap_reached / trunk_busy | per-day or simultaneous-call ceiling hit |
| 502 | dial_failed | the switch could not be reached — retry |
An unanswered call costs zero AI minutes: the assistant is only bridged in after the callee picks up.
Compliance. Outbound marketing in Turkey is subject to İYS. The consent, the İYS registration and the KVKK notice belong to the business placing the calls — which is what the acknowledgement records. Sesera enforces the mechanics (calling window, block list, daily cap, per-call audit trail); it does not obtain consent for you.