API Reference

All endpoints for the Chamade API. Authenticate with your API key via the X-API-Key header.

Authentication

All API calls require the X-API-Key header with your API key:

http
X-API-Key: chmd_your_key_here

API keys are created in your Dashboard. They start with chmd_ and are shown only once at creation. Store them securely.

Create Call

POST/api/call

Join a meeting or initiate a call. Returns the call ID and initial state.

Request body:

json
{ "platform": "discord", "meeting_url": "https://discord.com/channels/...", "agent_name": "AI Agent" }
FieldTypeRequiredDescription
platformstringYesOne of: discord, teams, meet, zoom, telegram, slack, nctalk, sip, whatsapp
meeting_urlstringDependsMeeting URL or SIP URI. Required for most platforms.
agent_namestringNoDisplay name for the agent in the meeting. Default: "AI Agent"
stt_voice_config_idstringNoUUID of a specific STT preset to use for this call. Omit to use the user's enabled STT preset (from dashboard → Voice providers). Hosted STT is BYOK — you bring the ElevenLabs / Deepgram key, Chamade runs the pipeline with it for free. No preset configured = BYO-audio (your own STT client reads raw PCM from the call WebSocket).
tts_voice_config_idstringNoUUID of a specific TTS preset to use for this call. Omit to use the user's enabled TTS preset. Same BYOK model as STT. No preset configured = POST /api/call/{id}/say returns 400 and you should synthesize locally + push PCM on the audio WebSocket.

Response:

json
{ "call_id": "abc123", "room_id": "...", "participant_id": "...", "text_ws_url": "wss://...", "state": "connecting", "capabilities": ["audio_in", "audio_out", "read", "write"], "platform": "discord", "meeting_url": "https://discord.com/channels/...", "agent_name": "AI Agent", "audio": { "sample_rate": 48000, "format": "pcm_s16le", "channels": 1, "frame_duration_ms": 20, "frame_bytes": 1920 } }

Get Call Status

GET/api/call/{call_id}?since=0

Get the current state of a call, including transcript lines. The since parameter enables a delta pattern: only transcript lines after that index are returned, so you can poll efficiently without re-reading the entire transcript.

Response:

json
{ "call_id": "abc123", "room_id": "...", "participant_id": "...", "text_ws_url": "wss://...", "state": "active", "capabilities": ["audio_in", "audio_out", "read", "write"], "audio": {"sample_rate": 48000, "format": "pcm_s16le", "channels": 1, ...}, "platform": "discord", "meeting_url": "https://discord.com/channels/...", "agent_name": "AI Agent", "transcript": ["[Alice] Hello everyone", "[Bob] Hi Alice"], "transcript_length": 42, "direction": "outbound", "caller": "", "created_at": "2026-03-13T10:00:00Z", "ended_at": null }
Delta Pattern

On the first call, use since=0 to get all transcript lines. Then pass since={transcript_length} from the response to get only new lines on subsequent polls.

Speak (hosted TTS) — [BYOK]

POST/api/call/{call_id}/say

Bring your own key

Requires a TTS preset in dashboard → Voice providers (setup). Returns 400 without one — fall back to BYO audio: synthesize locally and push PCM on the call's audio WebSocket.

Speak text aloud in the meeting via Chamade's hosted TTS. Only meaningful on platforms with the audio_out capability.

Request body:

json
{"text": "Hello, I am your AI assistant."}

Interrupt TTS (barge-in) — [BYOK]

POST/api/call/{call_id}/stop-speaking

Cancel any in-flight hosted-TTS utterance. No-op if nothing is currently being spoken. No body. Useful when the user starts talking over the agent, or when the agent decides to abandon mid-sentence. MCP equivalent: chamade_call_stop_speaking.

Send Chat

POST/api/call/{call_id}/chat

Send a text chat message in the meeting. Works on platforms with the write capability.

Request body:

json
{ "text": "Here is the link: https://example.com", "sender_name": "AI Assistant", "attachments": [{"file_id": "f_abc..."}] }
FieldTypeRequiredDescription
textstringDependsMessage text (1–10,000 characters). Optional when attachments is set — text then becomes the caption of the first attachment.
sender_namestringNoDisplay name for the sender in meeting chat cards. Default: empty.
attachmentsarrayNoFile attachments to send with the message. See Files & attachments. Three per-entry shapes: {file_id}, {url, name?, mime?}, or {bytes_b64, name, mime}. Cap 25 MB each.

