Docs · Ingestion

Conversation Ingestion API

Push conversations from any platform into Belter for AI-powered QA scoring — no native connector required. Works with CCaaS, ITSM, CRM, and custom systems including Genesys, NICE, Five9, Talkdesk, Webex, ServiceNow, Jira, and anything you can call out from.

v1.0 Updated 2026-06-09

Overview #

The ingestion API is Belter's generic adapter: a single, stable HTTPS contract that accepts conversations from any platform — even ones we don't have a native connector for. Once a conversation lands here, it's normalized into Belter's universal schema and joins the same QA pipeline as data from LiveChat, Zendesk, RingCentral, and the rest.

Base URL

https://clients.belter.app/api/v1 production · all customers

The machine-readable OpenAPI specification is available at GET /api/v1/docs/openapi.json — drop it into Postman, Insomnia, or your codegen of choice.

One contract, every channel. The same envelope handles voice transcripts, web chat, email tickets, SMS, social DMs, and anything else with two roles and timestamped turns. If your platform exports a transcript, Belter can score it.

Quick start #

  1. Sign in to Belter and go to Admin → API Tokens.
  2. Create a token with the conversations.write scope.
  3. POST a conversation envelope to /ingest/conversations.
  4. The conversation appears in Belter and is queued for QA evaluation (when auto-evaluation is enabled).
First call · curl
curl -X POST "https://clients.belter.app/api/v1/ingest/conversations" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "ticket-123",
    "channel": "chat",
    "messages": [
      { "author": "customer", "body": "Hi" },
      { "author": "agent",    "body": "Hello — how can I help?" }
    ]
  }'

# → 201 Created · evaluation queued

That's the whole API in one call: an external_id (your system's stable ID), and one or more messages. Everything else is optional but worth providing for richer reporting.

Authentication #

All requests use Bearer-token auth. Tokens are organisation-scoped — all data you ingest belongs to the token owner's org.

Authorization: Bearer <your_api_token>
Content-Type: application/json

Auth failure modes

ConditionHTTP status
Missing or invalid token401 Unauthorized
Token lacks conversations.write403 Forbidden
Expired token401 Unauthorized

Rotate freely. Mint one token per integration and rotate at will. Revoking one doesn't affect the others, and the error response always lists the scopes a token actually has — handy when an integration suddenly returns 403 after a config change.

Endpoints #

Three ingestion shapes. Same envelope. Same scope.

Method Path Purpose Limit
POST /ingest/conversations Ingest one conversation. 1
POST /ingest/conversations/bulk Ingest up to 200 conversations in one request. 200
POST /ingest/conversations/csv Upload a CSV file. Each row is a single-message conversation.
POST /ingest/amazon-connect Native receiver for Amazon Connect Contact Lens / CTR payloads. 1

Required scope: conversations.write for all ingestion endpoints.

Conversation envelope #

Every ingestion request uses the same JSON schema. Send it as the request body (single endpoint) or as items inside a conversations array (bulk endpoint).

Full example envelope
{
  "external_id": "ticket-123",
  "channel": "voice",
  "subject": "Billing question",
  "status": "closed",
  "started_at": "2026-05-29T10:00:00Z",
  "closed_at": "2026-05-29T10:12:00Z",
  "customer": {
    "external_id": "cust-42",
    "email": "[email protected]",
    "name": "Ada Lovelace",
    "phone": "+441234567890"
  },
  "agent": {
    "email": "[email protected]"
  },
  "metadata": {
    "queue": "Billing",
    "priority": "high"
  },
  "messages": [
    {
      "author": "customer",
      "body": "Hi, I was charged twice this month.",
      "sent_at": "2026-05-29T10:00:00Z",
      "external_id": "msg-1"
    },
    {
      "author": "agent",
      "body": "Sorry about that — I'll check your account now.",
      "sent_at": "2026-05-29T10:02:00Z",
      "external_id": "msg-2"
    }
  ]
}

Top-level fields #

FieldTypeRequiredDescription
external_idstringYesUnique ID from your system. Dedupe key per organisation. Max 255 chars.
channelstringNoChannel label, e.g. voice, chat, email, ticket. Defaults to api.
subjectstringNoConversation subject or title. Max 500 chars.
statusstringNoe.g. open, closed. Defaults to open.
sourcestringNoOrigin label stored in metadata. Defaults to generic_ingestion.
started_atISO 8601 datetimeNoWhen the conversation started.
closed_atISO 8601 datetimeNoWhen the conversation ended.
metadataobjectNoArbitrary key/value data preserved on the conversation record.
customerobjectNoCustomer details (see below).
agentobjectNoAgent details (see below).
messagesarrayYes1–500 messages. Each requires body.

customer object #

FieldTypeDescription
external_idstringYour customer ID. Used for lookup before email.
emailstringCustomer email address.
namestringDisplay name. Defaults to Unknown Customer when creating.
phonestringPhone number.

At least one of external_id or email is needed to link a customer record.

agent object #

FieldTypeDescription
emailstringEmail of an existing Belter agent user.

