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>" }.

HTTPcodemeaning
400bad_jsonbody was not valid JSON
400validation_errorinvalid fields (see details[])
400invalid_voicevoiceId not in GET /voices
401unauthorizedmissing / invalid / revoked key
402insufficient_poolnot enough minutes in your pool to allocate
403reseller_suspendedyour account is suspended
403quota_reachednumber cap reached — delete one or ask Sesera to raise it
404not_foundbusiness does not exist (or isn't yours)
409no_numberspool empty at assign-number time — retry after Sesera restocks (create never returns this)
429rate_limitedslow down (see Retry-After)
502agent_sync_failedupstream 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):

fieldtypenotes
namestringrequired, 2–120 chars
categorystringe.g. "kuafor", "klinik", "oto" (drives safety guardrails)
hoursobjecte.g. { "mon": { "closed": false, "intervals": [["09:00","18:00"]] }, … }
servicesarray[{ "name": "Saç kesimi", "price": "250 TL", "durationMinutes": 30 }]
faqarray[{ "q": "Otopark var mı?", "a": "Evet, ücretsiz." }]
freeTextstringfree-form extra knowledge
extraInstructionsstringtone/behaviour instructions
voiceIdstringfrom GET /voices (default applied if omitted)
voiceSpeednumber0.5–1.5
bookingsEnabledbooleanturn on appointment booking tools
bookingLeadMinutesintmin advance notice (default 60)
bookingMaxDaysAheadintmax days ahead (default 30)
enabledLanguagesstring[]extra ISO-639-1 codes the agent may switch to
maxCallMinutesintper-call cap; 0 = unlimited
toolsarraycustom tools the agent can call against your API — see Custom tools

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

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

201 Created:

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

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

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

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

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

GET /api/v1/businesses — list yours

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

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

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

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

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

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

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

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

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"
    }]
  }'
fieldtypenotes
namestringfunction name the AI calls; ^[a-z][a-z0-9_]{1,39}$, can't shadow a built-in
descriptionstringwhen to use it + what it returns — the AI reads this to decide
urlstringyour endpoint; absolute https (internal/private hosts rejected)
methodPOST\GETdefault POST
parametersobjectJSON-Schema (type:"object", properties, required) the AI fills
responseTimeoutSecsint3–20, default 15 — it's on the live call, keep your endpoint fast
responsePathstringJSON key in your response to hand back to the AI (e.g. result); omit = whole body
enabledbooleandefault true; false keeps the definition but turns it off
  • Request — Sesera POSTs { "name": "<tool name>", "arguments": { …the AI's args… }, "conversation_id": "<uuid>" } (or a GET with the args as query params). Respond with JSON quickly. (This doc previously described a parameters key and a caller field; the live agent sends neither. Read arguments.)
  • Auth — every tool call carries Authorization: Bearer <your webhook signing secret> (the same secret shown in Ayarlar → Geliştirici → Webhook). Verify it and reject anything else.
  • Replace semanticstools sets 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."
} ] }
  • limit max 100, default 25 (lower than the other lists — each row carries

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

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

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

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

GET /api/v1/calls/{id}

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

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

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

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

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

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

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

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

GET /api/v1/bookings + GET /api/v1/messages). Poll GET /api/v1/calls for the conversations themselves — webhooks fire on outcomes only, never on a call ending.

  1. PATCH to edit, DELETE to 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" }
  }'
fieldtypenotes
tostringrequired. E.164 (+90…) or a Turkish local form
trunkIdstringoptional while the business has exactly one outbound trunk; required with several
firstMessagestringthe assistant's opening line. {{business_name}} is substituted. Omit to use the normal greeting — which is usually wrong on an outbound call
extraInstructionsstringwhy this call exists. Injected ahead of the stored knowledge; tone, language policy and brevity rules still apply
metadataobjectechoed back on reads; your campaign/lead ids
{ "call": { "id": "…", "status": "dialing", "to": "+905321234567", "trunkId": "…" } }

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

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

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

status: queueddialingcompleted / no_answer / failed. callId points at the GET /api/v1/calls/{callId} record with the transcript.

Errors specific to outbound

HTTPcodemeaning
402no_planthe business has no minute budget; SIP Connect refuses unmetered accounts
402usage_cap_reachedminutes exhausted
403consent_requiredthe İYS/consent acknowledgement is not ticked on the trunk
403blocked_contactthe number is on the business's own block list
409no_trunkno active outbound trunk configured
409outside_calling_windowoutside the trunk's local calling hours (default 09:00–20:00 Europe/Istanbul)
409trunk_inactive / direction_not_allowedthe trunk is off, or set to inbound only
429daily_cap_reached / trunk_busyper-day or simultaneous-call ceiling hit
502dial_failedthe switch could not be reached — retry

An unanswered call costs zero AI minutes: the assistant is only bridged in after the callee picks up.

Compliance. Outbound marketing in Turkey is subject to İYS. The consent, the İYS registration and the KVKK notice belong to the business placing the calls — which is what the acknowledgement records. Sesera enforces the mechanics (calling window, block list, daily cap, per-call audit trail); it does not obtain consent for you.