Skip to main content

Mint a key

  1. Open Settings → Integrations → API Call Upload and click Create API key.
  2. Give it a descriptive name (e.g. prod-twilio-uploader).
  3. Copy the key once — Zelto only stores a hash.
The card lists active keys with their last-used timestamp. Delete a key any time to revoke access immediately.

Set up with a coding agent

Don’t want to wire this up by hand? Paste the prompt below into the coding agent in your editor — Claude Code, Cursor, Codex, or similar. It points the agent at this guide and the API reference, spells out the full upload contract, and has it send each finished conversation following your own codebase’s conventions. You still supply the key: mint one above and expose it as ZELTO_API_KEY — the prompt tells the agent to read it from the environment, never hardcode it.
Paste into your coding agent
You are integrating my app with Zelto, a production analytics platform for
voice and chat AI agents. Zelto ingests each finished conversation over one
HTTP endpoint, then transcribes and analyzes it. Your task: add code to this
codebase that POSTs every finished conversation to Zelto.

Read these before writing code — they are the source of truth. If anything
below conflicts with them, follow the docs:
- Guide: https://docs.zelto.ai/docs/integrations/api-call-upload
- API reference (full request shape + a playground to test a payload):
  https://docs.zelto.ai/api-reference/agents/list-agents
- If this editor has the Zelto MCP server connected, call the `read_docs`
  tool with path `integrations/api-call-upload` for the current version.

Contract:
- POST https://api.zelto.ai/webhooks/calls
- Headers: `Authorization: Bearer $ZELTO_API_KEY`, `X-Zelto-Provider: zelto`,
  `Content-Type: application/json`. Read the key from the ZELTO_API_KEY
  environment variable — never hardcode or log it.
- Required: `call.externalId` (a stable, unique id for the conversation) plus
  an agent reference — either `agent.externalId` (Zelto finds-or-creates the
  agent; use a stable id per agent, not per conversation) or `agentId` (an
  existing Zelto agent UUID). Everything else is optional.
- Optional `version`: a label for the agent build or prompt revision behind the
  conversation (e.g. `"v3"`). Zelto records it as the call's agent version so you
  can segment analytics by version. Use a stable string per build — the same
  string reuses a version, a new string starts one.
- `transcript.turns[]` are `{ role, content }`; role is one of user,
  assistant, system, tool.
- Voice calls: also send call.startedAt / call.endedAt (ISO 8601),
  call.recordingUrl (Zelto re-hosts the audio), and any of
  call.durationSeconds, call.endedReason, call.cost,
  call.customer.{number,name}, plus per-turn startSeconds / endSeconds.
- Text or chat conversations: omit the audio/telephony fields (recordingUrl,
  per-turn seconds, usually customer.number); record the channel in metadata.
- Response: 200 `{ "received": true }` means accepted and queued — processing
  is async, so a 200 is not "fully processed". A 4xx returns
  `{ "error", "details"? }`: 401 = missing/invalid key, 400 = invalid JSON or
  a field validation error (details names the offending fields).
- Idempotency: call.externalId is the dedup key. Re-POSTing the same id
  updates the conversation in place and never double-charges, so retries and
  later enrichment re-sends are both safe.

Minimal body:

```json
{
  "agent": { "externalId": "support-bot", "name": "Support Bot" },
  "call": { "externalId": "conv_01HXYZ" },
  "transcript": {
    "turns": [
      { "role": "assistant", "content": "Hi, how can I help?" },
      { "role": "user", "content": "I need to reschedule." }
    ]
  }
}
```

Implementation:
- Send each conversation once, right after it ends (e.g. an end-of-call or
  end-of-session hook), built from data you already have.
- Don't block the user-facing path: send from a background job/queue and
  tolerate Zelto being briefly unavailable. On a non-200, log the status and
  body and retry with backoff (safe, because externalId dedups).
- Follow this codebase's existing conventions for HTTP, config, logging, and
  async work. Add ZELTO_API_KEY to the env/config.

Verify: set ZELTO_API_KEY, run one real conversation, and confirm it lands
under Conversations in the Zelto dashboard within a few seconds. If it
doesn't, check the POST response body for the reason.
If your editor has the Zelto MCP server connected, the agent can call its read_docs tool to pull the always-current contract instead of the embedded copy — and the same key works for MCP, the REST API, and this upload flow.

Upload a conversation

POST the whole conversation — agent, transcript, optional recording, and metadata — in one request to /webhooks/calls. Zelto acknowledges immediately and processes it into a conversation and transcript in the background. The API Reference tab covers the REST conventions and has a request playground for verifying a payload before you wire up production. The example below is a voice call; a text conversation is the same shape with the audio fields left out.
curl -X POST https://api.zelto.ai/webhooks/calls \
  -H "Authorization: Bearer $ZELTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Zelto-Provider: zelto" \
  -d @call.json