Agents must already exist. Belter matches agents by email only and does not auto-create user accounts via the public API for security reasons. Unmatched agents are left unassigned — the conversation still imports cleanly. Provision agents in Belter first, then ingest.

messages array #

FieldTypeRequiredDescription
authorstringNoSee author mapping below. Defaults to customer.
bodystringYesMessage text. Empty bodies are skipped.
sent_atISO 8601 datetimeNoWhen the message was sent. Defaults to now.
external_idstringNoYour message ID. Used to avoid duplicate imports.
is_privatebooleanNoInternal note flag. Defaults to false.

Author mapping

author valueStored as
customercustomer
agent, staffagent
system, botsystem

Deduplication #

external_id is the dedupe key per organisation. The API is safe to retry — re-posting the same conversation only appends genuinely new messages.

ScenarioResultHTTP status
New conversationstatus: "imported"201 Created
Existing conversation, new messages appendedstatus: "imported"200 OK
Existing conversation, no new messagesstatus: "skipped"200 OK

When re-posting an existing conversation, only messages with a new external_id are appended. Messages without an external_id may be imported again — always include one when you can.

POST /ingest/conversations #

Ingest a single conversation.

POST /api/v1/ingest/conversations scope · conversations.write

Request

Headers:

Authorization: Bearer <token>
Content-Type: application/json

Body: one conversation envelope (see Conversation envelope above).

Response — imported

201 Created
{
  "data": {
    "status": "imported",
    "conversation_id": "550e8400-e29b-41d4-a716-446655440000"
  }
}

Response — skipped

200 OK
{
  "data": {
    "status": "skipped",
    "conversation_id": "550e8400-e29b-41d4-a716-446655440000",
    "reason": "No new messages"
  }
}

POST /ingest/conversations/bulk #

Ingest up to 200 conversations in one request. Each item is evaluated independently — one bad payload doesn't sink the rest.

POST /api/v1/ingest/conversations/bulk scope · conversations.write

Request

{
  "conversations": [
    {
      "external_id": "ticket-1",
      "channel": "chat",
      "messages": [
        { "author": "customer", "body": "Hello", "external_id": "m1" }
      ]
    },
    {
      "external_id": "ticket-2",
      "channel": "email",
      "messages": [
        { "author": "agent", "body": "Hi there", "external_id": "m2" }
      ]
    }
  ]
}

Response

200 OK · per-item results
{
  "data": {
    "imported": 2,
    "skipped": 0,
    "errors": 0,
    "details": [
      {
        "external_id": "ticket-1",
        "status": "imported",
        "conversation_id": "550e8400-e29b-41d4-a716-446655440001"
      },
      {
        "external_id": "ticket-2",
        "status": "skipped",
        "conversation_id": "550e8400-e29b-41d4-a716-446655440002",
        "reason": "No new messages"
      }
    ]
  }
}

Items that fail validation are counted in errors with an error message in the corresponding details[] entry.

POST /ingest/conversations/csv #

Upload a CSV file where each row becomes a single-message conversation. Use for flat transcript exports from platforms that can't easily POST JSON.

POST /api/v1/ingest/conversations/csv scope · conversations.write

Request

Authorization: Bearer <token>
Content-Type: multipart/form-data
Form fieldTypeRequiredDescription
filefileYesCSV or TXT file. Max 10 MB.

CSV headers

external_id, channel, subject, status, customer_email, customer_name, agent_email, author, body, sent_at

Required per row: external_id and body.

Example file · conversations.csv
external_id,channel,subject,status,customer_email,customer_name,agent_email,author,body,sent_at
ticket-100,chat,Billing issue,closed,[email protected],Jane Doe,[email protected],customer,I need a refund,2026-05-29T10:00:00Z
ticket-101,chat,Billing issue,closed,[email protected],Jane Doe,[email protected],agent,Happy to help with that,2026-05-29T10:01:00Z

Response

Same shape as the bulk endpoint — imported, skipped, errors, details.

POST /ingest/amazon-connect #

Native receiver for Amazon Connect's Contact Lens / Contact Trace Record (CTR) payloads. Amazon Connect delivers these via Kinesis; a small Lambda forwards each record to this endpoint and Belter normalises it into the generic envelope before evaluation.

POST /api/v1/ingest/amazon-connect scope · conversations.write

Request

Authorization: Bearer <token>
X-Amazon-Connect-Secret: <webhook_secret>
Content-Type: application/json

The payload may be the Contact Lens object directly, or wrapped as { "contact": { ... } } — Belter normalises both shapes.

Setup

  1. Enable Contact Lens on the Amazon Connect instance.
  2. In Belter, open Integrations → Amazon Connect and provide instance_id, aws_region, and a strong webhook_secret.
  3. Deploy a Lambda on the Contact Lens Kinesis stream that POSTs each record to the endpoint above with the shared secret and a valid API token.

The Lambda is small (~40 lines). We ship a reference implementation in our examples repo — wire the Kinesis stream's aws_lambda_function_arn to it, set two environment variables (API token + secret), and you're done.

