# Essere Voice API — full documentation for LLMs Generated from https://voice-developers.essere.ai. OpenAPI spec: https://voice-public-api.essere.ai/openapi.json ## Endpoint index - DELETE /v1/webhook-endpoints/{endpoint_id} — Delete a webhook endpoint - GET /v1/agents — List agents - GET /v1/agents/{agent_id} — Retrieve an agent - GET /v1/appointments — List appointments - GET /v1/calls — List calls - GET /v1/calls/{call_id} — Retrieve a call - GET /v1/calls/{call_id}/insights — Retrieve call insights - GET /v1/calls/{call_id}/recording — Stream the call recording - GET /v1/calls/{call_id}/transcript — Retrieve a call transcript - GET /v1/contacts — List contacts - GET /v1/contacts/{contact_id} — Retrieve a contact - GET /v1/numbers — List phone numbers - GET /v1/usage — Retrieve current-period usage - GET /v1/webhook-endpoints — List webhook endpoints - GET /v1/webhook-endpoints/{endpoint_id} — Retrieve a webhook endpoint - GET /v1/webhook-endpoints/{endpoint_id}/deliveries — List webhook deliveries - PATCH /v1/contacts/{contact_id} — Update a contact - PATCH /v1/webhook-endpoints/{endpoint_id} — Update a webhook endpoint - POST /v1/contacts — Create or update a contact - POST /v1/webhook-endpoints — Create a webhook endpoint - POST /v1/webhook-endpoints/{endpoint_id}/ping — Send a test event - POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret — Rotate a webhook signing secret --- # Quickstart From zero to your first API call in about five minutes. ## 1. Create an API key API access is included in the **Pro plan**. In your [Essere dashboard](https://voice-dashboard.essere.ai), go to **Developers → API keys → Create key**, pick a scope (`read`, or `read_write` if you'll create contacts or manage webhooks), and copy the key. Keys are shown **once**. Store it in a secret manager or environment variable — never in code or git. ```bash export ESSERE_API_KEY="" # from the show-once modal ``` ## 2. List your agents ```bash curl https://voice-public-api.essere.ai/v1/agents \ -H "Authorization: Bearer $ESSERE_API_KEY" ``` ```json { "object": "list", "data": [ { "id": "agt_1a2b3c4d5e6f7a8b9c0d1e2f", "name": "Reception Agent", "status": "active", "language": "en", "is_default": true, "created_at": "2026-06-01T09:12:00Z" } ], "has_more": false, "next_cursor": null } ``` ## 3. List recent calls ```bash curl "https://voice-public-api.essere.ai/v1/calls?limit=5&from=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer $ESSERE_API_KEY" ``` Each call includes `summary`, `sentiment`, `outcome`, and `has_recording`. Keep the `id` (`call_...`) of one call for the next step. ## 4. Fetch a transcript ```bash curl https://voice-public-api.essere.ai/v1/calls/call_9f8e7d6c5b4a3f2e1d0c9b8a/transcript \ -H "Authorization: Bearer $ESSERE_API_KEY" ``` ```json { "call_id": "call_9f8e7d6c5b4a3f2e1d0c9b8a", "turns": [ {"role": "agent", "text": "Hi, you've reached Horizon Estates. How can I help?"}, {"role": "caller", "text": "I'm calling about the two-bedroom on Makarios Avenue."} ] } ``` To download the audio instead, `GET /v1/calls/{id}/recording` streams the file bytes directly (`audio/mpeg`). ## 5. Create a contact (read_write scope) Contacts feed your agent's memory: on the next call from that number, the agent already knows who's calling. ```bash curl -X POST https://voice-public-api.essere.ai/v1/contacts \ -H "Authorization: Bearer $ESSERE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-contact-001" \ -d '{ "phone": "+35799123456", "name": "Maria Georgiou", "notes": "Interested in 2-bedroom apartments, budget €250k.", "fields": {"source": "website-form"} }' ``` Posting the same `phone` again updates the existing contact (upsert). ## Next steps - [Webhooks](/webhooks) — get `call.completed` events pushed to you instead of polling. - [Errors & Pagination](/errors-and-pagination) — the two conventions every integration needs. - [MCP Server](/mcp) — plug your account straight into an AI assistant. - [API Reference](/reference) — every endpoint, schema, and example. --- # Authentication Every request carries an API key in the `Authorization` header: ``` Authorization: Bearer $ESSERE_API_KEY ``` ## Keys - Created in the dashboard: **Developers → API keys** (Pro plan). - `vk_live_...` keys work against production; `vk_test_...` keys are issued on staging accounts. - Keys are shown **once** at creation. We store only a hash — if a key is lost, revoke it and create a new one. - Keys are account-level: they see every agent on your account. ## Scopes | Scope | Allows | |-------|--------| | `read` | All `GET` endpoints. | | `read_write` | Everything in `read`, plus creating/updating contacts and managing webhook endpoints. | A `read` key calling a write endpoint gets `403` with code `insufficient_scope`. ## Plan requirement The API is a Pro-plan feature, checked at key creation **and** on every request. If the account leaves Pro, requests fail with `403` and code `plan_required` until the plan is upgraded again — keys are not deleted. ## Rate limits 120 requests/minute per key, as a fixed one-minute window (a burst across a window boundary can briefly exceed the nominal rate — throttle to the limit, not the boundary behavior). Every response includes: ``` X-RateLimit-Limit: 120 X-RateLimit-Remaining: 118 X-RateLimit-Reset: 1752841260 ``` On `429`, back off for `Retry-After` seconds. Steady-state polling should stay well under the limit — prefer [webhooks](/webhooks) over polling. ## Key hygiene - Keep keys server-side. Never ship them in browser or mobile code. - **Never paste a live key into an AI prompt, chat, or shared document, and never commit one to source control** — use environment variables or a secret store, and reference them in code (`$ESSERE_API_KEY`). - One key per integration, named accordingly — revoke independently. - Rotate by creating the new key first, deploying it, then revoking the old. ## Data processing Anything you connect through this API — webhook receivers, Zapier or n8n workflows, and any LLM/MCP client — becomes a third-party processor of your call and contact data once you send data to it. Choose processors that meet your own privacy obligations, propagate deletion requests to them, and revoke the API key to cut off access at any time. --- # Errors & Pagination ## Error envelope All errors return JSON: ```json { "error": { "type": "permission_error", "code": "plan_required", "message": "The API is available on the Pro plan. Upgrade in your dashboard to continue.", "param": null } } ``` `code` is stable and machine-readable — branch on it, not on `message`. | HTTP | `type` | `code` | What to do | |------|--------|--------|------------| | 400 | `invalid_request_error` | `invalid_request` | Fix the field named in `param`. | | 400 | `invalid_request_error` | `invalid_cursor` | Restart pagination from the first page. | | 401 | `authentication_error` | `invalid_api_key` | Check the header format and that the key isn't revoked. | | 403 | `permission_error` | `plan_required` | Upgrade the account to Pro. | | 403 | `permission_error` | `insufficient_scope` | Use a `read_write` key. | | 404 | `not_found_error` | `resource_not_found` | The ID doesn't exist on this account. | | 409 | `idempotency_error` | `idempotency_conflict` | Same `Idempotency-Key` reused with a different body — use a fresh key. | | 429 | `rate_limit_error` | `rate_limit_exceeded` | Wait `Retry-After` seconds, then retry. | | 5xx | `api_error` | `internal_error` | Retry with exponential backoff. | ## Pagination List endpoints take `limit` (default 25, max 100) and an opaque `cursor`: ```json { "object": "list", "data": [ ... ], "has_more": true, "next_cursor": "eyJzIjoiMjAyNi0wNy0xN1QxNDowMzoyMloifQ" } ``` Loop until `has_more` is `false`: ```python import os import requests BASE = "https://voice-public-api.essere.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['ESSERE_API_KEY']}"} def iter_all(path, **params): cursor = None while True: r = requests.get(f"{BASE}{path}", headers=HEADERS, params={**params, "limit": 100, "cursor": cursor}) r.raise_for_status() page = r.json() yield from page["data"] if not page["has_more"]: return cursor = page["next_cursor"] for call in iter_all("/calls", status="completed"): print(call["id"], call["outcome"]) ``` Never parse or construct cursors yourself — treat them as opaque tokens. ## Idempotency `POST` and `PATCH` accept an `Idempotency-Key` header (any unique string, e.g. a UUID). Within 24 hours, retrying with the same key returns the original response with the header `Idempotent-Replayed: true` instead of performing the operation twice. Always send one on writes you might retry. --- # Webhooks Get events pushed to your server instead of polling. ## Events | Type | Fires when | |------|-----------| | `call.completed` | A call ends and its transcript, summary, and insights are ready. | | `call.transferred` | A call was transferred to a human. | | `appointment.booked` | An agent booked an appointment. | | `appointment.cancelled` | An appointment was cancelled. | | `lead.captured` | The agent captured a lead during a call. | | `usage.threshold_reached` | The account hit 80% or 100% of its included minutes. | Payload envelope — `data` is the same schema the REST API returns for the resource: ```json { "id": "evt_3c9d8e7f6a5b4c3d2e1f0a9b", "type": "call.completed", "created": 1752841265, "data": { "id": "call_9f8e7d6c5b4a3f2e1d0c9b8a", "agent_id": "agt_1a2b3c4d5e6f7a8b9c0d1e2f", "status": "completed", "summary": "Caller asked about availability; agent booked a viewing.", "outcome": "booked" } } ``` ## Subscribe Via the dashboard (**Developers → Webhooks**) or the API (`read_write` scope): ```bash curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints \ -H "Authorization: Bearer $ESSERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/essere/webhook", "events": ["call.completed", "appointment.booked"], "description": "CRM sync" }' ``` The response includes the signing `secret` (`whsec_...`) — shown **once**. An empty `events` array subscribes to all event types. Send a test event any time: ```bash curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints/we_5a4b3c2d1e0f9a8b7c6d5e4f/ping \ -H "Authorization: Bearer $ESSERE_API_KEY" ``` ## Verify signatures Every delivery is signed: ``` X-Essere-Signature: t=1752841265,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` `v1` is the hex HMAC-SHA256 of `"{t}.{raw request body}"` with your `whsec_` secret. Verify **before** trusting the payload, and reject timestamps older than 5 minutes (replay protection). Compute over the RAW bytes — do not re-serialize the JSON. During a [secret rotation](#secret-rotation) the header carries **two** `v1` entries (`t=..,v1=,v1=`). Your verifier must accept the delivery if **any** `v1` matches — the snippets below already do. ### Python (Flask example) ```python import hashlib import hmac import os import time from flask import Flask, abort, request app = Flask(__name__) WEBHOOK_SECRET = os.environ["ESSERE_WEBHOOK_SECRET"] # from endpoint creation def verify_essere_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool: t = None v1s: list[str] = [] for part in header.split(","): key, _, value = part.partition("=") if key == "t": try: t = int(value) except ValueError: return False elif key == "v1": v1s.append(value) # rotation overlap: there may be TWO v1 entries if t is None or not v1s: return False if abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v1) for v1 in v1s) @app.post("/essere/webhook") def essere_webhook(): sig = request.headers.get("X-Essere-Signature", "") if not verify_essere_signature(request.get_data(), sig, WEBHOOK_SECRET): abort(400) event = request.get_json() if event["type"] == "call.completed": print("call finished:", event["data"]["id"], event["data"]["outcome"]) return "", 200 ``` ### Node (Express example) ```js const crypto = require('node:crypto') const express = require('express') const app = express() const WEBHOOK_SECRET = process.env.ESSERE_WEBHOOK_SECRET // from endpoint creation function verifyEssereSignature(rawBody, header, secret, toleranceSeconds = 300) { let t = null const v1s = [] // rotation overlap: the header may carry TWO v1 entries for (const part of header.split(',')) { const i = part.indexOf('=') const key = part.slice(0, i) const value = part.slice(i + 1) if (key === 't') t = Number(value) else if (key === 'v1') v1s.push(value) } if (!t || v1s.length === 0) return false if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false const expected = crypto.createHmac('sha256', secret) .update(`${t}.`).update(rawBody).digest('hex') const a = Buffer.from(expected) return v1s.some((v1) => { const b = Buffer.from(v1) return a.length === b.length && crypto.timingSafeEqual(a, b) }) } // express.raw keeps the exact bytes — required for signature verification app.post('/essere/webhook', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.get('X-Essere-Signature') || '' if (!verifyEssereSignature(req.body, sig, WEBHOOK_SECRET)) { return res.status(400).end() } const event = JSON.parse(req.body) if (event.type === 'call.completed') { console.log('call finished:', event.data.id, event.data.outcome) } res.status(200).end() }) app.listen(3000) ``` ## Secret rotation Rotate an endpoint's signing secret any time (dashboard **Developers → Webhooks → Rotate secret**, or `POST /v1/webhook-endpoints/{id}/rotate-secret`). The response shows the NEW `whsec_` secret **once**. For the next **24 hours** every delivery is signed with both secrets — the header carries two `v1` entries — so you can deploy the new secret without dropping verifications. After the overlap, only the new secret signs. ## Delivery behavior - **At-least-once**: rarely, you may see the same `id` twice — deduplicate on `event.id`; make your handler idempotent. - **Ordering is not guaranteed**: retries and parallel delivery mean events can arrive out of order — order by the envelope's `created` timestamp (or refetch the resource), never by arrival time. - **Success** = any 2xx response within 10 seconds. Respond fast; do slow work async. Redirects are **not** followed — a 3xx counts as a failure. - **Retries**: failed deliveries retry with backoff (± jitter) — about 8 attempts spread over 24 hours. A `Retry-After` header on your response is honored (up to 1 hour). Endpoints answering 404/410/401/403 stop being retried after 3 attempts. - **Auto-disable**: after sustained failure across consecutive events, the endpoint is disabled and you're notified by email. Re-enable it in the dashboard once fixed; missed events can be redelivered from the delivery log. - **HTTPS only**: endpoint URLs must be public HTTPS URLs. - Inspect deliveries: `GET /v1/webhook-endpoints/{id}/deliveries` or the dashboard delivery log (delivery metadata only — payloads are not echoed back through the log). --- # Zapier Connect Essere events to 6,000+ apps with Zapier's built-in **Webhooks by Zapier** module — no custom app required. ## Trigger a Zap from call events 1. Create a Zap. For the trigger, choose **Webhooks by Zapier → Catch Hook**. 2. Copy the generated hook URL (`https://hooks.zapier.com/hooks/catch/...`). 3. Register it as an endpoint (or add it in **Developers → Webhooks** in the dashboard): ```bash curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints \ -H "Authorization: Bearer $ESSERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "events": ["call.completed"], "description": "Zapier"}' ``` 4. Send a test event so Zapier can learn the shape: ```bash curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints/we_5a4b3c2d1e0f9a8b7c6d5e4f/ping \ -H "Authorization: Bearer $ESSERE_API_KEY" ``` 5. In Zapier, click **Test trigger** — the ping appears; real events have `type` `call.completed` with the call under `data` (`data.summary`, `data.outcome`, `data.from_number`, ...). Map those fields into your action app (CRM, Sheets, Slack, ...). Tip: add a Zapier **Filter** step on `type` if you subscribe one endpoint to multiple event types. > Catch Hook can't verify our HMAC signature. The hook URL is secret and > HTTPS-only, which is fine for most workflows; if you need verification, > add a **Code by Zapier** step implementing the check from the > [webhooks guide](/webhooks), or receive events on your own server. ## Call the API from a Zap Use **Webhooks by Zapier → Custom Request** as an action — e.g. create a contact when a row lands in a Google Sheet: - **Method:** `POST` - **URL:** `https://voice-public-api.essere.ai/v1/contacts` - **Data:** `{"phone": "{{phone}}", "name": "{{name}}", "notes": "{{notes}}"}` - **Headers:** `Authorization: Bearer ` (paste from your secret store) and `Content-Type: application/json` The agent will greet that caller by name on their next call. --- # n8n Use n8n's built-in **Webhook** and **HTTP Request** nodes — no community node needed. ## Receive events 1. Add a **Webhook** node: method `POST`, path e.g. `essere-events`. Copy the production URL (`https://your-n8n.example.com/webhook/essere-events`). 2. Register it: ```bash curl -X POST https://voice-public-api.essere.ai/v1/webhook-endpoints \ -H "Authorization: Bearer $ESSERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-n8n.example.com/webhook/essere-events", "events": ["call.completed", "lead.captured"], "description": "n8n"}' ``` Save the returned `secret` (`whsec_...`) as an n8n credential/variable. 3. (Recommended) Verify the signature in a **Code** node right after the webhook. Set the Webhook node's **Raw body** option ON, then: ```javascript // n8n Code node (Run Once for Each Item) const crypto = require('crypto'); const secret = $env.ESSERE_WEBHOOK_SECRET; // whsec_... const header = $json.headers['x-essere-signature'] || ''; const raw = Buffer.from($json.body, 'base64'); // raw body (base64 when Raw Body is on) let t = null; const v1s = []; // rotation overlap: the header may carry TWO v1 entries for (const part of header.split(',')) { const i = part.indexOf('='); if (part.slice(0, i) === 't') t = Number(part.slice(i + 1)); else if (part.slice(0, i) === 'v1') v1s.push(part.slice(i + 1)); } if (!t || Math.abs(Date.now() / 1000 - t) > 300) { throw new Error('stale or missing signature timestamp'); } const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(raw).digest('hex'); const ok = v1s.some(v1 => { const b = Buffer.from(v1); return b.length === Buffer.from(expected).length && crypto.timingSafeEqual(Buffer.from(expected), b); }); if (!ok) throw new Error('bad signature'); return { json: JSON.parse(raw.toString()) }; ``` 4. Branch on `{{$json.type}}` (e.g. a **Switch** node) and route `call.completed` events into your CRM, Slack, database, etc. ## Call the API Add an **HTTP Request** node: - **Authentication:** Generic → **Header Auth**; create a credential with name `Authorization` and value `Bearer `. - **List calls:** GET `https://voice-public-api.essere.ai/v1/calls?limit=50` - **Fetch a transcript:** GET `https://voice-public-api.essere.ai/v1/calls/{{$json.data.id}}/transcript` - **Create a contact:** POST `https://voice-public-api.essere.ai/v1/contacts` with JSON body `{"phone": "{{$json.phone}}", "name": "{{$json.name}}"}` For list endpoints, enable the node's **Pagination** option: type "Response contains next URL"? No — use *Update a parameter in each request* with parameter `cursor` set from `{{$response.body.next_cursor}}`, stopping when `{{$response.body.has_more}}` is false. --- # MCP Server The Essere Voice API ships a built-in [MCP](https://modelcontextprotocol.io) server, so AI assistants and agent frameworks can work with your account directly — no glue code. - **Endpoint:** `https://voice-public-api.essere.ai/mcp` - **Transport:** Streamable HTTP - **Auth:** the same API keys as the REST API, sent as `Authorization: Bearer $ESSERE_API_KEY` ## Available tools | Tool | Needs scope | |------|-------------| | `list_calls`, `get_call`, `get_call_transcript`, `get_call_insights` | read | | `list_appointments` | read | | `list_agents`, `list_numbers` | read | | `get_usage` | read | | `list_contacts` | read | | `create_contact`, `update_contact` | read_write | ## Which clients work today The server authenticates with a **Bearer API key in a custom header**. That means it works TODAY with any MCP client that lets you configure request headers: - **Claude Code** (`--header`) - **Cursor** (mcp.json `headers`) - **VS Code** (mcp.json `headers`) - any other client with custom-header support (generic config below) **Not yet supported:** the claude.ai and ChatGPT web **connectors** — both require OAuth-based MCP authorization, which this server does not implement yet. OAuth support is on the roadmap; until then use a header-capable client. > **Create a read-scope key for AI assistants.** Least privilege: a `read` > key lets the assistant analyze calls, transcripts, and appointments but can > never modify your data. Grant `read_write` only if you specifically want > contact-sync through the assistant. ⚠️ **Never paste a live API key into an AI chat or prompt.** Configure the key in the client's server settings (it is sent as a request header) — do not type it into the conversation itself, and never commit it to source control. ## Connect from Claude Code ```bash claude mcp add --transport http essere-voice https://voice-public-api.essere.ai/mcp \ --header "Authorization: Bearer $ESSERE_API_KEY" ``` ## Connect from Cursor / VS Code Both accept an `mcp.json` with custom headers — use the generic config below (Cursor: `.cursor/mcp.json`; VS Code: `.vscode/mcp.json`). ## Generic JSON config Most header-capable MCP clients accept a config like this (replace `` from your secret store — never hard-code it in a committed file): ```json { "mcpServers": { "essere-voice": { "type": "http", "url": "https://voice-public-api.essere.ai/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ## Notes - Rate limits are shared with your REST usage (120 req/min per key, fixed window). - Tool results use the same schemas and IDs as the REST API — an assistant can hand off `call_...` IDs to your own code seamlessly. - Prefer a `read` key unless the assistant must create or update contacts. - Any LLM client you connect processes your call and contact data — see the Data processing note in the auth guide. Revoke the key to cut access. - Try it: connect, then ask your assistant *"Summarize yesterday's calls and list any captured leads."*