LeroDocs

Lero API

Speech with a Brazilian accent in your product: Text-to-Speech (TTS) in MP3, Speech-to-Text (STT) for spoken Brazilian Portuguese, with timestamps for each sentence and ready subtitles, and a live Conversational Agent over WebRTC. Voice Clone is coming soon. The API is on the Creator and Dev plans: TTS uses 1 credit per character and STT, 150 credits per minute of audio.

Direct integration

Use Lero in Claude, Codex or Cursor without writing code. Create a key at app.lero.studio/desenvolvedores and run your agent's command, with your key in place of lero_your_key:

claude mcp add --env LERO_API_KEY=lero_your_key lero -- npx -y lero-mcp

In Cursor, the JSON goes in ~/.cursor/mcp.json. In Claude Desktop, in Settings > Developer > Edit Config. Needs Node 18 or newer.

Done: ask for audio in the chat, for example make an audio in the Rio voice saying "bom dia, tudo certo?", and the agent saves the MP3 in ~/Downloads/Lero. More about the MCP in Build with AI.

To put Lero in your product's code, paste the integration prompt into your coding agent, or follow the quickstart below.

  1. Create a key at app.lero.studio/desenvolvedores. The API is on the Creator and Dev plans.
  2. Keep the key on your server, in the LERO_API_KEY variable.
  3. Ask for your first speech:
curl -X POST "https://app.lero.studio/api/v1/tts" \
  -H "Authorization: Bearer $LERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"texto": "Fala, tudo bem? Aqui é o Lero, direto do Rio.", "voz": "rio"}' \
  --output fala.mp3

Base URL for every route: https://app.lero.studio/api/v1. Requests and responses are JSON (UTF-8), except the audio: speech returns MP3 and transcription takes the file as multipart/form-data.

Route names are in English: /v1/tts, /v1/stt, /v1/voices, /v1/balance, /v1/agents and /v1/agents/conversation. The old Portuguese paths (/v1/fala, /v1/transcricao, /v1/vozes, /v1/saldo, /v1/agentes and /v1/agentes/conversa) keep working the same way.

Every route needs the key in the Authorization: Bearer <your key> header (or in the X-Api-Key header). The key starts with lero_ and shows in full only once, when you create it. After that, the app shows only the last 4 characters.

  • Use the key only on your server. Never in the browser, in a mobile app or in a repository: whoever has the key spends your credits.
  • Each key can have a name, a monthly credit cap and an expiry (1, 7, 30 or 90 days, or never).
  • Leaked? Revoke the key in the app. It stops at once, and you create another one.
  • Up to 10 active keys per account.

Every error comes back as JSON, with a stable code in the erro field and a message in Portuguese. Handle errors by erro, never by the mensagem text. Speech and transcription errors also carry pedido and, when a field is wrong, campo.

JSON
{"erro": "sem_credito", "mensagem": "Os créditos da conta acabaram.", "pedido": "3f9c1e0a7b2d4c6e8a1b3d5f7e9c0a2b"}
statuserrowhen
400texto_invalidoThe text is empty, longer than 500 characters, or the voice did not accept it (emojis and symbols).
400voz_invalidaUnknown voice. Use rio, sp or niteroi.
400audio_invalidoTranscription: the file is empty, has no readable audio, or the format is not supported.
400url_invalidaTranscription: url is not a public https link, did not answer 200, or could not be downloaded.
400formato_invalidoTranscription: formato is not json, texto, srt or vtt.
400idioma_invalidoTranscription: idioma is not pt-BR.
400modelo_invalidomodel is not Lero v1.
400pedido_invalidoThe body is not JSON. On transcription: the body is neither multipart nor JSON, or it has neither audio nor url, or both.
401sem_chaveNo key in the request.
401chave_invalidaThe key is wrong or was revoked.
401chave_vencidaThe key expired.
402sem_creditoThe account credits do not cover the request. On transcription, the JSON carries custo (the credits the audio needs) and saldo.
402teto_da_chaveThe key reached its monthly credit cap.
403planoThe account is not on a plan with the API (Creator or Dev).
403escopoThe key does not have the Fala scope (it covers speech and transcription).
403bloqueadoThe account is blocked.
413audio_grandeTranscription: the file in the body is over 4 MB (send it by url) or the one at url is over 500 MB.
413audio_longoTranscription: the audio is longer than 2 h.
429muitos_pedidosMore than 60 requests per minute on the account. Wait for Retry-After.
429simultaneasThe plan is already at its limit of concurrent requests (Creator 5, Dev 10). Wait for one to finish.
429ocupadoThe voice or the transcription is busy. Wait for Retry-After.
503carregandoThe voice is starting up. Wait for Retry-After.
503motor_desligadoThe voice or the transcription is offline. Wait for Retry-After.
502falhouSpeech generation or transcription failed. Try again.
504demorouSpeech generation or transcription timed out. Try again.
500internoAn error on our side. Try again; if it persists, send us the X-Pedido-Id.
400oferta_invalidaThe SDP offer is missing, does not start with v=0, or is longer than 20,000 characters.
400versao_invalidaversao is not in the GET /v1/agents list for your key.
400llm_invalidollm is not in the GET /v1/agents list.
400roteiro_invalidoA custom agent field is over its limit. The field name comes in campo.
502sem_botThe agent did not answer the offer. Try again.
503desligadoThe agents are offline now. Wait for Retry-After.