call.json
{
  "agent": { "externalId": "support-bot", "name": "Support Bot" },
  "version": "v3",
  "call": {
    "externalId": "call_01HXYZ",
    "startedAt": "2026-05-29T15:00:00Z",
    "endedAt": "2026-05-29T15:02:03Z",
    "endedReason": "customer_hangup",
    "cost": 0.042,
    "recordingUrl": "https://your-cdn.example.com/call_01HXYZ.mp3",
    "customer": { "number": "+15555550123", "name": "Jane Doe" }
  },
  "transcript": {
    "turns": [
      { "role": "assistant", "content": "Hi, how can I help?", "startSeconds": 0 },
      { "role": "user", "content": "I need to reschedule.", "startSeconds": 3.2 }
    ]
  },
  "systemPrompt": "You are a friendly scheduling assistant.",
  "metadata": { "campaign": "spring-2026" }
}
A successful upload returns HTTP 200 with { "received": true }. Only call.externalId and an agent reference are required; everything else is optional. Provide either agent.externalId (Zelto finds or creates the agent for you) or agentId to attach the call to an agent you already created in the dashboard. Include call.recordingUrl and Zelto re-hosts the audio so the player keeps working past your source’s recording TTL. Set version to label the agent build or prompt revision behind the call (e.g. "v3"); Zelto tracks it as the call’s agent version so you can compare performance across versions on the agent’s page. It’s optional and works with either agent reference — send the same string for calls on the same build.

Text and messaging conversations

The same endpoint ingests text conversations — a chatbot or messaging agent (web chat, SMS, WhatsApp, in-app). Send the messages as transcript.turns (role: "user" for the person, role: "assistant" for your agent) and omit the audio and telephony fields: recordingUrl, and usually durationSeconds and customer.number. There’s no recording to re-host and no per-turn audio timing, so drop startSeconds/endSeconds too. Record the channel in metadata (there’s no dedicated channel field) if you want to filter on it later.
chat.json
{
  "agent": { "externalId": "support-chatbot", "name": "Support Chatbot" },
  "call": {
    "externalId": "chat_01HXYZ",
    "startedAt": "2026-05-29T15:00:00Z",
    "endedAt": "2026-05-29T15:04:12Z"
  },
  "transcript": {
    "turns": [
      { "role": "user", "content": "Hi, I need to reschedule my appointment." },
      { "role": "assistant", "content": "Happy to help — what day works for you?" }
    ]
  },
  "metadata": { "channel": "web-chat" }
}

Handle errors

Errors come back as JSON with an error string; validation and provider errors add a details object naming what to fix.
StatuserrorWhen
401UnauthorizedMissing or invalid API key.
400Invalid JSONThe body isn’t valid JSON.
400Unsupported providerX-Zelto-Provider is set to an unrecognized value.
400Validation failedA field is missing or the wrong type — details lists each offending field.
A 200 means Zelto accepted the conversation and is processing it in the background — not that ingestion has finished. Bad auth and malformed bodies fail synchronously with a 4xx, but a semantic problem like an agentId that doesn’t exist in your org still returns 200 and then fails quietly, so it never appears. Prefer agent.externalId (find-or-create) to avoid that, and confirm conversations land under Conversations.A delivery that carries neither a transcript nor a recording is treated as an unconnected dial: Zelto acks it with 200 but intentionally creates no conversation, since there’s nothing to transcribe or analyze. Send the call once it has a transcript or recordingUrl (a later enrichment re-send with one of them ingests normally).

Idempotent re-uploads

call.externalId is the dedup key. POSTing the same id again updates the existing conversation in place — refreshing the recording, transcript, duration, or cost — and never double-charges. Send the call once when it ends, then re-send later if you enrich it (e.g. once the recording finishes uploading).

Already on Vapi or Retell?

The same endpoint also accepts native Vapi and Retell webhook payloads — set the X-Zelto-Provider header to vapi or retell and POST that provider’s body unchanged. Omit the header (or send zelto) to use the canonical shape above.

Verify the first call

After your first POST, open Conversations — the call appears within a few seconds with its transcript. The API Reference tab’s request playground is the fastest way to send a test payload. If a call doesn’t show up, check the response your POST got — { "received": true } means Zelto accepted it, while a 4xx returns the reason in the body (an unsupported X-Zelto-Provider value, a failed field validation, or an invalid key). See Connect a voice provider.

When to use this vs a native integration

If you already run on Vapi or Retell, use the native integration — webhooks land for free and recordings are fetched for you. This page is for everything else: Bland, Pipecat, a text or messaging agent, in-house stacks, or anything we haven’t shipped a first-class card for yet. Running on LiveKit? It has a dedicated LiveKit page.