Accept Inbound Call

POST/api/call/{call_id}/accept

Accept a ringing inbound call (SIP, etc.). Changes the call state from "ringing" to "active".

Refuse Inbound Call

POST/api/call/{call_id}/refuse

Refuse/reject a ringing inbound call. Changes the call state from "ringing" to "refused".

Hang Up

DELETE/api/call/{call_id}

End the call and leave the meeting. The call state changes to "ended".

List Active Calls

GET/api/calls

Returns a list of all active calls for the authenticated user.

Typing Indicator

POST/api/call/{call_id}/typing

Send a typing indicator in the meeting chat. Supported on platforms with the typing capability.

WebSocket Stream

WS/api/call/{call_id}/stream?api_key=chmd_...

Real-time bidirectional stream for receiving transcripts and chat, and sending speech and chat messages. This is an alternative to polling GET /api/call/{id} for real-time applications.

Incoming messages (server → client):

json
{"type": "call_transcript", "speaker": "Alice", "text": "Hello"} {"type": "call_chat", "sender": "Bob", "text": "Hi"} {"type": "call_state", "state": "active"} {"type": "call_error", "message": "..."}

Outgoing messages (client → server):

json
{"type": "say", "text": "Hello everyone"} {"type": "send_chat", "text": "Check this link"}

Direct Audio Streaming (BYO audio)

Alternative to hosted STT/TTS: the same WebSocket supports sending and receiving raw PCM, so you can plug in your own voice models — ElevenLabs streaming, OpenAI Realtime, Cartesia Sonic, Deepgram, LiveKit Agents, Pipecat, a manual Whisper cascade, whatever. Recommended when you already have a voice pipeline you want to keep; otherwise the hosted path (Voice providers) is simpler. Chamade is pure transport in this mode — omit stt_voice_config_id / tts_voice_config_id in POST /api/call (or leave no enabled preset) to bypass the speech loop entirely.

The audio object in the POST /api/call response tells you the native sample rate of the room. Sending audio at that rate avoids resampling and gives the best quality.

PlatformNative rate
Discord, Meet, NC Talk48,000 Hz
Teams, Zoom, Telegram16,000 Hz
SIP8,000 Hz

Format: raw PCM, signed 16-bit little-endian, mono, at the rate above (or your declared rate via audio_config). Internal frame cadence is 20 ms.

Option A — Binary frames (recommended): Send raw PCM s16le mono as binary WebSocket frames. Zero overhead, no JSON encoding. Each frame may contain any number of samples (Chamade buffers and re-aligns to 20 ms internally). Hard cap: 1 MB per frame (~10 s of audio at 48 kHz).

Option B — Base64 JSON: Compatible with OpenAI Realtime and similar JSON-based streaming patterns. Slightly higher overhead due to base64 encoding + JSON parsing.

json
{"type": "audio", "audio": "<base64 PCM>", "sample_rate": 24000}

Audio config (optional): Send this to discover the native sample rate, declare your own rate (Chamade will resample), or opt into receiving the meeting’s audio mix.

json
// 1. Query native rate (no sample_rate → returns native) {"type": "audio_config"} // Response: native rate is 48kHz, no resampling needed at this rate {"type": "audio_config", "sample_rate": 48000, "native_sample_rate": 48000, "resampling": false, "format": "pcm_s16le", "channels": 1, "frame_duration_ms": 20, "frame_bytes": 1920, "ready": true} // 2. Agent can't produce 48kHz (e.g. TTS outputs 24kHz) → override {"type": "audio_config", "sample_rate": 24000, "receive": true} // Response: Chamade will resample 24kHz → 48kHz on the fly {"type": "audio_config", "sample_rate": 24000, "native_sample_rate": 48000, "resampling": true, "format": "pcm_s16le", "channels": 1, "frame_duration_ms": 20, "frame_bytes": 960, "ready": true}
Best Performance

Send binary frames at the native sample rate (from the audio field in the call response). No config message needed, no resampling, lowest latency.