When speech generation fails after the charge, Lero refunds the credits at once: the cobrado field is 0 and the message ends with "Nada foi cobrado." ("Nothing was charged."). Transcription charges only on success: a transcription error never spends credits.

Credits and limits

Text-to-Speech (TTS)

  • 1 credit per character of the text, charged before generating and refunded if generation fails.
  • Up to 500 characters per request. For longer text, split it into sentences and join the MP3s.
  • Each request generates a new audio. Keep the MP3 if you will use it again.

Speech-to-Text (STT)

  • 150 credits per minute of audio (2.5 per second), billed by the second and rounded up, only when the transcription succeeds. The whole file counts, silence included.
  • Up to 2 h of audio per request. A file in the body can be up to 4 MB; by url, up to 500 MB.
  • About 30 times real time: 1 h of audio comes back in about 2 min. Use a 300 s timeout.
  • Lero does not keep the audio or the transcript. Keep the response if you will use it again.

The account

  • Up to 60 requests per minute per account, across all keys and routes.
  • Concurrent requests (speech and transcription): 5 on Creator and 10 on Dev. Above that, 429 simultaneas.
  • Live conversation uses 1,000 credits per minute of call, charged per second at the end; starting a call needs at least 1 minute of balance.
  • The plan allowance arrives on the first use of each month (Brasília time). Unused credits roll over up to 2 times the plan quota; anything above that expires at the turn of the month.
  • Extra credits, Dev only, at the plan rate: valid for 6 months.

Text-to-Speech (TTS)

Text to speech, in MP3, with a Brazilian accent.

Generate speech

POST/v1/tts

Turns the text into speech and returns the MP3 (audio/mpeg). Costs 1 credit per character of the text, charged before generating and refunded if generation fails.

