Uploading Calls and Chats
There are two ways to upload a call recording (or a chat transcript) and create a record — which one to use depends on where your audio already lives.
| Endpoint | Body | Use when… |
|---|---|---|
POST /records | JSON | You have a fileUrl Aelo can download from (or a chat transcript) |
POST /records/upload | multipart/form-data | You’re holding the audio bytes yourself and have nowhere to host them |
Both require the records:write scope.
Both also require the target project to be active. A paused or archived
project answers project_not_accepting_records (409) instead of accepting the
record — the project’s own admin needs to reactivate it before this
projectId can accept records again.
Size limits
Section titled “Size limits”| Path | Limit | Why |
|---|---|---|
POST /records (fileUrl) | 100 MB, or 25 MB when the source sends no Content-Length | Aelo streams the download straight to storage; a source that declares no length cannot be streamed and is held in memory instead |
POST /records (JSON body / textContent) | 10 MB body; textContent up to 1.9 MB (UTF-8) | The whole JSON body is read into memory before Aelo can validate it; the chat text is stored in one database row, which holds at most 2,000,000 bytes |
POST /records/upload (multipart) | 25 MB | A multipart body must be fully buffered in memory before Aelo can read it, so the ceiling is much lower |
If your file is larger than 25 MB, host it somewhere Aelo can reach over HTTPS
and call POST /records with fileUrl instead of uploading the bytes directly.
A chat over 100 KB is stored but not analyzed. A chat whose textContent
is longer than 100 KB (UTF-8) is accepted and appears in Records, but Aelo does
not analyze it and charges nothing for it: the record ends with status
not_applicable, and the upload response carries the chat_not_analyzed
warning. A textContent over 1.9 MB is refused with payload_too_large (413)
and creates nothing. Both sizes are counted on the text as Aelo stores it — trimmed, with phone numbers, e-mail addresses and card numbers already masked.
Uploading by URL
Section titled “Uploading by URL”curl -X POST https://api.aelo.cloud/api/public/v1/records \ -H "Authorization: Bearer aelo_sk_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "your-project-id", "type": "call", "externalId": "crm-call-12345", "fileUrl": "https://your-storage.example.com/call.mp3", "agentId": "42", "agentName": "Ivan Petrov", "durationSeconds": 184, "callDirection": "outbound", "crmEntityType": "deal", "crmEntityId": "9001" }'fileUrl must be https:// and resolve to a public address — Aelo rejects URLs
that point at private or link-local networks. Aelo waits up to 30 seconds for
the download; a source that’s slower or unreachable comes back as fetch_failed
(bad URL) or resolution_unavailable (Aelo’s own resolver, transient — retry).
The request must declare Content-Length. A chunked
(Transfer-Encoding: chunked) body is rejected with 400 before Aelo tries to
parse it — the same rule as the multipart upload below. This applies to the
whole request, not just type: "chat": a fileUrl body is tiny and never
comes close to the 10 MB limit, but the check runs before Aelo knows which
branch it is.
type is "call" or "chat":
type: "call"requiresfileUrland rejectstextContent.type: "chat"requirestextContent(the transcript text) and rejectsfileUrl.
Uploading a file directly
Section titled “Uploading a file directly”Formats. These endpoints accept any audio or video container the transcription provider can read — MP3, WAV, M4A, OGG, WebM and FLAC among them, and AAC, AIFF and Opus besides. They are deliberately more permissive than the in-app upload, whose dropzone offers the six formats the user guide lists, because integrations send whatever their CRM or telephony recorded. A container nothing can decode fails during transcription rather than at upload.
curl -X POST https://api.aelo.cloud/api/public/v1/records/upload \ -H "Authorization: Bearer aelo_sk_YOUR_KEY" \ -F "file=@call.mp3" \ -F "projectId=your-project-id" \ -F "externalId=crm-call-12345" \ -F "agentId=42" \ -F "agentName=Ivan Petrov" \ -F "callDirection=outbound"The audio goes in the part named file; every other part is a metadata field,
using the same names as the JSON body (values arrive as strings and are coerced
where needed — durationSeconds, for instance). Only calls can be uploaded this
way: type defaults to "call", and sending type=chat is rejected — post a
chat transcript to POST /records with textContent instead. If your form
happens to include a second part also named file, only the first one is read;
the rest is silently ignored.
The request must declare Content-Length. A chunked
(Transfer-Encoding: chunked) body is rejected with 400 before Aelo tries to
parse it. Every ordinary client sets this automatically — curl -F, fetch
with a File/Blob body, and every SDK’s multipart helper — so this only bites
a request built by hand-streaming a ReadableStream.
Required and optional fields
Section titled “Required and optional fields”| Field | Required | Notes |
|---|---|---|
projectId | Yes | Surrounding whitespace is trimmed before lookup; whitespace-only is rejected |
type | JSON: yes · multipart: no | call or chat; multipart defaults to call and never accepts chat |
fileUrl | JSON, calls only | Not accepted for type: "chat" |
textContent | JSON, chats only | Not accepted for type: "call". Up to 1.9 MB (UTF-8); over 100 KB the chat is stored but not analyzed |
externalId | No | Your own id for this record — see Idempotency below |
occurredAt | No | ISO-8601. Business time of the call — see the note on dashboards below. Rejected before 2000-01-01 or more than 24h in the future |
agentId | No | Free-form: CRM ids are usually numeric strings, telephony ids are operator ids. See Warnings |
agentName, participantName, participantPhone, agentPhone | No | |
durationSeconds | No | Leave it out if you don’t have it — see Warnings. 0–86400 (24h); outside that the request is rejected. A fractional value is accepted and stored rounded to the nearest second |
callDirection | No | inbound or outbound |
language | No | 2–8 characters |
crmEntityType | No | deal, lead, contact or company |
crmEntityId, crmEntityName, crmActivityName | No |
Every field this API doesn’t recognize is rejected with invalid_request rather
than silently dropped — a typo like projectID fails loudly instead of quietly
losing data.
A blank optional field means “not sent.” If a form field arrives empty or
whitespace-only (-F "agentId=", or an unset shell variable interpolated into a
form), Aelo treats it as absent rather than as an empty string — including
externalId and agentId. A blank required field, on the other hand, is a
400: it does not fall back to “not sent.” This distinction only matters for
multipart bodies; a JSON "" is a value you typed on purpose and is handled as
written.
Idempotency
Section titled “Idempotency”externalId is your own id for a record — a CRM activity id, a telephony call
id, whatever your system already tracks. Send the same externalId again and
Aelo returns the existing record instead of creating a duplicate (200 rather
than 201).
The uniqueness is per organization, not per project. If you send the same
externalId under two different projectIds in the same organization, the
second call does not create a second record — it returns the first
project’s record. If your integration reuses call ids across separate projects,
either keep them project-unique on your side or expect the second upload to
resolve to the first project’s record.
Warnings
Section titled “Warnings”A successful upload (200 or 201) can still carry warnings — the record was
created, but some analytics will not see it:
| Code | When | Message |
|---|---|---|
missing_agent_id | No agentId | ”No agentId supplied: this record is excluded from agent ratings (they filter on agent_id IS NOT NULL).” |
missing_crm_entity | No crmEntityType/crmEntityId pair | ”No crmEntityType/crmEntityId pair: this record joins no call series, so it appears in no funnel or deal outcome.” |
missing_duration | type: "call" with no durationSeconds | ”No durationSeconds: leaderboard totals and short-call detection ignore this record.” |
chat_not_analyzed | type: "chat" with textContent over 100 KB | ”textContent is over 100 KB: the chat is stored but not analysed, and nothing is charged for it.” |
warnings only appears on the two upload responses — it is not part of the
record shape returned by the GET endpoints. A blank durationSeconds in a
multipart form is treated the same as omitting it: Aelo never stores it as 0,
and the missing_duration warning still fires.
Dashboards bucket by upload time
Section titled “Dashboards bucket by upload time”Aelo stores occurredAt on the record, but v1 dashboards and reports bucket
calls by the time Aelo received them (createdAt), not by occurredAt. If you
back-post historical calls with an occurredAt from weeks or months ago,
they show up in today’s numbers, not the period they actually happened in. Bulk
historical import is not supported in v1 for this reason — this API is for
uploading calls and chats as they happen.
Related
Section titled “Related”- Public API overview — authentication, the read endpoints, and the full error table