Skip to content

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.

EndpointBodyUse when…
POST /recordsJSONYou have a fileUrl Aelo can download from (or a chat transcript)
POST /records/uploadmultipart/form-dataYou’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.

PathLimitWhy
POST /records (fileUrl)100 MB, or 25 MB when the source sends no Content-LengthAelo 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 MBThe 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 MBA 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.

Terminal window
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" requires fileUrl and rejects textContent.
  • type: "chat" requires textContent (the transcript text) and rejects fileUrl.
Terminal window
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.

FieldRequiredNotes
projectIdYesSurrounding whitespace is trimmed before lookup; whitespace-only is rejected
typeJSON: yes · multipart: nocall or chat; multipart defaults to call and never accepts chat
fileUrlJSON, calls onlyNot accepted for type: "chat"
textContentJSON, chats onlyNot accepted for type: "call"
externalIdNoYour own id for this record — see Idempotency below
occurredAtNoISO-8601. Business time of the call — see the note on dashboards below. Rejected before 2000-01-01 or more than 24h in the future
agentIdNoFree-form: CRM ids are usually numeric strings, telephony ids are operator ids. See Warnings
agentName, participantName, participantPhone, agentPhoneNo
durationSecondsNoLeave it out if you don’t have it — see Warnings. 0–86400 (24h); outside that the request is rejected
callDirectionNoinbound or outbound
languageNo2–8 characters
crmEntityTypeNodeal, lead, contact or company
crmEntityId, crmEntityName, crmActivityNameNo

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.

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.

A successful upload (200 or 201) can still carry warnings — the record was created, but some analytics will not see it:

CodeWhenMessage
missing_agent_idNo agentId”No agentId supplied: this record is excluded from agent ratings (they filter on agent_id IS NOT NULL).”
missing_crm_entityNo crmEntityType/crmEntityId pair”No crmEntityType/crmEntityId pair: this record joins no call series, so it appears in no funnel or deal outcome.”
missing_durationtype: "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.

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.