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.
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
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 #
- Sign in to Belter and go to Admin → API Tokens.
- Create a token with the
conversations.writescope. - POST a conversation envelope to
/ingest/conversations. - The conversation appears in Belter and is queued for QA evaluation (when auto-evaluation is enabled).
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
| Condition | HTTP status |
|---|---|
| Missing or invalid token | 401 Unauthorized |
Token lacks conversations.write | 403 Forbidden |
| Expired token | 401 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).
{ "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 #
| Field | Type | Required | Description |
|---|---|---|---|
| external_id | string | Yes | Unique ID from your system. Dedupe key per organisation. Max 255 chars. |
| channel | string | No | Channel label, e.g. voice, chat, email, ticket. Defaults to api. |
| subject | string | No | Conversation subject or title. Max 500 chars. |
| status | string | No | e.g. open, closed. Defaults to open. |
| source | string | No | Origin label stored in metadata. Defaults to generic_ingestion. |
| started_at | ISO 8601 datetime | No | When the conversation started. |
| closed_at | ISO 8601 datetime | No | When the conversation ended. |
| metadata | object | No | Arbitrary key/value data preserved on the conversation record. |
| customer | object | No | Customer details (see below). |
| agent | object | No | Agent details (see below). |
| messages | array | Yes | 1–500 messages. Each requires body. |
customer object #
| Field | Type | Description |
|---|---|---|
| external_id | string | Your customer ID. Used for lookup before email. |
| string | Customer email address. | |
| name | string | Display name. Defaults to Unknown Customer when creating. |
| phone | string | Phone number. |
At least one of external_id or email is needed to link a customer record.
agent object #
| Field | Type | Description |
|---|---|---|
| string | Email 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 #
| Field | Type | Required | Description |
|---|---|---|---|
| author | string | No | See author mapping below. Defaults to customer. |
| body | string | Yes | Message text. Empty bodies are skipped. |
| sent_at | ISO 8601 datetime | No | When the message was sent. Defaults to now. |
| external_id | string | No | Your message ID. Used to avoid duplicate imports. |
| is_private | boolean | No | Internal note flag. Defaults to false. |
Author mapping
| author value | Stored as |
|---|---|
| customer | customer |
| agent, staff | agent |
| system, bot | system |
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.
| Scenario | Result | HTTP status |
|---|---|---|
| New conversation | status: "imported" | 201 Created |
| Existing conversation, new messages appended | status: "imported" | 200 OK |
| Existing conversation, no new messages | status: "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.
Request
Headers:
Authorization: Bearer <token> Content-Type: application/json
Body: one conversation envelope (see Conversation envelope above).
Response — imported
{ "data": { "status": "imported", "conversation_id": "550e8400-e29b-41d4-a716-446655440000" } }
Response — skipped
{ "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.
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
{ "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.
Request
Authorization: Bearer <token> Content-Type: multipart/form-data
| Form field | Type | Required | Description |
|---|---|---|---|
| file | file | Yes | CSV 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.
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.
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
- Enable Contact Lens on the Amazon Connect instance.
- In Belter, open Integrations → Amazon Connect and provide
instance_id,aws_region, and a strongwebhook_secret. - 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
{ "errors": { "external_id": ["The external id field is required."], "messages": ["The messages field is required."] } }
Other errors
| HTTP | Body | Cause |
|---|---|---|
| 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 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.
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.
One-off migration
Export historical transcripts as CSV → POST to /ingest/conversations/csv. Use this once to back-fill QA history.
Related endpoints #
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.
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /conversations | conversations.read | List imported conversations |
| GET | /conversations/{id} | conversations.read | Get conversation details |
| GET | /evaluations | evaluations.read | List QA evaluation results |
| POST | /evaluations/trigger | evaluations.write | Manually trigger evaluation |
| POST | /csat | csat.write | Submit CSAT scores |
| GET | /webhooks/events | none | List outbound webhook event types |
Full API reference is at /api/v1/docs/openapi.json.
FAQ #
Can I ingest voice call transcripts?
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?
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?
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?
Where do I get an API token?
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?
X-RateLimit-* response headers report the current state. If you need a higher ceiling, talk to us.How are agents matched?
Ready to push your first conversation?
Mint a conversations.write token in the dashboard, POST one envelope, watch it score. Five-minute loop.