Prefer JSON? Send Accept: application/json (without */*) and the audio comes as base64 in the audio field.

Authentication: key with the Fala (speech) scope.

Request body application/json

textostringrequired

What Lero will say, 1 to 500 characters. Leading and trailing spaces do not count.

vozstringrequired

The voice: rio (Rio de Janeiro), sp (São Paulo) or niteroi (Niterói).

modelstringoptional

Lero v1, the only speech model for now.

Response headers

X-Pedido-Idstring

The request id. Send it if you need support.

X-Creditos-Custointeger

The credits charged for this request.

X-Creditos-Restantesinteger

The account balance after the request.

X-Duracaonumber

The audio duration, in seconds.

Responses

statusmeaning
200The speech MP3, with the headers above. With Accept: application/json, the JSON on the side.
400texto_invalido
400voz_invalida
400modelo_invalido
400pedido_invalido
401sem_chave
401chave_invalida
401chave_vencida
402sem_credito
402teto_da_chave
403plano
403escopo
403bloqueado
429muitos_pedidos
429simultaneas
429ocupado
503carregando
503motor_desligado
502falhou
504demorou
500interno

What each error means: Errors.

The Lero voices and their accents.

GET/v1/voices

The list of voices, with id and name. Free.

Authentication: key.

Responses

statusmeaning
200The voices.
401sem_chave
401chave_invalida
401chave_vencida
403plano
403bloqueado

What each error means: Errors.

Speech-to-Text (STT)

Audio to text, in spoken Brazilian Portuguese (slang, acronyms and numbers), with timestamps for each sentence and ready subtitles.

Transcribe audio

POST/v1/stt

Turns audio into text, in spoken Brazilian Portuguese: slang, acronyms, numbers and names come out right. Returns the text with paragraphs and the sentences with start and end times; on request, the time of each word or ready subtitles (SRT or VTT).

Send the file in the audio field as multipart/form-data (up to 4 MB), or a public https link in url (up to 500 MB; a signed S3, GCS or R2 link works). Files over 4 MB go by url, in a JSON body with the same options. Formats: MP3, WAV, M4A, OGG, OPUS, WEBM, FLAC and MP4 video (Lero uses the video's audio).

Costs 150 credits per minute of audio (2.5 per second), billed by the second and rounded up, only when the transcription succeeds: if it fails, nothing is charged. The whole file duration counts, silence included. Example: 11 min of audio cost 1,650 credits.

Up to 2 h of audio per request. Transcription runs at about 30 times real time (1 h of audio in about 2 min): use a 300 s timeout. Lero does not keep the audio or the transcript: it deletes both when it responds.

formatoresponse
json (default)JSON with the text, the sentences and the cost (on the side)
textotext/plain: just the text, with paragraphs
srtapplication/x-subrip: SRT subtitles
vtttext/vtt: WebVTT subtitles

In the JSON, texto has paragraphs (\n\n) at long pauses and segmentos has the sentences, with inicio and fim in seconds. With timestamps=palavra, each sentence also carries palavras: [{inicio, fim, palavra}]. formatado is true when the punctuation and capitalization pass covered the whole text. The texto, srt and vtt formats come in UTF-8.

Authentication: key with the Fala scope (the same as speech).

Request body multipart/form-data · application/json

audiofileone of two

The audio file, up to 4 MB: MP3, WAV, M4A, OGG, OPUS, WEBM, FLAC or MP4 video. Send audio or url, never both.

urlstringone of two

Public https link to the audio, up to 500 MB; Lero downloads it. For files over 4 MB, use url (a signed S3, GCS, R2 etc. link works).

formatostringoptional

json (default), texto (text/plain), srt or vtt (ready subtitles).

timestampsstringoptional

frase (sentence, default) or palavra (word): each segment gets palavras, with the start and end of each word. Only with json.

palavras_chavestring[]optional

Keyterms: up to 50 names, brands, acronyms and terms to recognize and keep spelled as given, e.g. ["Lero", "Unflat", "CNPJ"]. In multipart, as a comma-separated list: Lero,Unflat,CNPJ.

formatarbooleanoptional

The punctuation and capitalization pass, default true. With false, it returns faster, with the recognizer's punctuation; same price.

idiomastringoptional

pt-BR, the only one for now.

modelstringoptional

Lero v1, the only transcription model for now.

Response headers

X-Pedido-Idstring

The request id. Send it if you need support.

X-Creditos-Custointeger

The credits charged for this request.

X-Creditos-Restantesinteger

The account balance after the request.

X-Duracaonumber

The audio duration, in seconds.

Responses

statusmeaning
200The transcription: the JSON on the side (formato=json) or the file itself (texto, srt, vtt), with the headers above.
400audio_invalido
400url_invalida
400formato_invalido
400idioma_invalido
400modelo_invalido
400pedido_invalido
401sem_chave
401chave_invalida
401chave_vencida
402sem_credito
402teto_da_chave
403plano
403escopo
403bloqueado
413audio_grande
413audio_longo
429muitos_pedidos
429simultaneas
429ocupado
503motor_desligado
502falhou
504demorou
500interno

What each error means: Errors.

Coming soon. Your own voice from recordings of the voice owner, with their consent.

Conversational Agent

Real-time voice agent over WebRTC, with your system prompt, knowledge base and live context. In beta.

List versions and LLMs

GET/v1/agents

Conversational Agent discovery: the agent versions your key can use, the LLMs, the voices and the recommended setup. Call it before opening a call: the list changes. Free.

Authentication: key.

Responses

statusmeaning
200The current options.
401sem_chave
401chave_invalida
401chave_vencida

What each error means: Errors.

Open a call (WebRTC)

POST/v1/agents/conversation

Live call signaling: you send the SDP offer of your WebRTC client and get the SDP answer. After that, audio and the chat data channel flow peer-to-peer between your client and the agent (streaming STT, LLM and TTS, with barge-in and turn detection). Without versao, llm and voz, the recommended setup applies. With instrucoes, the agent becomes yours: the system prompt replaces the Lero persona. Step by step in Conversational Agent: step by step.

Authentication: key.

Request body application/json

typestringrequired

Always offer.

sdpstringrequired

The full SDP offer, with complete ICE (non-trickle), up to 20,000 characters.

versaostringoptional

The agent version, an id from GET /v1/agents. Recommended: v0.4.

llmstringoptional

The LLM, an id from GET /v1/agents. Recommended: oss120b.

vozstringoptional

rio, sp or niteroi. Ignored on single-voice versions (vozes: false).

instrucoesstringoptional

System prompt, up to 30,000 characters. Replaces the Lero persona; voice rules (no markdown, code or URLs in speech) are appended.

conhecimentostring | arrayoptional

Knowledge base (RAG): text or [{titulo, texto}], up to 50 items and 60,000 characters. The agent consults it and never reads it aloud.

aberturastringoptional

First message: how the agent opens the call. An instruction, not the literal sentence.

palavrasstring[]optional

STT keywords: up to 40 names and terms to recognize.

silencio_sfalse | number[]optional

Silence prompts. Off with instrucoes; [20, 40, 60] turns them on (2 nudges, then goodbye).

pesquisabooleanoptional

Web search tool. Off with instrucoes.

max_tokensintegeroptional

Max length of each reply, 60 to 1000 (default 260).

Responses

statusmeaning
200The SDP answer: apply it with setRemoteDescription.
400voz_invalida
400pedido_invalido
401sem_chave
401chave_invalida
401chave_vencida
400oferta_invalida
400versao_invalida
400llm_invalido
400roteiro_invalido
502sem_bot
503desligado

What each error means: Errors.

The account credits.

GET/v1/balance

The account credits right now, with this month's allowance, and the plan. Free.

Authentication: key.

Response headers

X-Creditos-Restantesinteger

The account balance.

Responses

statusmeaning
200The balance and the plan.
401sem_chave
401chave_invalida
401chave_vencida
403plano
403bloqueado

What each error means: Errors.

Webhooks are Lero calling your server when something happens, instead of you polling. Example: a Conversational Agent call ended, and your system gets the transcript, the duration and the end reason right away, to save in the CRM, score an interview or open a ticket.

  1. At app.lero.studio/desenvolvedores, Webhooks tab, add your server's https URL and choose the events (up to 5 webhooks per account).
  2. Copy the webhook secret (whsec_...) to your server.
  3. Click Enviar teste (send test): the webhook.teste event arrives.
  4. On your server, verify the signature and answer 2xx within 10 s.
eventwhen
agente.conversa.encerradaA Conversational Agent call of your account ended. Carries version, LLM, voice, start, end, duration, end reason and the transcript.
webhook.testeYou clicked Enviar teste.

Speech-to-Text answers in the same call, with no webhook. Voice Clone events arrive when that product launches.

Each delivery is a POST with Content-Type: application/json and the headers Lero-Evento, Lero-Evento-Id and Lero-Assinatura: t=<unix>,v1=<hex>. v1 is the HMAC-SHA256 of "<t>.<raw body>" with the webhook secret. Reject when the signature does not match or when t is older than 5 minutes.

JSON
{
  "id": "6f1c2a9e-3b7d-4e58-9a0c-2d4f6b8e1a37",
  "evento": "agente.conversa.encerrada",
  "criado": "2026-11-11T15:42:10.512Z",
  "dados": {
    "conversa_id": "2eecec700b63",
    "versao": "v0.4",
    "llm": "oss120b",
    "voz": "rio",
    "lingua": "pt",
    "inicio": "2026-11-11T15:31:07Z",
    "fim": "2026-11-11T15:42:09Z",
    "duracao_s": 662.4,
    "motivo": "a pessoa desligou",
    "transcript": [
      { "papel": "agente", "texto": "Oi! Aqui é a Bia, da Acme.", "t": 1.2 },
      { "papel": "pessoa", "texto": "Oi, tudo bem?", "t": 4.8 }
    ]
  }
}

Verify the signature (Node):

javascript
import crypto from "node:crypto";

// Next.js route: POST /api/lero-webhook. LERO_WEBHOOK_SECRET = o segredo do webhook (whsec_...)
export async function POST(req) {
  const corpo = await req.text();                                  // o corpo cru, antes do JSON.parse
  const [t, v1] = (req.headers.get("lero-assinatura") || "").split(",").map((p) => p.split("=")[1]);
  const esperado = crypto.createHmac("sha256", process.env.LERO_WEBHOOK_SECRET).update(`${t}.${corpo}`).digest("hex");
  const ok = v1 && v1.length === esperado.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado));
  if (!ok || Math.abs(Date.now() / 1000 - Number(t)) > 300) return new Response("assinatura inválida", { status: 401 });

  const evento = JSON.parse(corpo);
  if (evento.evento === "agente.conversa.encerrada") {
    // salve evento.dados.transcript, avalie a entrevista, abra o ticket...
  }
  return new Response("ok");                                       // responda 2xx rápido; o trabalho pesado vai para uma fila
}

Verify the signature (Python):

python
import hashlib, hmac, json, os, time

from flask import Flask, request

app = Flask(__name__)


@app.post("/lero-webhook")
def lero_webhook():
    corpo = request.get_data(as_text=True)                         # o corpo cru
    partes = dict(p.split("=", 1) for p in request.headers.get("Lero-Assinatura", "").split(",") if "=" in p)
    esperado = hmac.new(os.environ["LERO_WEBHOOK_SECRET"].encode(), f"{partes.get('t')}.{corpo}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(partes.get("v1", ""), esperado) or abs(time.time() - int(partes.get("t", 0))) > 300:
        return "assinatura inválida", 401
    evento = json.loads(corpo)
    if evento["evento"] == "agente.conversa.encerrada":
        pass  # salve evento["dados"]["transcript"]...
    return "ok"
  • Without a 2xx, Lero retries after 2 s and 8 s (3 attempts). The event id is the same across attempts: use it to avoid processing twice.
  • Redirects are not followed: use the final URL.
  • The latest deliveries, with status and time, show in the Webhooks tab.

Conversational Agent: step by step

The Conversational Agent is a real-time voice call. HTTP only does discovery and signaling; then audio and events flow peer-to-peer over WebRTC. Typical latency ~1 s, with barge-in (the person interrupts and the agent stops).

  1. Discover. GET /v1/agents returns the versions, LLMs and voices your key can use.
  2. Connect. In the WebRTC client, create the chat data channel before the offer, add 1 audio track (the microphone) and wait for ICE gathering.
  3. Signaling. Send the SDP offer to POST /v1/agents/conversation (from your backend, with the key) and apply the SDP answer. The call starts and the agent greets.
  4. Custom agent (optional). In the same POST, instrucoes (system prompt), conhecimento (knowledge base), abertura, palavras and the settings make the agent yours.
  5. Live context (optional). During the call, send on chat what the agent needs to see: the code in a live coding, the cart, the screen.
  6. Events. Read the transcript, the state and the latency of each turn on chat.
  7. Hang up. Close the RTCPeerConnection.

Live context and control, over the data channel (every message needs the type field):

javascript
// live context: one panel per key, always the latest version (send the full text on change, ~every 2 s)
chat.send(JSON.stringify({ type: "lero-contexto", chave: "codigo", texto: editor.getValue() }));
chat.send(JSON.stringify({ type: "lero-contexto", chave: "testes", texto: "3/5 testes passaram", responder: true }));

// system note: the person does not hear it; the agent acts on the next free turn (responder: false only stores it)
chat.send(JSON.stringify({ type: "lero-instrucao", texto: "O tempo da fase 2 acabou. Passe para a fase 3." }));

// exact utterance, bypasses the LLM
chat.send(JSON.stringify({ type: "lero-falar", texto: "Faltam 5 minutos." }));
messagefieldswhat it does
lero-contextochave (up to 60), texto (up to 20,000), responderOne live panel per key (up to 8). Only the latest version enters the next reply. Empty texto removes it.
lero-instrucaotexto (up to 4,000), responderSystem note the person does not hear: phase change, timer, hint.
lero-falartexto (up to 2,000)The agent says exactly the text, bypassing the LLM.

Events on chat (field tipo):

tipofields
estadoestado: conectado, na_fila, ouvindo, pensando, falando, aviso_fim, encerrada; motivo at the end
falapapel (pessoa or agente), texto, final, interrompido: the live transcript
latenciatotal_ms and the turn stages
ignoradoa short backchannel ("uhum") that did not interrupt the agent

The call ends when you close the connection, on the silence goodbye (if silencio_s is on) or after 10 minutes without speech. Every call is recorded (audio, transcript and agent config) for quality review.

A complete client, with a timeout and waits on 429 and 503:

import { writeFile } from "node:fs/promises";

const espera = s => new Promise(ok => setTimeout(ok, s * 1000));

// generates the speech and returns the MP3; waits Retry-After on 429 and 503
async function falar(texto, voz = "rio", tentativas = 3) {
  for (let i = 0; i < tentativas; i++) {
    const res = await fetch("https://app.lero.studio/api/v1/tts", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.LERO_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ texto, voz }),
      signal: AbortSignal.timeout(300_000),
    });
    if (res.status === 429 || res.status === 503) {
      await espera(Number(res.headers.get("Retry-After")) || 10);
      continue;
    }
    if (!res.ok) {
      const { erro, mensagem } = await res.json();
      throw new Error(`${res.status} ${erro}: ${mensagem}`);
    }
    return Buffer.from(await res.arrayBuffer());
  }
  throw new Error("the voice stayed busy");
}

await writeFile("fala.mp3", await falar("Bom dia! Seu pedido saiu pra entrega.", "sp"));

Questions and problems: . Send the X-Pedido-Id of the request that failed.

Go to the website