Receiving the meeting audio (mix-minus)

If you want your own STT (Whisper, Deepgram, etc.) instead of Chamade's, opt into receiving the meeting audio with audio_config + "receive": true.

Handling disconnects

The bridge between Chamade and the meeting platform can drop (Maquisard restart, network blip, platform-side disconnect). When that happens, all subscribed agent WebSockets receive:

json
{"type": "call_error", "code": "bridge_disconnected", "message": "Bridge connection lost. Use chamade_call_join with the same meeting_url to reconnect."}

To recover, call POST /api/call again with the same meeting_url. A new call_id is issued and a new WebSocket can be opened. Previous transcript lines are not carried over — the room is fresh.

Python example

Minimal client that opens the WebSocket, declares 24 kHz, sends a chunk of PCM (your TTS output), and receives the meeting audio for a custom STT pipeline.

python
import asyncio import json import httpx import websockets API_KEY = "chmd_..." # from chamade.io/dashboard async def main(): # 1. Create the call with transcripts disabled (BYO-STT) async with httpx.AsyncClient() as http: r = await http.post( "https://chamade.io/api/call/", headers={"X-API-Key": API_KEY, "Content-Type": "application/json"}, json={ "platform": "discord", "meeting_url": "https://discord.com/channels/.../...", "transcripts": False, # BYO-STT — Chamade pays no STT/TTS }, ) r.raise_for_status() call = r.json() call_id = call["call_id"] # 2. Open the WebSocket url = f"wss://chamade.io/api/call/{call_id}/stream?api_key={API_KEY}" async with websockets.connect(url) as ws: # 3. Declare your sample rate + opt into receiving audio await ws.send(json.dumps({ "type": "audio_config", "sample_rate": 24000, "receive": True, })) async def reader(): async for msg in ws: if isinstance(msg, bytes): # Raw PCM s16le mono @ 24 kHz, 20 ms frames, mix-minus. # Feed this to your STT (Whisper, Deepgram, ...). print(f"audio frame: {len(msg)} bytes") else: event = json.loads(msg) etype = event.get("type") if etype == "audio_config": print(f"audio_config: {event}") elif etype == "call_transcript": print(f'{event["speaker"]} → {event["text"]}') elif etype == "call_chat": print(f'chat <{event["sender"]}>: {event["text"]}') elif etype == "call_error" and event.get("code") == "bridge_disconnected": print("Bridge dropped — re-create the call.") return async def writer(): # Your TTS would produce 24 kHz s16le PCM here. Stub: 1 s of silence. pcm_one_second = b"\x00\x00" * 24000 # 24000 samples × 2 bytes await ws.send(pcm_one_second) # binary frame, sent as-is await asyncio.sleep(60) # keep the call alive for the reader await asyncio.gather(reader(), writer()) asyncio.run(main())
MCP server cannot stream audio

The @chamade/mcp-server npm package exposes text-only tools (chamade_call_say, chamade_call_chat, etc.) and a transcript resource. Direct audio I/O is not exposed via MCP — the stdio JSON-RPC transport doesn't reasonably support raw binary streaming. To stream your own PCM, your agent must connect to this WebSocket directly (alongside or instead of the MCP server).

Inbox API (DMs)

The Inbox API lets your agent read and respond to direct messages from platforms like Discord, Telegram, WhatsApp, Teams, and Slack.

List Conversations

GET/api/inbox?platform=discord&limit=50

Returns a list of DM conversations. Filter by platform and limit results.

ParamTypeRequiredDescription
platformstringNoFilter by platform (discord, telegram, teams, whatsapp, slack, nctalk)
limitintNoMax conversations to return (default 50)
statusstringNoFilter by status (default “active”)
offsetintNoPagination offset (default 0)

Get Conversation

GET/api/inbox/{conversation_id}?limit=50

Get messages from a specific conversation. Use limit to control how many messages are returned.

Send Message (by platform)

POST/api/dm/chat

Send a message to the active DM conversation on a platform. This is the recommended endpoint for agents.

Request body:

json
{ "platform": "discord", "text": "Thanks for reaching out!", "attachments": [{"file_id": "f_abc..."}] }

