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 | The whole JSON body is read into memory before Aelo can validate it, so the ceiling matches the multipart chat cap below |
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.
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”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" |
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 |
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.” |
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