> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zelto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom & other providers

> Mint an API key and POST conversations into Zelto — voice calls or text/messaging — from any stack we don't natively support yet (Bland, Pipecat, a chatbot, in-house, …).

## 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](/api-reference/agents/list-agents), 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](#mint-a-key) and expose it as `ZELTO_API_KEY` — the
prompt tells the agent to read it from the environment, never hardcode
it.

<div className="prompt-scroll">
  ````text title="Paste into your coding agent" theme={null}
  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.
  ````
</div>

<Tip>
  If your editor has the [Zelto MCP server](/docs/mcp) connected, the agent
  can call its `read_docs` tool to pull the always-current contract instead of
  the embedded copy — and the [same key](/docs/mcp#get-an-api-key) works for
  MCP, the [REST API](/api-reference/agents/list-agents), and this upload flow.
</Tip>

## 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](#text-and-messaging-conversations).

```bash theme={null}
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
```

```json title="call.json" theme={null}
{
  "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.

```json title="chat.json" theme={null}
{
  "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.

| Status | `error`                | When                                                                         |
| ------ | ---------------------- | ---------------------------------------------------------------------------- |
| `401`  | `Unauthorized`         | Missing or invalid API key.                                                  |
| `400`  | `Invalid JSON`         | The body isn't valid JSON.                                                   |
| `400`  | `Unsupported provider` | `X-Zelto-Provider` is set to an unrecognized value.                          |
| `400`  | `Validation failed`    | A field is missing or the wrong type — `details` lists each offending field. |

<Note>
  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](/docs/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](#idempotent-re-uploads) with one of them ingests
  normally).
</Note>

### 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](/docs/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](/docs/guides/connect-a-voice-provider#troubleshoot-a-missing-call).

## When to use this vs a native integration

If you already run on [Vapi](/docs/integrations/vapi) or [Retell](/docs/integrations/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](/docs/integrations/livekit) page.

## Related

* [Connect a voice provider](/docs/guides/connect-a-voice-provider) — pick the right path.
* [LiveKit](/docs/integrations/livekit) — the same endpoint, with a worker example.
* [API reference](/api-reference/agents/list-agents) — REST conventions and the full request shape.
* [Conversations](/docs/conversations) — where uploaded calls land.