Same attachments shape as call chat: array of {file_id}, {url, name?, mime?}, or {bytes_b64, name, mime}. See Files & attachments for the per-platform matrix and upload flow. When attachments is set, text becomes the caption of the first attachment and is optional.

Typing Indicator (by platform)

POST/api/dm/typing

Send a typing indicator on a DM conversation.

Request body:

json
{"platform": "discord"}
Typing behavior

When you call POST /api/dm/typing, the typing indicator stays active automatically (Chamade repeats it internally). It stops when you send a message via POST /api/dm/chat, or after a 65-second safety timeout.

Reply Timeout (60 seconds)

After a user sends a DM, you have 60 seconds to reply via POST /api/dm/chat. If no reply is sent in time, the user sees a timeout error message.

If your task takes longer than a few seconds:

  1. Send a short acknowledgment immediately via POST /api/dm/chat with "keep_typing": true (e.g. “Looking into it...”). The keep_typing flag re-activates the typing indicator automatically after sending.
  2. Do your work, then send the full answer via POST /api/dm/chat (without keep_typing)
  3. If your work takes more than 60 seconds, call POST /api/dm/typing every ~55 seconds to keep the indicator alive

Account Status

GET/api/account

Bootstrap call. Returns plan, a features block (global gateway availability — audio_in, audio_out, text_chat, typing_indicators, files are ready in early access; hosted_stt and hosted_tts are ready when the user has an enabled preset in dashboard → Voice providers, byok otherwise — add a key and Chamade runs the pipeline for free), platforms as a dict of {status, capabilities, files} per platform (the files bool on each platform tells you whether send+receive works end-to-end today — true for Discord, Telegram, Slack, WhatsApp, NC Talk; false for Teams, Meet, SIP, Zoom), concurrent_calls, the identities map, and a last_message_cursor to bootstrap chamade_inbox delta mode.

Files & Attachments