Error responses #

Validation error

422 Unprocessable Entity
{
  "errors": {
    "external_id": ["The external id field is required."],
    "messages": ["The messages field is required."]
  }
}

Other errors

HTTPBodyCause
401{"error":"Unauthorized"}Missing Authorization header
401{"error":"Invalid token"}Unknown token
401{"error":"Token expired"}Token past expiry
403{"error":"Insufficient scope"}Token missing conversations.write
422{"error":"CSV is empty or missing headers."}Invalid CSV upload
422{"error":"No valid rows found..."}CSV has no usable rows (external_id + body required)

Auto-evaluation #

When a conversation is successfully imported, Belter may automatically queue AI evaluation and contact-driver detection if all of the following are true:

  • Auto-evaluation is enabled for your organisation
  • Onboarding is complete
  • An active evaluation version with a system prompt exists
  • The conversation meets eligibility rules (channel, message count, etc.)

You can also trigger evaluation manually — useful for back-filling old imports after rolling out a new rubric version:

POST /api/v1/evaluations/trigger scope · evaluations.write
POST /api/v1/evaluations/trigger
Authorization: Bearer <token>
Content-Type: application/json

{ "conversation_id": "550e8400-e29b-41d4-a716-446655440000" }

cURL examples #

Single conversation

curl -X POST "https://clients.belter.app/api/v1/ingest/conversations" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "ticket-123",
    "channel": "chat",
    "subject": "Billing question",
    "status": "closed",
    "customer": {
      "email": "[email protected]",
      "name": "Ada Lovelace"
    },
    "agent": {
      "email": "[email protected]"
    },
    "messages": [
      {
        "author": "customer",
        "body": "Hi, I was charged twice this month.",
        "external_id": "msg-1"
      },
      {
        "author": "agent",
        "body": "Sorry about that — I will check your account now.",
        "external_id": "msg-2"
      }
    ]
  }'

Bulk import

curl -X POST "https://clients.belter.app/api/v1/ingest/conversations/bulk" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conversations": [
      {
        "external_id": "ticket-1",
        "messages": [{ "author": "customer", "body": "Hello", "external_id": "m1" }]
      },
      {
        "external_id": "ticket-2",
        "messages": [{ "author": "agent", "body": "Hi there", "external_id": "m2" }]
      }
    ]
  }'

CSV upload

curl -X POST "https://clients.belter.app/api/v1/ingest/conversations/csv" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -F "[email protected]"

Integration patterns #

Three battle-tested shapes. Pick by what your source platform can do.

Real-time push

Your platform fires a webhook on conversation close → your middleware transforms the payload into a Belter envelope → POST to /ingest/conversations.

Best for live chat · CCaaS · helpdesk with webhooks

Scheduled batch sync

Your cron or worker pulls closed conversations every N minutes and POSTs them to /ingest/conversations/bulk — up to 200 at a time.

Best for REST-only systems · no webhooks

One-off migration

Export historical transcripts as CSV → POST to /ingest/conversations/csv. Use this once to back-fill QA history.

Best for legacy platform backfills

Ingestion isn't the whole API. Once data is in, these endpoints let you read it back out, kick off evaluations, and push CSAT signal alongside.

MethodEndpointScopeDescription
GET/conversationsconversations.readList imported conversations
GET/conversations/{id}conversations.readGet conversation details
GET/evaluationsevaluations.readList QA evaluation results
POST/evaluations/triggerevaluations.writeManually trigger evaluation
POST/csatcsat.writeSubmit CSAT scores
GET/webhooks/eventsnoneList outbound webhook event types

Full API reference is at /api/v1/docs/openapi.json.

FAQ #

Can I ingest voice call transcripts?
Yes. Set channel to voice and include the transcript as messages with author set to customer or agent. Belter scores the transcript the same way it scores a chat thread.
What happens if I send the same conversation twice?
Belter deduplicates by external_id. Only new messages (by message external_id) are appended. The endpoints are safe to retry — duplicate POSTs return status: "skipped" when there's nothing new.
Can I attach metadata from my system?
Yes. Use the top-level metadata object — all fields are preserved on the conversation record and surface in reporting filters and the conversation detail view.
Do I need a native Belter integration?
No. This API is the generic adapter. Use it whenever no pre-built connector exists for your platform. Most integrations stand up in a day or two of work on your side.
Where do I get an API token?
In the Belter app, go to Admin → API Tokens. Create a token, grant it the conversations.write scope, and copy the value once — we hash it on the server, so you can't read it again later.
Are there rate limits?
1,000 requests per minute per token, with up to 200 conversations per bulk call. X-RateLimit-* response headers report the current state. If you need a higher ceiling, talk to us.
How are agents matched?
By email only. Belter doesn't auto-create user accounts via this API for security reasons. If a message arrives with an unknown agent email, the conversation imports cleanly but the agent stays unassigned — you can re-assign later in the UI.

Ready to push your first conversation?

Mint a conversations.write token in the dashboard, POST one envelope, watch it score. Five-minute loop.