Chamade relays file attachments in both directions on DMs and meeting chat. Every attachment served to your agent comes back as a signed Chamade URL (https://chamade.io/api/files/{token}) — your agent never touches platform-native URLs or bearer tokens. Outbound, you upload to Chamade once and reference the file_id any number of times.

Cap: 25 MB per attachment (configurable via CHAMADE_FILES_MAX_SIZE_MB). Retention: inbound metadata kept 30 days, agent uploads expire after 24 h. Bytes are hot-cached 15 min then re-fetched from the platform on demand. Zero long-term storage: if the platform evicts the file, Chamade responds 410 Gone — same behaviour as the source.

Per-platform matrix

PlatformSendReceiveNotes
Discord25 MB (native limit for non-Nitro). CDN URLs signed ~24 h.
Telegramfile_id persists indefinitely; refetch via getFile uses your BYOB bot token or the shared bot.
SlackUses files.getUploadURLExternal (the modern path; legacy files.upload was retired 2025-11-12).
WhatsAppImages/audio/video/voice notes/documents/stickers. Voice notes delivered as raw audio/ogg — auto-STT is V2. Meta caption rules apply (no caption on audio).
NC Talk(addon ≥ 2.4.0)WebDAV upload + OCS share (shareType=10) via the Chamade bot user. Inbound requires the chamade_talk addon at v2.4.0+ so messageParameters.file is forwarded.
Microsoft Teamscode-ready, gatedWiring complete behind CHAMADE_FILES_ENABLE_TEAMS=false. Flips on once AppSource certification clears — no user-side change needed.
Google Meetcode-ready, gatedGated behind CHAMADE_FILES_ENABLE_MEET=false while Google OAuth verification is pending.
SIP / ZoomNo file channel (audio-only / SDK-only by platform design).

Sending attachments

Three per-entry shapes on the attachments array of POST /api/call/{id}/chat and POST /api/dm/chat:

ShapeWhen to use
{"file_id": "f_…"}Recommended for any non-trivial local file. Upload first via POST /api/files (see below), then reference the returned file_id — including multiple times if you want to re-send the same file across conversations.
{"url": "https://…", "name": "…", "mime": "…"}Re-send an inbound attachment, or reference something already hosted on the public web. Chamade fetches the URL server-side (SSRF-guarded — private IPs, loopback, metadata services blocked).
{"bytes_b64": "…", "name": "…", "mime": "…"}Tiny inline files only. The full payload round-trips through JSON, so this forces the model (or your HTTP client) to emit every base64 character — a 100 KB image stalls a model for 30–60 s. Prefer file_id via the upload endpoint.

When attachments is set, text is optional and becomes the caption of the first attachment. The sync-reply fast-path (for DMs) is skipped when attachments are present — the send goes async, which is harmless but slightly slower than a text-only reply.

Upload a File

POST/api/files

Stage a file on Chamade and get a file_id you can reuse across sends.

Three input paths:

bash
# A — multipart (best for local files) curl -X POST https://chamade.io/api/files \ -H "X-API-Key: chmd_..." \ -F "file=@./report.pdf" # B — JSON url (re-host something from the web) curl -X POST https://chamade.io/api/files \ -H "X-API-Key: chmd_..." \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/report.pdf", "name": "report.pdf"}' # C — JSON bytes_b64 (inline, tiny files only) curl -X POST https://chamade.io/api/files \ -H "X-API-Key: chmd_..." \ -H "Content-Type: application/json" \ -d '{"bytes_b64": "iVBORw0K...", "name": "pixel.png", "mime": "image/png"}'

Response:

json
{ "file_id": "f_abc123...", "url": "https://chamade.io/api/files/f_abc123...", "name": "report.pdf", "mime": "application/pdf", "size": 123456, "expires_at": "2026-04-18T12:00:00Z" }

Pass the file_id in subsequent attachments: [{file_id}] arrays. Uploads are private to the user who created them — a stolen file_id cannot be used from another account.

Pre-signed Upload URL (no-auth POST)

POST/api/files/reserve

Reserve a file_id and get a pre-signed upload URL that accepts a multipart POST with no auth header — the URL itself is the auth. The slot is single-use and expires in 5 minutes.

This is the path the MCP tool chamade_file_upload_url exposes. Useful when the uploader (a shell script, a CI job, a browser fetch) shouldn't see the API key.

bash
# 1. Reserve a slot curl -X POST https://chamade.io/api/files/reserve \ -H "X-API-Key: chmd_..." \ -H "Content-Type: application/json" \ -d '{"name": "report.pdf", "mime": "application/pdf"}' # → {"file_id": "f_...", "upload_url": "https://chamade.io/api/files/u/...", "expires_at": "..."} # 2. Upload bytes to the pre-signed URL — no auth header curl -F "file=@./report.pdf" https://chamade.io/api/files/u/... # 3. Reference the file_id on your next send curl -X POST https://chamade.io/api/dm/chat \ -H "X-API-Key: chmd_..." \ -H "Content-Type: application/json" \ -d '{"platform": "nctalk", "attachments": [{"file_id": "f_..."}]}'

Serve a File

GET/api/files/{token} · HEAD/api/files/{token}

Stream the bytes of an attachment. The token path param is the auth — it is 24 bytes of url-safe entropy (~128 bits), so possession of the URL grants read access. No X-API-Key header required. Makes it trivial to hand a URL to an external tool (a vision model, OCR service, etc.) without leaking your API key.

On a cache miss, Chamade re-fetches from the upstream platform (using the appropriate refetcher — Telegram getFile, Slack signed URL + bearer, Meta Graph media endpoint, NC Talk WebDAV GET, or plain HTTPS for Discord CDN). A concurrent lock per token dedupes re-fetches; if the platform has evicted the file, Chamade returns 410 Gone.

HEAD returns the same Content-Type, Content-Length, and Content-Disposition headers as GET without transferring bytes — useful for size probes.

Attachments in the Inbox

GET /api/inbox/{conversation_id} enriches each message with an attachments[] array:

json
{ "messages": [{ "id": "msg_...", "text": "Here's the report", "direction": "inbound", "attachments": [{ "name": "report.pdf", "mime": "application/pdf", "size": 123456, "url": "https://chamade.io/api/files/f_abc..." }] }] }

Same shape on the push/long-poll events (dm_chat, call_chat) and on the MCP chamade_inbox tool. Your agent can curl the url directly — no additional auth header needed beyond X-API-Key when going through the authenticated path, or nothing at all when going through the opaque token path (see above).